Merge branch 'new-plugin-system' into 'master'
LabThings API base See merge request openflexure/openflexure-microscope-server!36
This commit is contained in:
commit
fc6bea3359
103 changed files with 4207 additions and 3853 deletions
|
|
@ -2,7 +2,7 @@ from openflexure_microscope.plugins import MicroscopePlugin
|
||||||
from openflexure_microscope.api.views import MicroscopeViewPlugin
|
from openflexure_microscope.api.views import MicroscopeViewPlugin
|
||||||
from openflexure_microscope.api.utilities import JsonResponse
|
from openflexure_microscope.api.utilities import JsonResponse
|
||||||
|
|
||||||
from openflexure_microscope.common.tasks import taskify
|
from openflexure_microscope.common.labthings_core.tasks import taskify
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
__all__ = ["Microscope", "config", "task", "utilities"]
|
__all__ = ["Microscope", "config", "utilities"]
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
from .microscope import Microscope
|
from .microscope import Microscope
|
||||||
from . import config
|
from . import config
|
||||||
from . import utilities
|
from . import utilities
|
||||||
from . import task
|
|
||||||
from . import common
|
from . import common
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
__all__ = ["utilities", "views"]
|
|
||||||
|
|
||||||
from . import utilities, views
|
|
||||||
|
|
@ -3,189 +3,117 @@
|
||||||
import time
|
import time
|
||||||
import atexit
|
import atexit
|
||||||
import logging
|
import logging
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import pkg_resources
|
||||||
|
|
||||||
from flask import Flask, jsonify, send_file
|
from flask import Flask, jsonify, send_file
|
||||||
|
|
||||||
from serial import SerialException
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS, cross_origin
|
||||||
|
|
||||||
from openflexure_microscope.api.exceptions import JSONExceptionHandler
|
from openflexure_microscope.api.utilities import list_routes, init_default_extensions
|
||||||
from openflexure_microscope.api.utilities import list_routes
|
|
||||||
|
|
||||||
from openflexure_microscope import Microscope
|
from openflexure_microscope.config import (
|
||||||
|
settings_file_path,
|
||||||
|
JSONEncoder,
|
||||||
|
USER_EXTENSIONS_PATH,
|
||||||
|
)
|
||||||
|
|
||||||
from openflexure_microscope.camera.capture import build_captures_from_exif
|
from openflexure_microscope.common.flask_labthings.quick import create_app
|
||||||
from openflexure_microscope.config import settings_file_path, JSONEncoder
|
from openflexure_microscope.common.flask_labthings.extensions import find_extensions
|
||||||
from openflexure_microscope.api.v1 import blueprints
|
|
||||||
from openflexure_microscope.api import v2
|
|
||||||
|
|
||||||
# Import device modules
|
from openflexure_microscope.api.microscope import default_microscope as api_microscope
|
||||||
# NB this will eventually be handled by the RC file, so you can choose what device
|
|
||||||
# class should be attached.
|
|
||||||
try:
|
|
||||||
from openflexure_microscope.camera.pi import PiCameraStreamer
|
|
||||||
except ImportError:
|
|
||||||
logging.warning("Unable to import PiCameraStreamer")
|
|
||||||
from openflexure_microscope.camera.mock import MockStreamer
|
|
||||||
|
|
||||||
from openflexure_microscope.stage.sanga import SangaStage
|
|
||||||
from openflexure_microscope.stage.mock import MockStage
|
|
||||||
|
|
||||||
|
from openflexure_microscope.api.v2 import views
|
||||||
|
|
||||||
# Handle logging
|
# Handle logging
|
||||||
is_gunicorn = "gunicorn" in os.environ.get("SERVER_SOFTWARE", "")
|
is_gunicorn = "gunicorn" in os.environ.get("SERVER_SOFTWARE", "")
|
||||||
|
|
||||||
DEFAULT_LOGFILE = settings_file_path("openflexure_microscope.log")
|
DEFAULT_LOGFILE = settings_file_path("openflexure_microscope.log")
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
if (__name__ == "__main__") or (not is_gunicorn):
|
if (__name__ == "__main__") or (not is_gunicorn):
|
||||||
# If imported, but not by gunicorn
|
# If imported, but not by gunicorn
|
||||||
print("Letting sys handle logs")
|
print("Letting sys handle logs")
|
||||||
logger = logging.getLogger()
|
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
else:
|
else:
|
||||||
# Direct standard Python logging to file and console
|
# Direct standard Python logging to file and console
|
||||||
root = logging.getLogger()
|
|
||||||
error_formatter = logging.Formatter(
|
error_formatter = logging.Formatter(
|
||||||
"[%(asctime)s] [%(threadName)s] [%(levelname)s] %(message)s"
|
"[%(asctime)s] [%(threadName)s] [%(levelname)s] %(message)s"
|
||||||
)
|
)
|
||||||
|
|
||||||
rotating_logfile = logging.handlers.RotatingFileHandler(
|
rotating_logfile = logging.handlers.RotatingFileHandler(
|
||||||
DEFAULT_LOGFILE, maxBytes=1000000, backupCount=7
|
DEFAULT_LOGFILE, maxBytes=1_000_000, backupCount=7
|
||||||
)
|
)
|
||||||
|
|
||||||
error_handlers = [rotating_logfile, logging.StreamHandler()]
|
error_handlers = [rotating_logfile, logging.StreamHandler()]
|
||||||
|
|
||||||
for handler in error_handlers:
|
for handler in error_handlers:
|
||||||
handler.setFormatter(error_formatter)
|
handler.setFormatter(error_formatter)
|
||||||
root.addHandler(handler)
|
logger.addHandler(handler)
|
||||||
|
|
||||||
root.setLevel(logging.getLogger("gunicorn.error").level)
|
logger.setLevel(logging.getLogger("gunicorn.error").level)
|
||||||
|
|
||||||
# Create a dummy microscope object, with no hardware attachments
|
|
||||||
api_microscope = Microscope()
|
|
||||||
|
|
||||||
# Initialise camera
|
|
||||||
logging.debug("Creating camera object...")
|
|
||||||
try:
|
|
||||||
api_camera = PiCameraStreamer()
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(e)
|
|
||||||
logging.warning("No valid camera hardware found. Falling back to mock camera!")
|
|
||||||
api_camera = MockStreamer()
|
|
||||||
|
|
||||||
# Initialise stage
|
|
||||||
logging.debug("Creating stage object...")
|
|
||||||
try:
|
|
||||||
api_stage = SangaStage()
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(e)
|
|
||||||
logging.warning("No valid stage hardware found. Falling back to mock stage!")
|
|
||||||
api_stage = MockStage()
|
|
||||||
|
|
||||||
# Attach devices to microscope
|
|
||||||
logging.debug("Attaching devices to microscope...")
|
|
||||||
api_microscope.attach(api_camera, api_stage)
|
|
||||||
|
|
||||||
# Restore loaded capture array to camera object
|
|
||||||
logging.debug("Restoring captures...")
|
|
||||||
api_microscope.camera.images = build_captures_from_exif(
|
|
||||||
api_microscope.camera.paths["default"]
|
|
||||||
)
|
|
||||||
|
|
||||||
logging.debug("Microscope successfully attached!")
|
|
||||||
|
|
||||||
|
|
||||||
# Generate API URI based on version from filename
|
|
||||||
def uri(suffix, api_version, base=None):
|
|
||||||
if not base:
|
|
||||||
base = "/api/{}".format(api_version)
|
|
||||||
return_uri = base + suffix
|
|
||||||
logging.debug("Created app route: {}".format(return_uri))
|
|
||||||
return return_uri
|
|
||||||
|
|
||||||
|
|
||||||
# Create flask app
|
# Create flask app
|
||||||
app = Flask(__name__)
|
app, labthing = create_app(
|
||||||
app.url_map.strict_slashes = False
|
__name__,
|
||||||
|
prefix="/api/v2",
|
||||||
|
title=f"OpenFlexure Microscope {api_microscope.name}",
|
||||||
|
description="Test LabThing-based API for OpenFlexure Microscope",
|
||||||
|
version=pkg_resources.get_distribution("openflexure_microscope").version,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enable CORS for some routes outside of LabThings
|
||||||
|
cors = CORS(app)
|
||||||
|
|
||||||
# Use custom JSON encoder
|
# Use custom JSON encoder
|
||||||
app.json_encoder = JSONEncoder
|
app.json_encoder = JSONEncoder
|
||||||
|
|
||||||
# Enable CORS everywhere
|
# Attach lab devices
|
||||||
CORS(app, resources=r"*")
|
labthing.add_component(api_microscope, "org.openflexure.microscope")
|
||||||
|
|
||||||
# Make errors more API friendly
|
# Attach extensions
|
||||||
handler = JSONExceptionHandler(app)
|
if not os.path.isfile(USER_EXTENSIONS_PATH):
|
||||||
|
init_default_extensions(USER_EXTENSIONS_PATH)
|
||||||
|
for extension in find_extensions(USER_EXTENSIONS_PATH):
|
||||||
|
labthing.register_extension(extension)
|
||||||
|
|
||||||
# WEBAPP ROUTES
|
# Attach captures resources
|
||||||
|
labthing.add_view(views.CaptureList, f"/captures")
|
||||||
|
labthing.add_root_link(views.CaptureList, "captures")
|
||||||
|
|
||||||
# Base routes
|
labthing.add_view(views.CaptureView, f"/captures/<id>")
|
||||||
base_blueprint = blueprints.base.construct_blueprint(api_microscope)
|
labthing.add_view(views.CaptureDownload, f"/captures/<id>/download/<filename>")
|
||||||
app.register_blueprint(base_blueprint, url_prefix=uri("", "v1"))
|
labthing.add_view(views.CaptureTags, f"/captures/<id>/tags")
|
||||||
|
labthing.add_view(views.CaptureMetadata, f"/captures/<id>/metadata")
|
||||||
|
|
||||||
# Stage routes
|
# Attach settings and status resources
|
||||||
stage_blueprint = blueprints.stage.construct_blueprint(api_microscope)
|
labthing.add_view(views.SettingsProperty, f"/settings")
|
||||||
app.register_blueprint(stage_blueprint, url_prefix=uri("/stage", "v1"))
|
labthing.add_root_link(views.SettingsProperty, "settings")
|
||||||
|
labthing.add_view(views.NestedSettingsProperty, "/settings/<path:route>")
|
||||||
|
labthing.add_view(views.StatusProperty, "/status")
|
||||||
|
labthing.add_view(views.NestedStatusProperty, "/status/<path:route>")
|
||||||
|
labthing.add_root_link(views.StatusProperty, "status")
|
||||||
|
|
||||||
# Camera routes
|
# Attach streams resources
|
||||||
camera_blueprint = blueprints.camera.construct_blueprint(api_microscope)
|
labthing.add_view(views.MjpegStream, f"/streams/mjpeg")
|
||||||
app.register_blueprint(camera_blueprint, url_prefix=uri("/camera", "v1"))
|
labthing.add_view(views.SnapshotStream, f"/streams/snapshot")
|
||||||
|
|
||||||
# Plugin routes
|
# Attach microscope action resources
|
||||||
plugin_blueprint = blueprints.plugins.construct_blueprint(api_microscope)
|
labthing.add_view(views.actions.ActionsView, "/actions")
|
||||||
app.register_blueprint(plugin_blueprint, url_prefix=uri("/plugin", "v1"))
|
for name, action in views.enabled_root_actions().items():
|
||||||
|
view_class = action["view_class"]
|
||||||
# Task routes
|
rule = action["rule"]
|
||||||
task_blueprint = blueprints.task.construct_blueprint(api_microscope)
|
labthing.add_view(view_class, f"/actions{rule}")
|
||||||
app.register_blueprint(task_blueprint, url_prefix=uri("/task", "v1"))
|
|
||||||
|
|
||||||
### V2
|
|
||||||
# Root routes
|
|
||||||
v2_root_blueprint = v2.blueprints.root.construct_blueprint(api_microscope)
|
|
||||||
app.register_blueprint(v2_root_blueprint, url_prefix=uri("/", "v2"))
|
|
||||||
|
|
||||||
v2_streams_blueprint = v2.blueprints.streams.construct_blueprint(api_microscope)
|
|
||||||
app.register_blueprint(v2_streams_blueprint, url_prefix=uri("/streams", "v2"))
|
|
||||||
|
|
||||||
# Captures routes
|
|
||||||
v2_captures_blueprint = v2.blueprints.captures.construct_blueprint(api_microscope)
|
|
||||||
app.register_blueprint(v2_captures_blueprint, url_prefix=uri("/captures", "v2"))
|
|
||||||
|
|
||||||
# Settings routes
|
|
||||||
v2_settings_blueprint = v2.blueprints.settings.construct_blueprint(api_microscope)
|
|
||||||
app.register_blueprint(v2_settings_blueprint, url_prefix=uri("/settings", "v2"))
|
|
||||||
|
|
||||||
# Status routes
|
|
||||||
v2_status_blueprint = v2.blueprints.status.construct_blueprint(api_microscope)
|
|
||||||
app.register_blueprint(v2_status_blueprint, url_prefix=uri("/status", "v2"))
|
|
||||||
|
|
||||||
# Plugins routes
|
|
||||||
v2_plugin_blueprint = v2.blueprints.plugins.construct_blueprint(api_microscope)
|
|
||||||
app.register_blueprint(v2_plugin_blueprint, url_prefix=uri("/plugins", "v2"))
|
|
||||||
|
|
||||||
# Tasks routes
|
|
||||||
v2_tasks_blueprint = v2.blueprints.tasks.construct_blueprint(api_microscope)
|
|
||||||
app.register_blueprint(v2_tasks_blueprint, url_prefix=uri("/tasks", "v2"))
|
|
||||||
|
|
||||||
# Actions routes
|
|
||||||
v2_actions_blueprint = v2.blueprints.actions.construct_blueprint(api_microscope)
|
|
||||||
app.register_blueprint(v2_actions_blueprint, url_prefix=uri("/actions", "v2"))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/routes")
|
@app.route("/routes")
|
||||||
|
@cross_origin()
|
||||||
def routes():
|
def routes():
|
||||||
"""
|
"""
|
||||||
List of all connected API routes
|
List of all connected API routes
|
||||||
|
|
||||||
.. :quickref: Global; Routes
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
:>header Content-Type: application/json
|
|
||||||
:status 200: stream active
|
|
||||||
"""
|
"""
|
||||||
return jsonify(list_routes(app))
|
return jsonify(list_routes(app))
|
||||||
|
|
||||||
|
|
@ -194,12 +122,6 @@ def routes():
|
||||||
def err_log():
|
def err_log():
|
||||||
"""
|
"""
|
||||||
Most recent 1mb of log output
|
Most recent 1mb of log output
|
||||||
|
|
||||||
.. :quickref: Global; Log
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
:>header Content-Type: application/json
|
|
||||||
:status 200: stream active
|
|
||||||
"""
|
"""
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
return send_file(
|
return send_file(
|
||||||
|
|
@ -211,7 +133,6 @@ def err_log():
|
||||||
|
|
||||||
# Automatically clean up microscope at exit
|
# Automatically clean up microscope at exit
|
||||||
def cleanup():
|
def cleanup():
|
||||||
global api_microscope
|
|
||||||
logging.debug("App teardown started...")
|
logging.debug("App teardown started...")
|
||||||
logging.debug("Settling...")
|
logging.debug("Settling...")
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .autofocus import autofocus_extension_v2
|
||||||
|
from .scan import scan_extension_v2
|
||||||
|
from .zip_builder import zip_extension_v2
|
||||||
369
openflexure_microscope/api/default_extensions/autofocus.py
Normal file
369
openflexure_microscope/api/default_extensions/autofocus.py
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
from openflexure_microscope.common.flask_labthings.find import find_component
|
||||||
|
from openflexure_microscope.common.flask_labthings.extensions import BaseExtension
|
||||||
|
from openflexure_microscope.common.flask_labthings.view import View
|
||||||
|
from openflexure_microscope.common.flask_labthings.decorators import (
|
||||||
|
ThingAction,
|
||||||
|
ThingProperty,
|
||||||
|
)
|
||||||
|
|
||||||
|
from openflexure_microscope.devel import JsonResponse, request, jsonify, taskify, abort
|
||||||
|
from openflexure_microscope.utilities import set_properties
|
||||||
|
|
||||||
|
import time
|
||||||
|
import numpy as np
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
from scipy import ndimage
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
### Autofocus utilities
|
||||||
|
|
||||||
|
|
||||||
|
class JPEGSharpnessMonitor:
|
||||||
|
def __init__(self, microscope, timeout=60):
|
||||||
|
self.microscope = microscope
|
||||||
|
self.camera = microscope.camera
|
||||||
|
self.stage = microscope.stage
|
||||||
|
self.jpeg_sizes = []
|
||||||
|
self.jpeg_times = []
|
||||||
|
self.stage_positions = []
|
||||||
|
self.stage_times = []
|
||||||
|
self.stop_event = threading.Event()
|
||||||
|
self.timeout = timeout
|
||||||
|
self.keep_alive()
|
||||||
|
self.background_thread = None
|
||||||
|
|
||||||
|
def is_alive(self):
|
||||||
|
if self.background_thread is None:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return self.background_thread.is_alive()
|
||||||
|
|
||||||
|
def should_stop(self):
|
||||||
|
import time
|
||||||
|
|
||||||
|
return time.time() - self.kept_alive > self.timeout
|
||||||
|
|
||||||
|
def keep_alive(self):
|
||||||
|
import time
|
||||||
|
|
||||||
|
self.kept_alive = time.time()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"Start monitoring sharpness by looking at JPEG size"
|
||||||
|
self.background_thread = threading.Thread(target=self._measure_jpegs)
|
||||||
|
self.background_thread.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"Stop the background thread"
|
||||||
|
self.stop_event.set()
|
||||||
|
self.background_thread.join()
|
||||||
|
|
||||||
|
def _measure_jpegs(self):
|
||||||
|
"Function that runs in a background thread to record sharpness"
|
||||||
|
logging.info("Starting sharpness measurement in background thread")
|
||||||
|
self.keep_alive()
|
||||||
|
while not self.stop_event.is_set() and not self.should_stop():
|
||||||
|
self.jpeg_sizes.append(self.jpeg_size())
|
||||||
|
self.jpeg_times.append(time.time())
|
||||||
|
if self.stop_event.is_set():
|
||||||
|
logging.info("Cleanly stopped sharpness measurement in background thread")
|
||||||
|
if self.should_stop():
|
||||||
|
logging.info("Sharpness measurement timed out and has stopped")
|
||||||
|
|
||||||
|
def jpeg_size(self):
|
||||||
|
"""Return the size of a frame from the MJPEG stream"""
|
||||||
|
return len(self.camera.get_frame())
|
||||||
|
|
||||||
|
def focus_rel(self, dz, backlash=False, **kwargs):
|
||||||
|
self.keep_alive()
|
||||||
|
self.stage_times.append(time.time())
|
||||||
|
self.stage_positions.append(self.stage.position)
|
||||||
|
self.stage.move_rel([0, 0, dz], backlash=backlash, **kwargs)
|
||||||
|
self.stage_times.append(time.time())
|
||||||
|
self.stage_positions.append(self.stage.position)
|
||||||
|
i = len(self.stage_positions) - 2
|
||||||
|
return i, self.stage_positions[-1][2]
|
||||||
|
|
||||||
|
def move_data(self, istart, istop=None):
|
||||||
|
"Extract sharpness as a function of (interpolated) z"
|
||||||
|
global np, logging
|
||||||
|
if istop is None:
|
||||||
|
istop = istart + 2
|
||||||
|
jpeg_times = np.array(self.jpeg_times)
|
||||||
|
jpeg_sizes = np.array(self.jpeg_sizes)
|
||||||
|
stage_times = np.array(self.stage_times)[istart:istop]
|
||||||
|
stage_zs = np.array(self.stage_positions)[istart:istop, 2]
|
||||||
|
start = np.argmax(jpeg_times > stage_times[0])
|
||||||
|
stop = np.argmax(jpeg_times > stage_times[1])
|
||||||
|
if stop < 1:
|
||||||
|
stop = len(jpeg_times)
|
||||||
|
logging.debug("changing stop to {}".format(stop))
|
||||||
|
jpeg_times = jpeg_times[start:stop]
|
||||||
|
jpeg_zs = np.interp(jpeg_times, stage_times, stage_zs)
|
||||||
|
return jpeg_times, jpeg_zs, jpeg_sizes[start:stop]
|
||||||
|
|
||||||
|
def sharpest_z_on_move(self, index):
|
||||||
|
"""Return the z position of the sharpest image on a given move"""
|
||||||
|
jt, jz, js = self.move_data(index)
|
||||||
|
return jz[np.argmax(js)]
|
||||||
|
|
||||||
|
def data_dict(self):
|
||||||
|
"""Return the gathered data as a single convenient dictionary"""
|
||||||
|
data = {}
|
||||||
|
for k in ["jpeg_times", "jpeg_sizes", "stage_times", "stage_positions"]:
|
||||||
|
data[k] = getattr(self, k)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def decimate_to(shape, image):
|
||||||
|
"""Decimate an image to reduce its size if it's too big."""
|
||||||
|
decimation = np.max(
|
||||||
|
np.ceil(np.array(image.shape, dtype=np.float)[: len(shape)] / np.array(shape))
|
||||||
|
)
|
||||||
|
return image[:: int(decimation), :: int(decimation), ...]
|
||||||
|
|
||||||
|
|
||||||
|
def sharpness_sum_lap2(rgb_image):
|
||||||
|
"""Return an image sharpness metric: sum(laplacian(image)**")"""
|
||||||
|
# image_bw=np.mean(decimate_to((1000,1000), rgb_image),2)
|
||||||
|
image_bw = np.mean(rgb_image, 2)
|
||||||
|
image_lap = ndimage.filters.laplace(image_bw)
|
||||||
|
return np.mean(image_lap.astype(np.float) ** 4)
|
||||||
|
|
||||||
|
|
||||||
|
def sharpness_edge(image):
|
||||||
|
"""Return a sharpness metric optimised for vertical lines"""
|
||||||
|
gray = np.mean(image.astype(float), 2)
|
||||||
|
n = 20
|
||||||
|
edge = np.array([[-1] * n + [1] * n])
|
||||||
|
return np.sum(
|
||||||
|
[np.sum(ndimage.filters.convolve(gray, W) ** 2) for W in [edge, edge.T]]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
### Autofocus extension
|
||||||
|
|
||||||
|
|
||||||
|
def measure_sharpness(microscope, metric_fn=sharpness_sum_lap2):
|
||||||
|
"""Measure the sharpness of the camera's current view."""
|
||||||
|
if hasattr(microscope.camera, "array"):
|
||||||
|
return metric_fn(microscope.camera.array(use_video_port=True))
|
||||||
|
|
||||||
|
|
||||||
|
def autofocus(microscope, dz, settle=0.5, metric_fn=sharpness_sum_lap2):
|
||||||
|
"""Perform a simple autofocus routine.
|
||||||
|
The stage is moved to z positions (relative to current position) in dz,
|
||||||
|
and at each position an image is captured and the sharpness function
|
||||||
|
evaulated. We then move back to the position where the sharpness was
|
||||||
|
highest. No interpolation is performed.
|
||||||
|
dz is assumed to be in ascending order (starting at -ve values)
|
||||||
|
"""
|
||||||
|
camera = microscope.camera
|
||||||
|
stage = microscope.stage
|
||||||
|
|
||||||
|
with set_properties(stage, backlash=256), stage.lock, camera.lock:
|
||||||
|
sharpnesses = []
|
||||||
|
positions = []
|
||||||
|
camera.annotate_text = ""
|
||||||
|
|
||||||
|
for _ in stage.scan_z(dz, return_to_start=False):
|
||||||
|
positions.append(stage.position[2])
|
||||||
|
time.sleep(settle)
|
||||||
|
sharpnesses.append(measure_sharpness(microscope, metric_fn))
|
||||||
|
|
||||||
|
newposition = positions[np.argmax(sharpnesses)]
|
||||||
|
stage.move_rel([0, 0, newposition - stage.position[2]])
|
||||||
|
|
||||||
|
return positions, sharpnesses
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def monitor_sharpness(microscope):
|
||||||
|
m = JPEGSharpnessMonitor(microscope)
|
||||||
|
m.start()
|
||||||
|
try:
|
||||||
|
yield m
|
||||||
|
finally:
|
||||||
|
m.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def move_and_find_focus(microscope, dz):
|
||||||
|
"""Make a relative Z move and return the peak sharpness position"""
|
||||||
|
with monitor_sharpness(microscope) as m:
|
||||||
|
m.focus_rel(dz)
|
||||||
|
return m.sharpest_z_on_move(0)
|
||||||
|
|
||||||
|
|
||||||
|
def fast_autofocus(microscope, dz=2000, backlash=None):
|
||||||
|
"""Perform a down-up-down-up autofocus"""
|
||||||
|
with monitor_sharpness(microscope) as m:
|
||||||
|
i, z = m.focus_rel(-dz / 2)
|
||||||
|
i, z = m.focus_rel(dz)
|
||||||
|
fz = m.sharpest_z_on_move(i)
|
||||||
|
if backlash is None:
|
||||||
|
i, z = m.focus_rel(-dz) # move all the way to the start so it's consistent
|
||||||
|
else:
|
||||||
|
i, z = m.focus_rel(fz - z - backlash)
|
||||||
|
m.focus_rel(fz - z)
|
||||||
|
return m.data_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def fast_up_down_up_autofocus(
|
||||||
|
microscope, dz=2000, target_z=0, initial_move_up=True, mini_backlash=150
|
||||||
|
):
|
||||||
|
"""Autofocus by measuring on the way down, and moving back up with feedback.
|
||||||
|
|
||||||
|
This autofocus method is very efficient, as it only passes the peak once.
|
||||||
|
The sequence of moves it performs is:
|
||||||
|
|
||||||
|
1. Move to the top of the range `dz/2` (can be disabled)
|
||||||
|
|
||||||
|
2. Move down by `dz` while monitoring JPEG size to find the focus.
|
||||||
|
|
||||||
|
3. Move back up to the `target_z` position, relative to the sharpest image.
|
||||||
|
|
||||||
|
4. Measure the sharpness, and compare against the curve recorded in (2) to \\
|
||||||
|
estimate how much further we need to go. Make this move, to reach our \\
|
||||||
|
target position.
|
||||||
|
|
||||||
|
Moving back to the target position in two steps allows us to correct for
|
||||||
|
backlash, by using the sharpness-vs-z curve as a rough encoder for Z.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
dz: number of steps over which to scan (optional, default 2000)
|
||||||
|
|
||||||
|
target_z: we aim to finish at this position, relative to focus. This may
|
||||||
|
be useful if, for example, you want to acquire a stack of images in Z.
|
||||||
|
It is optional, and the default value of 0 will finish at the focus.
|
||||||
|
|
||||||
|
initial_move_up: (optional, default True) set this to `False` to move down
|
||||||
|
from the starting position. Mostly useful if you're able to combine
|
||||||
|
the initial move with something else, e.g. moving to the next scan point.
|
||||||
|
|
||||||
|
mini_backlash: (optional, default 50) is a small extra move made in step
|
||||||
|
3 to help counteract backlash. It should be small enough that you
|
||||||
|
would always expect there to be greater backlash than this. Too small
|
||||||
|
might slightly hurt accuracy, but is unlikely to be a big issue. Too big
|
||||||
|
may cause you to overshoot, which is a problem.
|
||||||
|
"""
|
||||||
|
with monitor_sharpness(microscope) as m, microscope.camera.lock:
|
||||||
|
# Ensure the MJPEG stream has started
|
||||||
|
microscope.camera.start_stream_recording()
|
||||||
|
|
||||||
|
df = dz # TODO: refactor so I actually use dz in the code below!
|
||||||
|
if initial_move_up:
|
||||||
|
m.focus_rel(df / 2)
|
||||||
|
# move down
|
||||||
|
i, z = m.focus_rel(-df)
|
||||||
|
# now inspect where the sharpest point is, and estimate the sharpness
|
||||||
|
# (JPEG size) that we should find at the start of the Z stack
|
||||||
|
jt, jz, js = m.move_data(i)
|
||||||
|
best_z = jz[np.argmax(js)]
|
||||||
|
target_s = np.interp(
|
||||||
|
[best_z + target_z], jz[::-1], js[::-1]
|
||||||
|
) # NB jz is decreasing
|
||||||
|
|
||||||
|
# now move to the start of the z stack
|
||||||
|
i, z = m.focus_rel(
|
||||||
|
best_z + target_z - z + mini_backlash
|
||||||
|
) # takes us to the start of the stack
|
||||||
|
|
||||||
|
# We've deliberately undershot - figure out how much further we should move based on the curve
|
||||||
|
current_js = m.jpeg_size()
|
||||||
|
imax = np.argmax(js) # we want to crop out just the bit below the peak
|
||||||
|
js = js[imax:] # NB z is in DECREASING order
|
||||||
|
jz = jz[imax:]
|
||||||
|
inow = np.argmax(
|
||||||
|
js < current_js
|
||||||
|
) # use the curve we recorded to estimate our position
|
||||||
|
# TODO: fancy interpolation stuff
|
||||||
|
|
||||||
|
# So, the Z position corresponding to our current sharpness value is zs[inow]
|
||||||
|
# That means we should move forwards, by best_z - zs[inow]
|
||||||
|
correction_move = best_z + target_z - jz[inow]
|
||||||
|
logging.debug(
|
||||||
|
"Fast autofocus scan: correcting backlash by moving {} steps".format(
|
||||||
|
correction_move
|
||||||
|
)
|
||||||
|
)
|
||||||
|
m.focus_rel(correction_move)
|
||||||
|
return m.data_dict()
|
||||||
|
|
||||||
|
|
||||||
|
class MeasureSharpnessAPI(View):
|
||||||
|
def post(self):
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
|
||||||
|
if not microscope:
|
||||||
|
abort(503, "No microscope connected. Unable to measure sharpness.")
|
||||||
|
|
||||||
|
return jsonify({"sharpness": measure_sharpness(microscope)})
|
||||||
|
|
||||||
|
|
||||||
|
@ThingAction
|
||||||
|
class AutofocusAPI(View):
|
||||||
|
"""
|
||||||
|
Run a standard autofocus
|
||||||
|
"""
|
||||||
|
|
||||||
|
def post(self):
|
||||||
|
payload = JsonResponse(request)
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
|
||||||
|
if not microscope:
|
||||||
|
abort(503, "No microscope connected. Unable to autofocus.")
|
||||||
|
|
||||||
|
# Figure out the range of z values to use
|
||||||
|
dz = payload.param("dz", default=np.linspace(-300, 300, 7), convert=np.array)
|
||||||
|
|
||||||
|
if microscope.has_real_stage():
|
||||||
|
logging.info("Running autofocus...")
|
||||||
|
task = taskify(autofocus)(microscope, dz)
|
||||||
|
|
||||||
|
# return a handle on the autofocus task
|
||||||
|
return jsonify(task.state), 201
|
||||||
|
|
||||||
|
else:
|
||||||
|
abort(503, "No stage connected. Unable to autofocus.")
|
||||||
|
|
||||||
|
|
||||||
|
@ThingAction
|
||||||
|
class FastAutofocusAPI(View):
|
||||||
|
"""
|
||||||
|
Run a fast autofocus
|
||||||
|
"""
|
||||||
|
|
||||||
|
def post(self):
|
||||||
|
payload = JsonResponse(request)
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
|
||||||
|
if not microscope:
|
||||||
|
abort(503, "No microscope connected. Unable to autofocus.")
|
||||||
|
|
||||||
|
# Figure out the parameters to use
|
||||||
|
dz = payload.param("dz", default=2000, convert=int)
|
||||||
|
backlash = payload.param("backlash", default=0, convert=int)
|
||||||
|
if backlash < 0:
|
||||||
|
backlash = 0
|
||||||
|
|
||||||
|
if microscope.has_real_stage():
|
||||||
|
logging.info("Running autofocus...")
|
||||||
|
task = taskify(fast_autofocus)(microscope, dz, backlash=backlash)
|
||||||
|
|
||||||
|
# return a handle on the autofocus task
|
||||||
|
return jsonify(task.state), 201
|
||||||
|
|
||||||
|
else:
|
||||||
|
abort(503, "No stage connected. Unable to autofocus.")
|
||||||
|
|
||||||
|
|
||||||
|
autofocus_extension_v2 = BaseExtension("org.openflexure.autofocus", version="2.0.0-beta.1")
|
||||||
|
|
||||||
|
autofocus_extension_v2.add_method(fast_autofocus, "fast_autofocus")
|
||||||
|
autofocus_extension_v2.add_method(autofocus, "autofocus")
|
||||||
|
|
||||||
|
autofocus_extension_v2.add_view(MeasureSharpnessAPI, "/measure_sharpness")
|
||||||
|
autofocus_extension_v2.add_view(AutofocusAPI, "/autofocus")
|
||||||
|
autofocus_extension_v2.add_view(FastAutofocusAPI, "/fast_autofocus")
|
||||||
401
openflexure_microscope/api/default_extensions/scan.py
Normal file
401
openflexure_microscope/api/default_extensions/scan.py
Normal file
|
|
@ -0,0 +1,401 @@
|
||||||
|
import itertools
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Tuple
|
||||||
|
from functools import reduce
|
||||||
|
|
||||||
|
from openflexure_microscope.camera.base import generate_basename
|
||||||
|
from openflexure_microscope.common.flask_labthings.find import (
|
||||||
|
find_component,
|
||||||
|
find_extension,
|
||||||
|
)
|
||||||
|
from openflexure_microscope.common.flask_labthings.extensions import BaseExtension
|
||||||
|
from openflexure_microscope.common.flask_labthings.decorators import (
|
||||||
|
marshal_task,
|
||||||
|
use_args,
|
||||||
|
ThingAction,
|
||||||
|
)
|
||||||
|
from openflexure_microscope.common.flask_labthings import fields
|
||||||
|
|
||||||
|
from openflexure_microscope.devel import taskify, abort, update_task_progress
|
||||||
|
|
||||||
|
from openflexure_microscope.common.flask_labthings.view import View
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
### Grid construction
|
||||||
|
|
||||||
|
|
||||||
|
def construct_grid(initial, step_sizes, n_steps, style="raster"):
|
||||||
|
"""
|
||||||
|
Given an initial position, step sizes, and number of steps,
|
||||||
|
construct a 2-dimensional list of scan x-y positions.
|
||||||
|
"""
|
||||||
|
arr = []
|
||||||
|
|
||||||
|
for i in range(n_steps[0]): # x axis
|
||||||
|
arr.append([])
|
||||||
|
for j in range(n_steps[1]): # y axis
|
||||||
|
# Create a coordinate array
|
||||||
|
coord = [initial[ax] + [i, j][ax] * step_sizes[ax] for ax in range(2)]
|
||||||
|
# Append coordinate array to position grid
|
||||||
|
arr[i].append(tuple(coord))
|
||||||
|
|
||||||
|
# Style modifiers
|
||||||
|
if style == "snake":
|
||||||
|
for i, line in enumerate(arr):
|
||||||
|
if i % 2 != 0:
|
||||||
|
line.reverse()
|
||||||
|
|
||||||
|
return arr
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_grid(grid):
|
||||||
|
"""
|
||||||
|
Convert a 3D list of scan positions into a flat list
|
||||||
|
of sequential positions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
grid = list(itertools.chain(*grid))
|
||||||
|
return grid
|
||||||
|
|
||||||
|
|
||||||
|
### Progress
|
||||||
|
|
||||||
|
_images_to_be_captured: int = 1
|
||||||
|
_images_captured_so_far: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def progress():
|
||||||
|
progress = (_images_captured_so_far / _images_to_be_captured) * 100
|
||||||
|
logging.info(progress)
|
||||||
|
return progress
|
||||||
|
|
||||||
|
|
||||||
|
### Capturing
|
||||||
|
|
||||||
|
|
||||||
|
def capture(
|
||||||
|
microscope,
|
||||||
|
basename,
|
||||||
|
scan_id,
|
||||||
|
temporary: bool = False,
|
||||||
|
use_video_port: bool = False,
|
||||||
|
resize: Tuple[int, int] = None,
|
||||||
|
bayer: bool = False,
|
||||||
|
metadata: dict = {},
|
||||||
|
tags: list = [],
|
||||||
|
):
|
||||||
|
|
||||||
|
# Construct a tile filename
|
||||||
|
filename = "{}_{}_{}_{}".format(basename, *microscope.stage.position)
|
||||||
|
folder = "SCAN_{}".format(basename)
|
||||||
|
|
||||||
|
# Create output object
|
||||||
|
output = microscope.camera.new_image(
|
||||||
|
temporary=temporary, filename=filename, folder=folder
|
||||||
|
)
|
||||||
|
|
||||||
|
# Capture
|
||||||
|
microscope.camera.capture(
|
||||||
|
output.file, use_video_port=use_video_port, resize=resize, bayer=bayer
|
||||||
|
)
|
||||||
|
|
||||||
|
# Affix metadata
|
||||||
|
if "scan" not in tags:
|
||||||
|
tags.append("scan")
|
||||||
|
|
||||||
|
# Inject system metadata
|
||||||
|
output.put_metadata(microscope.metadata, system=True)
|
||||||
|
|
||||||
|
# Insert custom metadata
|
||||||
|
output.put_metadata(metadata)
|
||||||
|
|
||||||
|
# Insert custom tags
|
||||||
|
output.put_tags(tags)
|
||||||
|
|
||||||
|
|
||||||
|
### Scanning
|
||||||
|
|
||||||
|
|
||||||
|
def tile(
|
||||||
|
microscope,
|
||||||
|
basename: str = None,
|
||||||
|
temporary: bool = False,
|
||||||
|
step_size: int = [2000, 1500, 100],
|
||||||
|
grid: list = [3, 3, 5],
|
||||||
|
style="raster",
|
||||||
|
autofocus_dz: int = 50,
|
||||||
|
use_video_port: bool = False,
|
||||||
|
resize: Tuple[int, int] = None,
|
||||||
|
bayer: bool = False,
|
||||||
|
fast_autofocus=False,
|
||||||
|
metadata: dict = {},
|
||||||
|
tags: list = [],
|
||||||
|
):
|
||||||
|
global _images_to_be_captured
|
||||||
|
global _images_captured_so_far
|
||||||
|
|
||||||
|
# Keep task progress
|
||||||
|
# TODO: Make this line not nasty
|
||||||
|
_images_to_be_captured = reduce((lambda x, y: x * y), grid)
|
||||||
|
_images_captured_so_far = 0
|
||||||
|
|
||||||
|
# Generate a basename if none given
|
||||||
|
if not basename:
|
||||||
|
basename = generate_basename()
|
||||||
|
|
||||||
|
# Generate a stack ID
|
||||||
|
scan_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Store initial position
|
||||||
|
initial_position = microscope.stage.position
|
||||||
|
|
||||||
|
# Add scan metadata
|
||||||
|
if "time" not in metadata:
|
||||||
|
metadata["time"] = generate_basename()
|
||||||
|
|
||||||
|
metadata.update(
|
||||||
|
{
|
||||||
|
"scan_id": scan_id,
|
||||||
|
"basename": basename,
|
||||||
|
"scan_parameters": {
|
||||||
|
"step_size": step_size,
|
||||||
|
"grid": grid,
|
||||||
|
"style": style,
|
||||||
|
"autofocus_dz": autofocus_dz,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if autofocus is enabled
|
||||||
|
autofocus_extension = find_extension("org.openflexure.autofocus")
|
||||||
|
if (
|
||||||
|
autofocus_dz
|
||||||
|
and autofocus_extension
|
||||||
|
and microscope.has_real_stage()
|
||||||
|
and microscope.has_real_camera()
|
||||||
|
):
|
||||||
|
autofocus_enabled = True
|
||||||
|
else:
|
||||||
|
autofocus_enabled = False
|
||||||
|
|
||||||
|
z_stack_dz = (
|
||||||
|
grid[2] * step_size[2] if grid[2] > 1 else 0
|
||||||
|
) # shorthand for Z stack range
|
||||||
|
|
||||||
|
# Construct an x-y grid (worry about z later)
|
||||||
|
x_y_grid = construct_grid(initial_position, step_size[:2], grid[:2], style=style)
|
||||||
|
|
||||||
|
# Keep the initial Z position the same as our current position
|
||||||
|
next_z = initial_position[2]
|
||||||
|
if fast_autofocus: # If fast autofocus is enabled, make
|
||||||
|
next_z += autofocus_dz / 2 # sure we start from the top of the range
|
||||||
|
initial_z = next_z # Save this value for use in raster scans
|
||||||
|
|
||||||
|
# Now step through each point in the x-y coordinate array
|
||||||
|
for line in x_y_grid:
|
||||||
|
# If rastering, rather than snake (or eventually spiral)
|
||||||
|
# Return focus to initial position
|
||||||
|
if style == "raster":
|
||||||
|
next_z = initial_z
|
||||||
|
logging.debug("Returning to initial z position")
|
||||||
|
microscope.stage.move_abs(
|
||||||
|
[line[0][0], line[0][1], next_z]
|
||||||
|
) # RWB: I think this line is redundant
|
||||||
|
|
||||||
|
for x_y in line:
|
||||||
|
# Move to new grid position without changing z
|
||||||
|
logging.debug("Moving to step {}".format([x_y[0], x_y[1], next_z]))
|
||||||
|
microscope.stage.move_abs([x_y[0], x_y[1], next_z])
|
||||||
|
# Refocus
|
||||||
|
if autofocus_enabled:
|
||||||
|
if fast_autofocus:
|
||||||
|
autofocus_extension.fast_autofocus(
|
||||||
|
dz=autofocus_dz,
|
||||||
|
target_z=-z_stack_dz / 2.0, # Finish below the focus
|
||||||
|
initial_move_up=False, # We're already at the top of the scan
|
||||||
|
)
|
||||||
|
# TODO: save the focus data for future reference? Use it for diagnostics?
|
||||||
|
else:
|
||||||
|
logging.debug("Running autofocus")
|
||||||
|
autofocus_extension.autofocus(
|
||||||
|
range(-3 * autofocus_dz, 4 * autofocus_dz, autofocus_dz)
|
||||||
|
)
|
||||||
|
logging.debug("Finished autofocus")
|
||||||
|
time.sleep(1) # TODO: Remove
|
||||||
|
|
||||||
|
# If we're not doing a z-stack, just capture
|
||||||
|
if grid[2] <= 1:
|
||||||
|
capture(
|
||||||
|
microscope,
|
||||||
|
basename,
|
||||||
|
scan_id,
|
||||||
|
temporary=temporary,
|
||||||
|
use_video_port=use_video_port,
|
||||||
|
resize=resize,
|
||||||
|
bayer=bayer,
|
||||||
|
metadata=metadata,
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
# Update task progress
|
||||||
|
_images_captured_so_far += 1
|
||||||
|
update_task_progress(progress())
|
||||||
|
else:
|
||||||
|
logging.debug("Entering z-stack")
|
||||||
|
stack(
|
||||||
|
microscope=microscope,
|
||||||
|
basename=basename,
|
||||||
|
temporary=temporary,
|
||||||
|
scan_id=scan_id,
|
||||||
|
step_size=step_size[2],
|
||||||
|
steps=grid[2],
|
||||||
|
center=not fast_autofocus, # fast_autofocus does this for us!
|
||||||
|
return_to_start=not fast_autofocus,
|
||||||
|
use_video_port=use_video_port,
|
||||||
|
resize=resize,
|
||||||
|
bayer=bayer,
|
||||||
|
metadata=metadata,
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
# Make sure we use our current best estimate of focus (i.e. the current position) next point
|
||||||
|
next_z = microscope.stage.position[2]
|
||||||
|
if fast_autofocus:
|
||||||
|
next_z += (
|
||||||
|
autofocus_dz / 2
|
||||||
|
) # Fast autofocus requires us to start at the top of the range
|
||||||
|
if grid[2] > 1:
|
||||||
|
next_z -= int(
|
||||||
|
grid[2] / 2.0 * step_size[2]
|
||||||
|
) # Z stacking means we're higher up to start with
|
||||||
|
|
||||||
|
logging.debug("Returning to {}".format(initial_position))
|
||||||
|
microscope.stage.move_abs(initial_position)
|
||||||
|
|
||||||
|
|
||||||
|
def stack(
|
||||||
|
microscope,
|
||||||
|
basename: str = None,
|
||||||
|
temporary: bool = False,
|
||||||
|
scan_id: str = None,
|
||||||
|
step_size: int = 100,
|
||||||
|
steps: int = 5,
|
||||||
|
center: bool = True,
|
||||||
|
return_to_start: bool = True,
|
||||||
|
use_video_port: bool = False,
|
||||||
|
resize: Tuple[int, int] = None,
|
||||||
|
bayer: bool = False,
|
||||||
|
metadata: dict = {},
|
||||||
|
tags: list = [],
|
||||||
|
):
|
||||||
|
global _images_captured_so_far
|
||||||
|
|
||||||
|
# Generate a basename if none given
|
||||||
|
if not basename:
|
||||||
|
basename = generate_basename()
|
||||||
|
|
||||||
|
# Generate a stack ID
|
||||||
|
if not scan_id:
|
||||||
|
scan_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Add scan metadata
|
||||||
|
if not "time" in metadata:
|
||||||
|
metadata["time"] = generate_basename()
|
||||||
|
|
||||||
|
# Store initial position
|
||||||
|
initial_position = microscope.stage.position
|
||||||
|
|
||||||
|
with microscope.lock:
|
||||||
|
# Move to center scan
|
||||||
|
if center:
|
||||||
|
logging.debug("Moving to starting position")
|
||||||
|
microscope.stage.move_rel([0, 0, int((-step_size * steps) / 2)])
|
||||||
|
|
||||||
|
for i in range(steps):
|
||||||
|
time.sleep(0.1)
|
||||||
|
logging.debug("Capturing...")
|
||||||
|
capture(
|
||||||
|
microscope,
|
||||||
|
basename,
|
||||||
|
scan_id,
|
||||||
|
temporary=temporary,
|
||||||
|
use_video_port=use_video_port,
|
||||||
|
resize=resize,
|
||||||
|
bayer=bayer,
|
||||||
|
metadata=metadata,
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
# Update task progress
|
||||||
|
_images_captured_so_far += 1
|
||||||
|
update_task_progress(progress())
|
||||||
|
|
||||||
|
if i != steps - 1:
|
||||||
|
logging.debug("Moving z by {}".format(step_size))
|
||||||
|
microscope.stage.move_rel([0, 0, step_size])
|
||||||
|
if return_to_start:
|
||||||
|
logging.debug("Returning to {}".format(initial_position))
|
||||||
|
microscope.stage.move_abs(initial_position)
|
||||||
|
|
||||||
|
|
||||||
|
### Web views
|
||||||
|
|
||||||
|
|
||||||
|
@ThingAction
|
||||||
|
class TileScanAPI(View):
|
||||||
|
@use_args(
|
||||||
|
{
|
||||||
|
"filename": fields.String(),
|
||||||
|
"temporary": fields.Boolean(missing=False),
|
||||||
|
"step_size": fields.List(fields.Integer, missing=[2000, 1500, 100]),
|
||||||
|
"grid": fields.List(fields.Integer, missing=[3, 3, 5]),
|
||||||
|
"style": fields.String(missing="raster"),
|
||||||
|
"autofocus_dz": fields.Integer(missing=50),
|
||||||
|
"fast_autofocus": fields.Boolean(missing=False),
|
||||||
|
"use_video_port": fields.Boolean(missing=False),
|
||||||
|
"bayer": fields.Boolean(missing=False),
|
||||||
|
"metadata": fields.Dict(missing={}),
|
||||||
|
"tags": fields.List(fields.String, missing=[]),
|
||||||
|
"resize": fields.Dict(missing=None), # TODO: Validate keys
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@marshal_task
|
||||||
|
def post(self, args):
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
|
||||||
|
if not microscope:
|
||||||
|
abort(503, "No microscope connected. Unable to autofocus.")
|
||||||
|
|
||||||
|
resize = args.get("resize", None)
|
||||||
|
if resize:
|
||||||
|
if ("width" in resize) and ("height" in resize):
|
||||||
|
resize = (
|
||||||
|
int(resize["width"]),
|
||||||
|
int(resize["height"]),
|
||||||
|
) # Convert dict to tuple
|
||||||
|
else:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
logging.info("Running tile scan...")
|
||||||
|
task = taskify(tile)(
|
||||||
|
microscope,
|
||||||
|
basename=args.get("filename"),
|
||||||
|
temporary=args.get("temporary"),
|
||||||
|
step_size=args.get("step_size"),
|
||||||
|
grid=args.get("grid"),
|
||||||
|
style=args.get("style"),
|
||||||
|
autofocus_dz=args.get("autofocus_dz"),
|
||||||
|
use_video_port=args.get("use_video_port"),
|
||||||
|
resize=resize,
|
||||||
|
bayer=args.get("bayer"),
|
||||||
|
fast_autofocus=args.get("fast_autofocus"),
|
||||||
|
metadata=args.get("metadata"),
|
||||||
|
tags=args.get("tags"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# return a handle on the scan task
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
scan_extension_v2 = BaseExtension("org.openflexure.scan", version="2.0.0-beta.1")
|
||||||
|
|
||||||
|
scan_extension_v2.add_view(TileScanAPI, "/tile")
|
||||||
155
openflexure_microscope/api/default_extensions/zip_builder.py
Normal file
155
openflexure_microscope/api/default_extensions/zip_builder.py
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
from openflexure_microscope.devel import (
|
||||||
|
JsonResponse,
|
||||||
|
request,
|
||||||
|
jsonify,
|
||||||
|
taskify,
|
||||||
|
update_task_progress,
|
||||||
|
)
|
||||||
|
|
||||||
|
from flask import send_file, abort
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
import tempfile
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from openflexure_microscope.common.flask_labthings.find import find_component
|
||||||
|
from openflexure_microscope.common.flask_labthings.view import View
|
||||||
|
from openflexure_microscope.common.flask_labthings.extensions import BaseExtension
|
||||||
|
from openflexure_microscope.common.flask_labthings.decorators import (
|
||||||
|
ThingAction,
|
||||||
|
ThingProperty,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ZipManager:
|
||||||
|
"""
|
||||||
|
ZIP-builder manager
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.session_zips = {}
|
||||||
|
|
||||||
|
def build_zip_from_capture_ids(self, microscope, capture_id_list):
|
||||||
|
logging.debug(capture_id_list)
|
||||||
|
|
||||||
|
# Get array of captures from IDs
|
||||||
|
capture_list = [
|
||||||
|
microscope.camera.image_from_id(capture_id)
|
||||||
|
for capture_id in capture_id_list
|
||||||
|
]
|
||||||
|
# Remove Nones from list (missing/invalid captures)
|
||||||
|
capture_list = [capture for capture in capture_list if capture]
|
||||||
|
|
||||||
|
# Get size (in bytes) of each capture
|
||||||
|
capture_sizes = [
|
||||||
|
os.path.getsize(capture_obj.file) for capture_obj in capture_list
|
||||||
|
]
|
||||||
|
# Calculate size of input data in megabytes
|
||||||
|
data_size_megabytes = sum(capture_sizes) * 1e-6
|
||||||
|
|
||||||
|
# If more than 1GB
|
||||||
|
if data_size_megabytes > 1000:
|
||||||
|
# Throw exception
|
||||||
|
raise Exception(
|
||||||
|
"Zip data cannot exceed 1GB. Please transfer data manually."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Number of files to add (used for task progress)
|
||||||
|
n_files = len(capture_id_list)
|
||||||
|
|
||||||
|
# Create temporary file
|
||||||
|
fp = tempfile.NamedTemporaryFile(delete=False)
|
||||||
|
|
||||||
|
# Open temp file as a ZIP file
|
||||||
|
with zipfile.ZipFile(fp, "w") as zipObj:
|
||||||
|
for index, capture_obj in enumerate(capture_list):
|
||||||
|
# Add to ZIP file if it exists
|
||||||
|
file_path = capture_obj.file
|
||||||
|
rel_path = os.path.relpath(
|
||||||
|
file_path, microscope.camera.paths["default"]
|
||||||
|
)
|
||||||
|
zipObj.write(file_path, arcname=rel_path)
|
||||||
|
# Update task progress
|
||||||
|
update_task_progress(int((index / n_files) * 100))
|
||||||
|
|
||||||
|
session_id = uuid.uuid4()
|
||||||
|
session_key = str(session_id)
|
||||||
|
# self.session_zips[session_id] = fp
|
||||||
|
self.session_zips[session_key] = {
|
||||||
|
"id": session_id,
|
||||||
|
"fp": fp,
|
||||||
|
"data_size": data_size_megabytes,
|
||||||
|
"zip_size": os.path.getsize(fp.name) * 1e-6,
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.session_zips[session_key]
|
||||||
|
|
||||||
|
def zip_from_id(self, session_id):
|
||||||
|
return self.session_zips[session_id]["fp"]
|
||||||
|
|
||||||
|
|
||||||
|
# Create a global ZIP manager
|
||||||
|
default_zip_manager = ZipManager()
|
||||||
|
|
||||||
|
|
||||||
|
@ThingAction
|
||||||
|
class ZipBuilderAPIView(View):
|
||||||
|
def post(self):
|
||||||
|
|
||||||
|
ids = list(JsonResponse(request).json)
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
|
||||||
|
task = taskify(default_zip_manager.build_zip_from_capture_ids)(microscope, ids)
|
||||||
|
|
||||||
|
# Return a handle on the autofocus task
|
||||||
|
return jsonify(task.state), 201
|
||||||
|
|
||||||
|
|
||||||
|
@ThingProperty
|
||||||
|
class ZipListAPIView(View):
|
||||||
|
def get(self):
|
||||||
|
return jsonify(default_zip_manager.session_zips)
|
||||||
|
|
||||||
|
|
||||||
|
class ZipGetterAPIView(View):
|
||||||
|
def get(self, session_id):
|
||||||
|
if not session_id in default_zip_manager.session_zips:
|
||||||
|
return abort(404) # 404 Not Found
|
||||||
|
|
||||||
|
logging.info(f"Session ID: {session_id}")
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
default_zip_manager.zip_from_id(session_id).name,
|
||||||
|
mimetype="application/zip",
|
||||||
|
as_attachment=True,
|
||||||
|
attachment_filename=f"{session_id}.zip",
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, session_id):
|
||||||
|
if not session_id in default_zip_manager.session_zips:
|
||||||
|
return abort(404) # 404 Not Found
|
||||||
|
|
||||||
|
logging.info(f"Session ID: {session_id}")
|
||||||
|
|
||||||
|
fp = default_zip_manager.zip_from_id(session_id)
|
||||||
|
logging.debug(fp.name)
|
||||||
|
fp.close()
|
||||||
|
os.unlink(fp.name)
|
||||||
|
|
||||||
|
assert not os.path.exists(fp.name)
|
||||||
|
|
||||||
|
del default_zip_manager.session_zips[session_id]
|
||||||
|
|
||||||
|
return jsonify({"return": session_id})
|
||||||
|
|
||||||
|
|
||||||
|
zip_extension_v2 = BaseExtension("org.openflexure.zipbuilder", version="2.0.0-beta.1")
|
||||||
|
|
||||||
|
zip_extension_v2.add_view(ZipGetterAPIView, "/get/<string:session_id>")
|
||||||
|
zip_extension_v2.add_view(ZipListAPIView, "/get")
|
||||||
|
|
||||||
|
zip_extension_v2.add_view(ZipBuilderAPIView, "/build")
|
||||||
122
openflexure_microscope/api/example_extensions/ev_gui.py
Normal file
122
openflexure_microscope/api/example_extensions/ev_gui.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
from openflexure_microscope.common.flask_labthings.view import View
|
||||||
|
from openflexure_microscope.common.flask_labthings.extensions import BaseExtension
|
||||||
|
from openflexure_microscope.common.flask_labthings.decorators import (
|
||||||
|
ThingAction,
|
||||||
|
ThingProperty,
|
||||||
|
)
|
||||||
|
|
||||||
|
from openflexure_microscope.api.utilities.gui import build_gui
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
# Some value that will change over time
|
||||||
|
# Here, we add 1 to it every time a GET request is made
|
||||||
|
val_int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def dynamic_form():
|
||||||
|
global val_int
|
||||||
|
return {
|
||||||
|
"id": "test-plugin",
|
||||||
|
"icon": "pets",
|
||||||
|
"forms": [
|
||||||
|
{
|
||||||
|
"name": "Simple request",
|
||||||
|
"isCollapsible": False,
|
||||||
|
"isTask": False,
|
||||||
|
"selfUpdate": True,
|
||||||
|
"route": "/do",
|
||||||
|
"submitLabel": "Do things",
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"fieldType": "numberInput",
|
||||||
|
"name": "val_int",
|
||||||
|
"label": "Number value",
|
||||||
|
"minValue": 0,
|
||||||
|
"value": val_int
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldType": "htmlBlock",
|
||||||
|
"name": "html_block",
|
||||||
|
"content": f"<i>Value is: </i><br>{val_int}.", # We dynamically use the val_int variable
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Alternate form without any dynamic parts
|
||||||
|
static_form = {
|
||||||
|
"id": "test-plugin",
|
||||||
|
"icon": "pets",
|
||||||
|
"forms": [
|
||||||
|
{
|
||||||
|
"name": "Simple request",
|
||||||
|
"isCollapsible": False,
|
||||||
|
"isTask": False,
|
||||||
|
"selfUpdate": True,
|
||||||
|
"route": "/do",
|
||||||
|
"submitLabel": "Do things",
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"fieldType": "numberInput",
|
||||||
|
"name": "val_int",
|
||||||
|
"label": "Number value",
|
||||||
|
"minValue": 0,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldType": "htmlBlock",
|
||||||
|
"name": "html_block",
|
||||||
|
"content": f"<i>Value is fixed</i>.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ThingProperty
|
||||||
|
class TestAPIView(View):
|
||||||
|
def get(self):
|
||||||
|
global val_int
|
||||||
|
val_int += 1
|
||||||
|
return jsonify({"val": True})
|
||||||
|
|
||||||
|
|
||||||
|
@ThingAction
|
||||||
|
class TestDoAPIView(View):
|
||||||
|
def post(self):
|
||||||
|
global val_int
|
||||||
|
val_int += 1
|
||||||
|
return jsonify({"val": True})
|
||||||
|
|
||||||
|
|
||||||
|
# Using the dynamic form
|
||||||
|
dynamic_test_extension_v2 = BaseExtension("org.openflexure.examples.dynamic-gui")
|
||||||
|
dynamic_test_extension_v2.add_view(
|
||||||
|
TestAPIView, "/get", endpoint="dynamic_test_extension_get"
|
||||||
|
)
|
||||||
|
dynamic_test_extension_v2.add_view(
|
||||||
|
TestDoAPIView, "/do", endpoint="dynamic_test_extension_do"
|
||||||
|
)
|
||||||
|
|
||||||
|
dynamic_test_extension_v2.add_meta(
|
||||||
|
"gui", build_gui(dynamic_form, dynamic_test_extension_v2)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Using the static form
|
||||||
|
static_test_extension_v2 = BaseExtension("org.openflexure.examples.static-gui")
|
||||||
|
static_test_extension_v2.add_view(
|
||||||
|
TestAPIView, "/get", endpoint="static_test_extension_get"
|
||||||
|
)
|
||||||
|
static_test_extension_v2.add_view(
|
||||||
|
TestDoAPIView, "/do", endpoint="static_test_extension_do"
|
||||||
|
)
|
||||||
|
static_test_extension_v2.add_meta(
|
||||||
|
"gui", build_gui(static_form, static_test_extension_v2)
|
||||||
|
)
|
||||||
44
openflexure_microscope/api/microscope.py
Normal file
44
openflexure_microscope/api/microscope.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
from openflexure_microscope import Microscope
|
||||||
|
from openflexure_microscope.camera.capture import build_captures_from_exif
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Import device modules
|
||||||
|
# NB this will eventually be handled by the RC file, so you can choose what device
|
||||||
|
# class should be attached.
|
||||||
|
try:
|
||||||
|
from openflexure_microscope.camera.pi import PiCameraStreamer
|
||||||
|
except ImportError:
|
||||||
|
logging.warning("Unable to import PiCameraStreamer")
|
||||||
|
from openflexure_microscope.camera.mock import MockStreamer
|
||||||
|
|
||||||
|
from openflexure_microscope.stage.sanga import SangaStage
|
||||||
|
from openflexure_microscope.stage.mock import MockStage
|
||||||
|
|
||||||
|
default_microscope = Microscope()
|
||||||
|
|
||||||
|
# Initialise camera
|
||||||
|
logging.debug("Creating camera object...")
|
||||||
|
try:
|
||||||
|
api_camera = PiCameraStreamer()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(e)
|
||||||
|
logging.warning("No valid camera hardware found. Falling back to mock camera!")
|
||||||
|
api_camera = MockStreamer()
|
||||||
|
|
||||||
|
# Initialise stage
|
||||||
|
logging.debug("Creating stage object...")
|
||||||
|
|
||||||
|
api_stage = MockStage()
|
||||||
|
|
||||||
|
# Attach devices to microscope
|
||||||
|
logging.debug("Attaching devices to microscope...")
|
||||||
|
default_microscope.attach(api_camera, api_stage)
|
||||||
|
|
||||||
|
# Restore loaded capture array to camera object
|
||||||
|
logging.debug("Restoring captures...")
|
||||||
|
default_microscope.camera.images = build_captures_from_exif(
|
||||||
|
default_microscope.camera.paths["default"]
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.debug("Microscope successfully attached!")
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import errno
|
||||||
from werkzeug.exceptions import BadRequest
|
from werkzeug.exceptions import BadRequest
|
||||||
from flask import url_for, Blueprint
|
from flask import url_for, Blueprint, current_app
|
||||||
|
|
||||||
|
from . import gui
|
||||||
|
|
||||||
|
|
||||||
|
def view_class_from_endpoint(endpoint: str):
|
||||||
|
return current_app.view_functions[endpoint].view_class
|
||||||
|
|
||||||
|
|
||||||
def blueprint_for_module(module_name, api_ver=2, suffix=""):
|
def blueprint_for_module(module_name, api_ver=2, suffix=""):
|
||||||
|
|
@ -95,3 +103,32 @@ def list_routes(app):
|
||||||
output[url] = line
|
output[url] = line
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def create_file(config_path):
|
||||||
|
if not os.path.exists(os.path.dirname(config_path)):
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(config_path))
|
||||||
|
except OSError as exc: # Guard against race condition
|
||||||
|
if exc.errno != errno.EEXIST:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def init_default_extensions(extension_dir):
|
||||||
|
global _DEFAULT_EXTENSION_INIT
|
||||||
|
os.makedirs(extension_dir, exist_ok=True)
|
||||||
|
|
||||||
|
default_ext_path = os.path.join(extension_dir, "defaults.py")
|
||||||
|
|
||||||
|
if not os.path.isfile(default_ext_path): # If user extensions file doesn't exist
|
||||||
|
logging.warning(
|
||||||
|
"No extension file found at {}. Creating...".format(extension_dir)
|
||||||
|
)
|
||||||
|
create_file(default_ext_path)
|
||||||
|
|
||||||
|
logging.info("Populating {}...".format(default_ext_path))
|
||||||
|
with open(default_ext_path, "w") as outfile:
|
||||||
|
outfile.write(_DEFAULT_EXTENSION_INIT)
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_EXTENSION_INIT = "from openflexure_microscope.api.default_extensions import *"
|
||||||
48
openflexure_microscope/api/utilities/gui.py
Normal file
48
openflexure_microscope/api/utilities/gui.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
|
||||||
|
def build_gui_from_dict(gui_description, extension_object):
|
||||||
|
# Make a working copy of GUI description
|
||||||
|
api_gui = copy.deepcopy(gui_description)
|
||||||
|
|
||||||
|
# Expand shorthand routes into full relative URLs
|
||||||
|
if "forms" in gui_description and isinstance(api_gui["forms"], list):
|
||||||
|
for form in api_gui["forms"]:
|
||||||
|
if "route" in form and form["route"] in extension_object._rules.keys():
|
||||||
|
form["route"] = extension_object._rules[form["route"]]["rule"]
|
||||||
|
else:
|
||||||
|
logging.warn(
|
||||||
|
"No valid expandable route found for {}".format(form["route"])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Inject extension information
|
||||||
|
api_gui["id"] = extension_object.name
|
||||||
|
api_gui["version"] = extension_object.version
|
||||||
|
return api_gui
|
||||||
|
|
||||||
|
|
||||||
|
def build_gui_from_func(func, extension_object):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
return build_gui_from_dict(func(*args, **kwargs), extension_object)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def build_gui(gui_description, extension_object):
|
||||||
|
# If given a function that generates a GUI dictionary
|
||||||
|
if callable(gui_description):
|
||||||
|
# Wrap in the route expander
|
||||||
|
return build_gui_from_func(gui_description, extension_object)
|
||||||
|
# If given a dictionary directly
|
||||||
|
elif isinstance(gui_description, dict):
|
||||||
|
# Build a GUI generator function
|
||||||
|
def gui_description_func():
|
||||||
|
return gui_description
|
||||||
|
|
||||||
|
# Wrap in the route expander
|
||||||
|
return build_gui_from_func(gui_description_func, extension_object)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("GUI description must be a function or a dictionary")
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
from . import camera, stage, base, plugins, task
|
|
||||||
|
|
@ -1,253 +0,0 @@
|
||||||
from openflexure_microscope.api.utilities import gen, JsonResponse
|
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
|
||||||
|
|
||||||
from flask import Response, Blueprint, jsonify, request
|
|
||||||
import logging
|
|
||||||
|
|
||||||
|
|
||||||
class StreamAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
Real-time MJPEG stream from the microscope camera
|
|
||||||
|
|
||||||
.. :quickref: State; Camera stream
|
|
||||||
|
|
||||||
:>header Accept: image/jpeg
|
|
||||||
:>header Content-Type: image/jpeg
|
|
||||||
:status 200: stream active
|
|
||||||
"""
|
|
||||||
# Restart stream worker thread
|
|
||||||
self.microscope.camera.start_worker()
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
gen(self.microscope.camera),
|
|
||||||
mimetype="multipart/x-mixed-replace; boundary=frame",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SnapshotAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
Single snapshot from the camera stream
|
|
||||||
|
|
||||||
.. :quickref: State; Camera snapshot
|
|
||||||
|
|
||||||
:>header Accept: image/jpeg
|
|
||||||
:>header Content-Type: image/jpeg
|
|
||||||
:status 200: stream active
|
|
||||||
"""
|
|
||||||
# Restart stream worker thread
|
|
||||||
self.microscope.camera.start_worker()
|
|
||||||
|
|
||||||
return Response(self.microscope.camera.get_frame(), mimetype="image/jpeg")
|
|
||||||
|
|
||||||
|
|
||||||
class StateAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
JSON representation of the microscope object.
|
|
||||||
|
|
||||||
.. :quickref: State; Microscope state
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /state/ HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
**Example response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Vary: Accept
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"camera": {
|
|
||||||
"preview_active": false,
|
|
||||||
"record_active": false,
|
|
||||||
"stream_active": true
|
|
||||||
},
|
|
||||||
"plugin": {},
|
|
||||||
"stage": {
|
|
||||||
"backlash": {
|
|
||||||
"x": 128,
|
|
||||||
"y": 128,
|
|
||||||
"z": 128
|
|
||||||
},
|
|
||||||
"position": {
|
|
||||||
"x": -8080,
|
|
||||||
"y": 5665,
|
|
||||||
"z": -12600
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
:>header Content-Type: application/json
|
|
||||||
:status 200: state available
|
|
||||||
"""
|
|
||||||
return jsonify(self.microscope.state)
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
JSON representation of the microscope config.
|
|
||||||
|
|
||||||
.. :quickref: Config; Get microscope config
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /config/ HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
**Example response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Vary: Accept
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"id": "0e2c6fac5421429aac67c7903107bdd8",
|
|
||||||
"name": "docuscope-2000",
|
|
||||||
"fov": [4100, 3146],
|
|
||||||
"camera_settings": {
|
|
||||||
"image_resolution": [2592, 1944],
|
|
||||||
"numpy_resolution": [1312, 976],
|
|
||||||
"video_resolution": [832, 624],
|
|
||||||
"jpeg_quality": 75,
|
|
||||||
"picamera_settings": {
|
|
||||||
"analog_gain": 1.0,
|
|
||||||
"digital_gain": 1.0,
|
|
||||||
"awb_gains": [0.92578125, 2.94921875],
|
|
||||||
"awb_mode": "off",
|
|
||||||
"exposure_mode": "off",
|
|
||||||
"framerate": 24.0,
|
|
||||||
"saturation": 0,
|
|
||||||
"shutter_speed": 5378
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"stage_settings": {
|
|
||||||
"backlash": {
|
|
||||||
"x": 256,
|
|
||||||
"y": 256,
|
|
||||||
"z": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"plugins": [
|
|
||||||
"openflexure_microscope.plugins.default.autofocus:AutofocusPlugin",
|
|
||||||
"openflexure_microscope.plugins.default.scan:ScanPlugin",
|
|
||||||
"openflexure_microscope.plugins.default.camera_calibration:Plugin"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
:>header Content-Type: application/json
|
|
||||||
:status 200: state available
|
|
||||||
"""
|
|
||||||
return jsonify(self.microscope.read_settings(json_safe=True))
|
|
||||||
|
|
||||||
def post(self):
|
|
||||||
"""
|
|
||||||
Modify microscope configuration
|
|
||||||
|
|
||||||
.. :quickref: Config; Set microscope config
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
POST /config HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"id": "0e2c6fac5421429aac67c7903107bdd8",
|
|
||||||
"name": "docuscope-2000",
|
|
||||||
"fov": [4100, 3146],
|
|
||||||
"camera_settings": {
|
|
||||||
"image_resolution": [2592, 1944],
|
|
||||||
"numpy_resolution": [1312, 976],
|
|
||||||
"video_resolution": [832, 624],
|
|
||||||
"jpeg_quality": 75,
|
|
||||||
"picamera_settings": {
|
|
||||||
"analog_gain": 1.0,
|
|
||||||
"digital_gain": 1.0,
|
|
||||||
"awb_gains": [0.92578125, 2.94921875],
|
|
||||||
"awb_mode": "off",
|
|
||||||
"exposure_mode": "off",
|
|
||||||
"framerate": 24.0,
|
|
||||||
"saturation": 0,
|
|
||||||
"shutter_speed": 5378
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"stage_settings": {
|
|
||||||
"backlash": {
|
|
||||||
"x": 256,
|
|
||||||
"y": 256,
|
|
||||||
"z": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"plugins": [
|
|
||||||
"openflexure_microscope.plugins.default.autofocus:AutofocusPlugin",
|
|
||||||
"openflexure_microscope.plugins.default.scan:ScanPlugin",
|
|
||||||
"openflexure_microscope.plugins.default.camera_calibration:Plugin"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<json string id: Unique string identifier of the microscope.
|
|
||||||
:<json string name: Friendly name for the microscope
|
|
||||||
:<json array fov: Field of view (motor steps per full width and height of frame)
|
|
||||||
:<json json camera_settings: - **image_resolution** *(array)*: Resolution of full image captures
|
|
||||||
- **numpy_resolution** *(array)*: Resolution of full numpy array captures
|
|
||||||
- **video_resolution** *(array)*: Resolution of video recordings, low res image captures, and the preview stream
|
|
||||||
- **jpeg_quality** *(int)*: Quality in which to store JPEG capture data
|
|
||||||
- **picamera_settings** *(json)*: Key-value pairs to apply directly to any attached PiCamera object
|
|
||||||
:<json json stage_settings: - **backlash** *(json)*: x, y, and z backlash compensation, in motor steps
|
|
||||||
:<json array plugins: Array of plugin paths to load. Requires reloading the microscope object after applying
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: capture created
|
|
||||||
|
|
||||||
"""
|
|
||||||
payload = JsonResponse(request)
|
|
||||||
|
|
||||||
logging.debug("Updating settings from POST request:")
|
|
||||||
logging.debug(payload.json)
|
|
||||||
|
|
||||||
self.microscope.apply_settings(payload.json)
|
|
||||||
self.microscope.save_settings()
|
|
||||||
|
|
||||||
return jsonify(self.microscope.read_settings(json_safe=True))
|
|
||||||
|
|
||||||
|
|
||||||
def construct_blueprint(microscope_obj):
|
|
||||||
|
|
||||||
blueprint = Blueprint("base_blueprint", __name__)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/stream", view_func=StreamAPI.as_view("stream", microscope=microscope_obj)
|
|
||||||
)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/snapshot",
|
|
||||||
view_func=SnapshotAPI.as_view("snapshot", microscope=microscope_obj),
|
|
||||||
)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/state", view_func=StateAPI.as_view("state", microscope=microscope_obj)
|
|
||||||
)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/config", view_func=ConfigAPI.as_view("config", microscope=microscope_obj)
|
|
||||||
)
|
|
||||||
|
|
||||||
return blueprint
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
from flask import Blueprint
|
|
||||||
|
|
||||||
from . import capture, preview, function
|
|
||||||
|
|
||||||
|
|
||||||
def construct_blueprint(microscope_obj):
|
|
||||||
|
|
||||||
blueprint = Blueprint("camera_blueprint", __name__)
|
|
||||||
|
|
||||||
# Tag routes
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/capture/<capture_id>/tags",
|
|
||||||
view_func=capture.TagsAPI.as_view("capture_tags", microscope=microscope_obj),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Capture routes
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/capture/<capture_id>/download/<filename>",
|
|
||||||
view_func=capture.DownloadAPI.as_view(
|
|
||||||
"capture_download", microscope=microscope_obj
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/capture/<capture_id>/download",
|
|
||||||
view_func=capture.DownloadRedirectAPI.as_view(
|
|
||||||
"capture_download_redirect", microscope=microscope_obj
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/capture/<capture_id>/",
|
|
||||||
view_func=capture.CaptureAPI.as_view("capture", microscope=microscope_obj),
|
|
||||||
)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/capture/",
|
|
||||||
view_func=capture.ListAPI.as_view("capture_list", microscope=microscope_obj),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Preview routes
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/preview/<string:operation>",
|
|
||||||
view_func=preview.GPUPreviewAPI.as_view(
|
|
||||||
"gpu_preview", microscope=microscope_obj
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Function routes
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/overlay",
|
|
||||||
view_func=function.OverlayAPI.as_view("overlay", microscope=microscope_obj),
|
|
||||||
)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/zoom", view_func=function.ZoomAPI.as_view("zoom", microscope=microscope_obj)
|
|
||||||
)
|
|
||||||
|
|
||||||
return blueprint
|
|
||||||
|
|
@ -1,459 +0,0 @@
|
||||||
from openflexure_microscope.api.utilities import get_bool, JsonResponse
|
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
|
||||||
from openflexure_microscope.utilities import filter_dict
|
|
||||||
|
|
||||||
from flask import jsonify, request, abort, url_for, redirect, send_file
|
|
||||||
|
|
||||||
|
|
||||||
class ListAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
Get list of image captures.
|
|
||||||
|
|
||||||
.. :quickref: Captures; Get collection of captures
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
:query include_unavailable: return json representations of captures that have been completely deleted
|
|
||||||
|
|
||||||
:>jsonarr boolean available: availability of capture data
|
|
||||||
:>jsonarr string filename: filename of capture
|
|
||||||
:>jsonarr string id: unique id of the capture object
|
|
||||||
:>jsonarr boolean keep_on_disk: keep the capture file on microscope after closing
|
|
||||||
:>jsonarr boolean locked: file locked for modifications (mostly used for video recording)
|
|
||||||
:>jsonarr string path: path on pi storage to the capture file, if available
|
|
||||||
:>jsonarr boolean stream: capture stored in-memory as a BytesIO stream
|
|
||||||
:>jsonarr json uri: - **download** *(string)*: api uri to the capture file download
|
|
||||||
- **state** *(string)*: api uri to the capture json representation
|
|
||||||
|
|
||||||
:>header Content-Type: application/json
|
|
||||||
:status 200: capture found
|
|
||||||
:status 404: no capture found with that id
|
|
||||||
"""
|
|
||||||
include_unavailable = get_bool(request.args.get("include_unavailable"))
|
|
||||||
|
|
||||||
if include_unavailable:
|
|
||||||
captures = [image.state for image in self.microscope.camera.images]
|
|
||||||
else:
|
|
||||||
captures = [
|
|
||||||
image.state
|
|
||||||
for image in self.microscope.camera.images
|
|
||||||
if image.state["available"]
|
|
||||||
]
|
|
||||||
|
|
||||||
return jsonify(captures)
|
|
||||||
|
|
||||||
def delete(self):
|
|
||||||
"""
|
|
||||||
Delete all captures (not yet implemented)
|
|
||||||
|
|
||||||
.. :quickref: Captures; Delete all captures
|
|
||||||
"""
|
|
||||||
for image in self.microscope.camera.images:
|
|
||||||
image.delete()
|
|
||||||
|
|
||||||
captures = [image.state for image in self.microscope.camera.images]
|
|
||||||
|
|
||||||
return jsonify(captures)
|
|
||||||
|
|
||||||
def post(self):
|
|
||||||
"""
|
|
||||||
Create a new image capture.
|
|
||||||
|
|
||||||
.. :quickref: Captures; New capture
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
POST /camera/capture HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"filename": "myfirstcapture",
|
|
||||||
"temporary": false,
|
|
||||||
"use_video_port": true,
|
|
||||||
"bayer": true,
|
|
||||||
"size": {
|
|
||||||
"width": 640,
|
|
||||||
"height": 480
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<json string filename: filename of stored capture
|
|
||||||
:<json boolean temporary: delete the capture file on microscope after closing
|
|
||||||
:<json boolean use_video_port: capture still image from the video port
|
|
||||||
:<json boolean bayer: keep raw capture data in the image file
|
|
||||||
:<json json size: - **x** *(int)*: x-axis resize
|
|
||||||
- **y** *(int)*: y-axis resize
|
|
||||||
|
|
||||||
:>json boolean available: availability of capture data
|
|
||||||
:>json string filename: filename of capture
|
|
||||||
:>json string id: unique id of the capture object
|
|
||||||
:>json boolean temporary: delete the capture file on microscope after closing
|
|
||||||
:>json boolean locked: file locked for modifications (mostly used for video recording)
|
|
||||||
:>json string path: path on pi storage to the capture file, if available
|
|
||||||
:>json boolean stream: capture stored in-memory as a BytesIO stream
|
|
||||||
:>json json uri: - **download** *(string)*: api uri to the capture file download
|
|
||||||
- **state** *(string)*: api uri to the capture json representation
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: capture created
|
|
||||||
"""
|
|
||||||
payload = JsonResponse(request)
|
|
||||||
|
|
||||||
filename = payload.param("filename")
|
|
||||||
temporary = payload.param("temporary", default=False, convert=bool)
|
|
||||||
use_video_port = payload.param("use_video_port", default=False, convert=bool)
|
|
||||||
bayer = payload.param("bayer", default=True, convert=bool)
|
|
||||||
metadata = payload.param("metadata", default={}, convert=dict)
|
|
||||||
tags = payload.param("tags", default=[], convert=list)
|
|
||||||
|
|
||||||
resize = payload.param("size", default=None)
|
|
||||||
if resize:
|
|
||||||
if ("width" in resize) and ("height" in resize):
|
|
||||||
resize = (
|
|
||||||
int(resize["width"]),
|
|
||||||
int(resize["height"]),
|
|
||||||
) # Convert dict to tuple
|
|
||||||
else:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
# Explicitally acquire lock (prevents empty files being created if lock is unavailable)
|
|
||||||
with self.microscope.camera.lock:
|
|
||||||
output = self.microscope.camera.new_image(
|
|
||||||
temporary=temporary, filename=filename
|
|
||||||
)
|
|
||||||
|
|
||||||
self.microscope.camera.capture(
|
|
||||||
output.file, use_video_port=use_video_port, resize=resize, bayer=bayer
|
|
||||||
)
|
|
||||||
|
|
||||||
# Inject system metadata
|
|
||||||
system_metadata = {
|
|
||||||
"microscope_settings": self.microscope.read_settings(),
|
|
||||||
"microscope_state": self.microscope.state,
|
|
||||||
"microscope_id": self.microscope.id,
|
|
||||||
"microscope_name": self.microscope.name,
|
|
||||||
}
|
|
||||||
output.system_metadata.update(system_metadata)
|
|
||||||
|
|
||||||
# Insert custom metadata
|
|
||||||
output.put_metadata(metadata)
|
|
||||||
|
|
||||||
# Insert custom tags
|
|
||||||
output.put_tags(tags)
|
|
||||||
|
|
||||||
return jsonify(output.state)
|
|
||||||
|
|
||||||
|
|
||||||
class CaptureAPI(MicroscopeView):
|
|
||||||
def get(self, capture_id):
|
|
||||||
"""
|
|
||||||
Get JSON representation of a capture
|
|
||||||
|
|
||||||
.. :quickref: Capture; Get capture
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/ HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
**Example response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Vary: Accept
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"available": true,
|
|
||||||
"bytestream": false,
|
|
||||||
"keep_on_disk": false,
|
|
||||||
"locked": false,
|
|
||||||
"metadata": {
|
|
||||||
"filename": "2018-11-16_10-21-53.jpeg",
|
|
||||||
"id": "d0b2067abbb946f19351e075c5e7cd5b",
|
|
||||||
"path": "capture/2018-11-16_10-21-53.jpeg",
|
|
||||||
"time": "2018-11-16_10-21-53"
|
|
||||||
}
|
|
||||||
"uri": {
|
|
||||||
"download": "/api/v1/capture/d0b2067abbb946f19351e075c5e7cd5b/download",
|
|
||||||
"state": "/api/v1/capture/d0b2067abbb946f19351e075c5e7cd5b/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:>json boolean available: availability of capture data
|
|
||||||
:>json string filename: filename of capture
|
|
||||||
:>json string id: unique id of the capture object
|
|
||||||
:>json boolean keep_on_disk: keep the capture file on microscope after closing
|
|
||||||
:>json boolean locked: file locked for modifications (mostly used for video recording)
|
|
||||||
:>json string path: path on pi storage to the capture file, if available
|
|
||||||
:>json boolean stream: capture stored in-memory as a BytesIO stream
|
|
||||||
:>json json uri: - **download** *(string)*: api uri to the capture file download
|
|
||||||
- **state** *(string)*: api uri to the capture json representation
|
|
||||||
|
|
||||||
"""
|
|
||||||
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
|
||||||
|
|
||||||
if not capture_obj:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
# Get capture state
|
|
||||||
capture_metadata = capture_obj.state
|
|
||||||
|
|
||||||
# Add API routes to returned state
|
|
||||||
uri_dict = {
|
|
||||||
"uri": {
|
|
||||||
"state": "{}".format(url_for(".capture", capture_id=capture_obj.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# If available, also add download link
|
|
||||||
if capture_metadata["available"]:
|
|
||||||
uri_dict["uri"]["download"] = "{}download/{}".format(
|
|
||||||
url_for(".capture", capture_id=capture_obj.id), capture_obj.filename
|
|
||||||
)
|
|
||||||
|
|
||||||
capture_metadata.update(uri_dict)
|
|
||||||
|
|
||||||
return jsonify(capture_metadata)
|
|
||||||
|
|
||||||
def delete(self, capture_id):
|
|
||||||
"""
|
|
||||||
Delete all capture data from the Pi, even if `keep_on_disk=true;`.
|
|
||||||
|
|
||||||
.. :quickref: Capture; Delete capture.
|
|
||||||
"""
|
|
||||||
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
|
||||||
|
|
||||||
if not capture_obj:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
capture_obj.delete()
|
|
||||||
|
|
||||||
return jsonify({"return": capture_id})
|
|
||||||
|
|
||||||
def put(self, capture_id):
|
|
||||||
"""
|
|
||||||
Add arbitrary metadata to the capture
|
|
||||||
|
|
||||||
.. :quickref: Capture; Update capture metadata
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
PUT /camera/capture/d0b2067abbb946f19351e075c5e7cd5b HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"user_id": "ofm_1234",
|
|
||||||
"patient_number": 1452,
|
|
||||||
}
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: metadata updated
|
|
||||||
"""
|
|
||||||
|
|
||||||
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
|
||||||
|
|
||||||
if not capture_obj:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
data_dict = JsonResponse(request).json
|
|
||||||
|
|
||||||
capture_obj.put_metadata(data_dict)
|
|
||||||
|
|
||||||
capture_metadata = capture_obj.state
|
|
||||||
|
|
||||||
return jsonify(capture_metadata)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadRedirectAPI(MicroscopeView):
|
|
||||||
def get(self, capture_id):
|
|
||||||
"""
|
|
||||||
Return image data for a capture.
|
|
||||||
|
|
||||||
Return capture data as an image file with the requested filename.
|
|
||||||
I.e., `/(capture_id)/download/foo.jpeg` will download the image as
|
|
||||||
`foo.jpeg`, regardless of the capture's initially set filename.
|
|
||||||
|
|
||||||
Route automatically redirects to download the capture under it's currently set filename.
|
|
||||||
I.e., `/(capture_id)/download` will
|
|
||||||
redirect to `/(capture_id)/download/(filename)`.
|
|
||||||
|
|
||||||
.. :quickref: Capture; Download capture image
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/download/2018-11-20_16-04-17.jpeg HTTP/1.1
|
|
||||||
Accept: image/jpeg
|
|
||||||
|
|
||||||
:>header Accept: image/jpeg
|
|
||||||
:query thumbnail: return an image thumbnail e.g. ?thumbnail=true
|
|
||||||
|
|
||||||
:>header Content-Type: image/jpeg
|
|
||||||
:status 200: capture data found
|
|
||||||
:status 404: no capture found with that id
|
|
||||||
|
|
||||||
"""
|
|
||||||
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
|
||||||
|
|
||||||
if not capture_obj or not capture_obj.state["available"]:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
thumbnail = get_bool(request.args.get("thumbnail"))
|
|
||||||
|
|
||||||
return redirect(
|
|
||||||
url_for(
|
|
||||||
".capture_download",
|
|
||||||
capture_id=capture_id,
|
|
||||||
filename=capture_obj.filename,
|
|
||||||
thumbnail=thumbnail,
|
|
||||||
),
|
|
||||||
code=307,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadAPI(MicroscopeView):
|
|
||||||
def get(self, capture_id, filename):
|
|
||||||
|
|
||||||
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
|
||||||
|
|
||||||
if not capture_obj or not capture_obj.state["available"]:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
thumbnail = get_bool(request.args.get("thumbnail"))
|
|
||||||
|
|
||||||
# If no filename is specified, redirect to the capture's currently set filename
|
|
||||||
if not filename:
|
|
||||||
return redirect(
|
|
||||||
url_for(
|
|
||||||
"capture_download",
|
|
||||||
capture_id=capture_id,
|
|
||||||
filename=capture_obj.filename,
|
|
||||||
thumbnail=thumbnail,
|
|
||||||
),
|
|
||||||
code=307,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Download the image data using the requested filename
|
|
||||||
if thumbnail:
|
|
||||||
img = capture_obj.thumbnail
|
|
||||||
else:
|
|
||||||
img = capture_obj.data
|
|
||||||
|
|
||||||
return send_file(img, mimetype="image/jpeg")
|
|
||||||
|
|
||||||
|
|
||||||
class TagsAPI(MicroscopeView):
|
|
||||||
def get(self, capture_id):
|
|
||||||
"""
|
|
||||||
Return tag list for a capture.
|
|
||||||
|
|
||||||
.. :quickref: Capture; Show capture tags
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1
|
|
||||||
Accept: text/json
|
|
||||||
|
|
||||||
:>header Accept: text/json
|
|
||||||
|
|
||||||
:>header Content-Type: text/json
|
|
||||||
:status 200: capture data found
|
|
||||||
:status 404: no capture found with that id
|
|
||||||
"""
|
|
||||||
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
|
||||||
|
|
||||||
if not capture_obj or not capture_obj.state["available"]:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
metadata_tags = filter_dict(capture_obj.state, ["metadata", "tags"])
|
|
||||||
|
|
||||||
return jsonify(metadata_tags)
|
|
||||||
|
|
||||||
def put(self, capture_id):
|
|
||||||
"""
|
|
||||||
Add tags to the capture
|
|
||||||
|
|
||||||
.. :quickref: Capture; Update capture tags
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
PUT /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
["tests", "mytag", "someothertag"]
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: metadata updated
|
|
||||||
"""
|
|
||||||
|
|
||||||
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
|
||||||
|
|
||||||
if not capture_obj or not capture_obj.state["available"]:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
data_dict = JsonResponse(request).json
|
|
||||||
|
|
||||||
if type(data_dict) != list:
|
|
||||||
return abort(400)
|
|
||||||
|
|
||||||
capture_obj.put_tags(data_dict)
|
|
||||||
|
|
||||||
metadata_tags = filter_dict(capture_obj.state, ["metadata", "tags"])
|
|
||||||
|
|
||||||
return jsonify(metadata_tags)
|
|
||||||
|
|
||||||
def delete(self, capture_id):
|
|
||||||
"""
|
|
||||||
Delete tags from the capture
|
|
||||||
|
|
||||||
.. :quickref: Capture; Delete tags.
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
DELETE /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
["tests", "mytag"]
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: metadata updated
|
|
||||||
"""
|
|
||||||
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
|
||||||
|
|
||||||
if not capture_obj:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
data_dict = JsonResponse(request).json
|
|
||||||
|
|
||||||
if type(data_dict) != list:
|
|
||||||
return abort(400)
|
|
||||||
|
|
||||||
for tag in data_dict:
|
|
||||||
capture_obj.delete_tag(str(tag))
|
|
||||||
|
|
||||||
metadata_tags = filter_dict(capture_obj.state, ["metadata", "tags"])
|
|
||||||
|
|
||||||
return jsonify(metadata_tags)
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
|
||||||
from openflexure_microscope.api.utilities import JsonResponse
|
|
||||||
|
|
||||||
from flask import jsonify, request
|
|
||||||
|
|
||||||
|
|
||||||
class ZoomAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
Get the current zoom value
|
|
||||||
|
|
||||||
.. :quickref: Zoom; Get the zoom value
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: preview started/stopped
|
|
||||||
"""
|
|
||||||
zoom_value = self.microscope.camera.status["zoom_value"]
|
|
||||||
|
|
||||||
return jsonify({"zoom_value": zoom_value})
|
|
||||||
|
|
||||||
def post(self):
|
|
||||||
"""
|
|
||||||
Change the current zoom value
|
|
||||||
|
|
||||||
.. :quickref: Zoom; Set the zoom value
|
|
||||||
|
|
||||||
**Example requests**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
POST /camera/zoom HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"zoom_value": 2.5,
|
|
||||||
}
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: preview started/stopped
|
|
||||||
"""
|
|
||||||
payload = JsonResponse(request)
|
|
||||||
zoom_value = payload.param("zoom_value", default=1.0, convert=float)
|
|
||||||
|
|
||||||
self.microscope.camera.set_zoom(zoom_value)
|
|
||||||
|
|
||||||
return jsonify(self.microscope.camera.status)
|
|
||||||
|
|
||||||
|
|
||||||
class OverlayAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
Get overlay text
|
|
||||||
|
|
||||||
.. :quickref: Overlay; Get camera overlay text
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: preview started/stopped
|
|
||||||
"""
|
|
||||||
text = self.microscope.camera.camera.annotate_text
|
|
||||||
size = self.microscope.camera.camera.annotate_text_size
|
|
||||||
|
|
||||||
return jsonify({"text": text, "size": size})
|
|
||||||
|
|
||||||
def post(self):
|
|
||||||
"""
|
|
||||||
Set overlay text
|
|
||||||
|
|
||||||
.. :quickref: Overlay; Set camera overlay text
|
|
||||||
|
|
||||||
**Example requests**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
POST /camera/overlay HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"text": "2019/01/15 14:48",
|
|
||||||
"size": 50
|
|
||||||
}
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: preview started/stopped
|
|
||||||
"""
|
|
||||||
|
|
||||||
payload = JsonResponse(request)
|
|
||||||
text = payload.param("text", default="", convert=str)
|
|
||||||
size = payload.param("size", default=50, convert=int)
|
|
||||||
|
|
||||||
self.microscope.camera.camera.annotate_text = text
|
|
||||||
self.microscope.camera.camera.annotate_text_size = size
|
|
||||||
|
|
||||||
return jsonify({"text": text, "size": size})
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
from openflexure_microscope.api.utilities import JsonResponse
|
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
|
||||||
|
|
||||||
from flask import jsonify, request
|
|
||||||
import logging
|
|
||||||
|
|
||||||
|
|
||||||
class GPUPreviewAPI(MicroscopeView):
|
|
||||||
def post(self, operation):
|
|
||||||
"""
|
|
||||||
Start or stop the onboard GPU preview.
|
|
||||||
Optional "window" parameter can be passed to control the position and size of the preview window,
|
|
||||||
in the format ``[x, y, width, height]``.
|
|
||||||
|
|
||||||
.. :quickref: GPU Preview; Start/stop preview
|
|
||||||
|
|
||||||
**Example requests**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
POST /camera/preview/start HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"window": [0, 0, 480, 320],
|
|
||||||
}
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
POST /camera/preview/stop HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: preview started/stopped
|
|
||||||
"""
|
|
||||||
if operation == "start":
|
|
||||||
payload = JsonResponse(request)
|
|
||||||
|
|
||||||
window = payload.param("window", default=[])
|
|
||||||
logging.debug(window)
|
|
||||||
|
|
||||||
if len(window) != 4:
|
|
||||||
fullscreen = True
|
|
||||||
window = None
|
|
||||||
else:
|
|
||||||
fullscreen = False
|
|
||||||
window = [int(w) for w in window]
|
|
||||||
|
|
||||||
self.microscope.camera.start_preview(fullscreen=fullscreen, window=window)
|
|
||||||
elif operation == "stop":
|
|
||||||
self.microscope.camera.stop_preview()
|
|
||||||
return jsonify(self.microscope.state)
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
from openflexure_microscope.api.views import MicroscopeViewPlugin
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify
|
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
|
||||||
|
|
||||||
import copy
|
|
||||||
import logging
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
|
|
||||||
class PluginFormAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
Return the current plugin forms
|
|
||||||
|
|
||||||
.. :quickref: Plugin; Get forms
|
|
||||||
|
|
||||||
Returns an array of present plugin forms (describing plugin user interfaces.)
|
|
||||||
Please note, this is *not* a list of all enabled plugins, only those with associated
|
|
||||||
user interface forms.
|
|
||||||
|
|
||||||
A complete list of enabled plugins can be found in the microscope state.
|
|
||||||
|
|
||||||
"""
|
|
||||||
out = self.microscope.plugins.forms
|
|
||||||
return jsonify(out)
|
|
||||||
|
|
||||||
|
|
||||||
def construct_blueprint(microscope_obj):
|
|
||||||
|
|
||||||
blueprint = Blueprint("plugin_blueprint", __name__)
|
|
||||||
|
|
||||||
# Create a base route to return plugin API forms, if any exist
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/",
|
|
||||||
view_func=PluginFormAPI.as_view("plugin_api_form", microscope=microscope_obj),
|
|
||||||
)
|
|
||||||
|
|
||||||
all_routes = []
|
|
||||||
|
|
||||||
# For each plugin attached to the microscope object
|
|
||||||
for plugin_name, plugin_obj in microscope_obj.plugins._legacy_plugins.items():
|
|
||||||
|
|
||||||
# If plugin contains valid endpoints
|
|
||||||
if hasattr(plugin_obj, "api_views") and isinstance(plugin_obj.api_views, dict):
|
|
||||||
|
|
||||||
# We'll keep a record of how each route was expanded
|
|
||||||
expanded_routes = {}
|
|
||||||
|
|
||||||
# For each defined endpoint
|
|
||||||
for view_route, view_class in plugin_obj.api_views.items():
|
|
||||||
|
|
||||||
# Remove all leading slashes from view route
|
|
||||||
cleaned_route = view_route
|
|
||||||
while cleaned_route[0] == "/":
|
|
||||||
cleaned_route = cleaned_route[1:]
|
|
||||||
|
|
||||||
# Construct a full view route from the plugin name
|
|
||||||
full_view_route = "/{}/{}".format(plugin_name, cleaned_route)
|
|
||||||
logging.debug(full_view_route)
|
|
||||||
|
|
||||||
# Record how the view_route got expanded
|
|
||||||
expanded_routes[view_route] = full_view_route
|
|
||||||
|
|
||||||
# Check if endpoint name clashes
|
|
||||||
if full_view_route not in all_routes and issubclass(
|
|
||||||
view_class, MicroscopeViewPlugin
|
|
||||||
):
|
|
||||||
# Add route to main route dictionary
|
|
||||||
all_routes.append(full_view_route)
|
|
||||||
|
|
||||||
# Create a Python-safe name for the route
|
|
||||||
plugin_route_id = "plugin{}".format(full_view_route).replace(
|
|
||||||
"/", "_"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add route to the plugins blueprint
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
full_view_route,
|
|
||||||
view_func=view_class.as_view(
|
|
||||||
plugin_route_id,
|
|
||||||
microscope=microscope_obj,
|
|
||||||
plugin=plugin_obj,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
warnings.warn(
|
|
||||||
"An endpoint /{} has already been loaded. Skipping {}.".format(
|
|
||||||
full_view_route, view_class
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# If plugin includes an API form
|
|
||||||
if hasattr(plugin_obj, "api_form") and isinstance(
|
|
||||||
plugin_obj.api_form, dict
|
|
||||||
):
|
|
||||||
api_form_info = copy.deepcopy(plugin_obj.api_form)
|
|
||||||
api_form_info["id"] = plugin_name
|
|
||||||
if "forms" in api_form_info and isinstance(
|
|
||||||
api_form_info["forms"], list
|
|
||||||
):
|
|
||||||
for form in api_form_info["forms"]:
|
|
||||||
if "route" in form and form["route"] in expanded_routes.keys():
|
|
||||||
form["route"] = expanded_routes[form["route"]]
|
|
||||||
else:
|
|
||||||
logging.warn(
|
|
||||||
"No valid expandable route found for {}".format(
|
|
||||||
form["route"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Store the complete form in Microscope().plugin.form
|
|
||||||
microscope_obj.plugins.forms.append(api_form_info)
|
|
||||||
|
|
||||||
else:
|
|
||||||
warnings.warn(
|
|
||||||
"No valid 'api_views' dictionary found in {}".format(plugin_obj)
|
|
||||||
)
|
|
||||||
return blueprint
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
from openflexure_microscope.api.utilities import JsonResponse
|
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
|
||||||
from openflexure_microscope.utilities import axes_to_array, filter_dict
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
|
|
||||||
class PositionAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
Return current x, y and z positions of the stage.
|
|
||||||
The response is formatted as a sub-section of the general microscope state.
|
|
||||||
|
|
||||||
.. :quickref: Position; Get current position
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /stage/position/ HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
**Example response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Vary: Accept
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"stage": {
|
|
||||||
"position": {
|
|
||||||
"x": -6629,
|
|
||||||
"y": 7489,
|
|
||||||
"z": -3844
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"""
|
|
||||||
out = filter_dict(self.microscope.state, ["stage", "position"])
|
|
||||||
return jsonify(out)
|
|
||||||
|
|
||||||
def post(self):
|
|
||||||
"""
|
|
||||||
Set x, y and z positions of the stage.
|
|
||||||
|
|
||||||
.. :quickref: Position; Update current position
|
|
||||||
|
|
||||||
:reqheader Accept: application/json
|
|
||||||
:<json boolean absolute: (true) move to absolute position, (false) move by relative amount
|
|
||||||
:<json boolean force: allow moving by more than programmed limit
|
|
||||||
:<json int x: x steps
|
|
||||||
:<json int y: y steps
|
|
||||||
:<json int z: z steps
|
|
||||||
|
|
||||||
"""
|
|
||||||
# Create response object
|
|
||||||
payload = JsonResponse(request)
|
|
||||||
logging.debug(payload.json)
|
|
||||||
|
|
||||||
# Construct position array
|
|
||||||
position = [0, 0, 0]
|
|
||||||
|
|
||||||
# Handle absolute positioning (calculate a relative move from current position and target)
|
|
||||||
if (payload.param("absolute") is True) and (
|
|
||||||
self.microscope.stage
|
|
||||||
): # Only if stage exists
|
|
||||||
target_position = axes_to_array(payload.json, ["x", "y", "z"])
|
|
||||||
logging.debug("TARGET: {}".format(target_position))
|
|
||||||
position = [
|
|
||||||
target_position[i] - self.microscope.stage.position[i] for i in range(3)
|
|
||||||
]
|
|
||||||
logging.debug("DELTA: {}".format(position))
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Get coordinates from payload
|
|
||||||
position = axes_to_array(payload.json, ["x", "y", "z"], [0, 0, 0])
|
|
||||||
|
|
||||||
logging.debug(position)
|
|
||||||
|
|
||||||
# Move if stage exists
|
|
||||||
if self.microscope.stage:
|
|
||||||
# Explicitally acquire lock
|
|
||||||
with self.microscope.stage.lock:
|
|
||||||
self.microscope.stage.move_rel(position)
|
|
||||||
|
|
||||||
out = filter_dict(self.microscope.state, ["stage", "position"])
|
|
||||||
|
|
||||||
return jsonify(out)
|
|
||||||
|
|
||||||
|
|
||||||
def construct_blueprint(microscope_obj):
|
|
||||||
|
|
||||||
blueprint = Blueprint("stage_blueprint", __name__)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/position",
|
|
||||||
view_func=PositionAPI.as_view("position", microscope=microscope_obj),
|
|
||||||
)
|
|
||||||
|
|
||||||
return blueprint
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
from openflexure_microscope.api.views import MethodView
|
|
||||||
from flask import jsonify, abort, Blueprint
|
|
||||||
|
|
||||||
from openflexure_microscope.common import tasks
|
|
||||||
|
|
||||||
|
|
||||||
class TaskListAPI(MethodView):
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
Get list of long-running tasks.
|
|
||||||
|
|
||||||
.. :quickref: Tasks; Get collection of tasks
|
|
||||||
|
|
||||||
**Example response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Vary: Accept
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"end_time": "2019-01-23 16-33-33",
|
|
||||||
"id": "db13a66787e1419bb06b1504e4d80b0c",
|
|
||||||
"return": [
|
|
||||||
0.848622546386467,
|
|
||||||
0.6106785018091292,
|
|
||||||
],
|
|
||||||
"start_time": "2019-01-23 16-33-13",
|
|
||||||
"status": "success"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"end_time": null,
|
|
||||||
"id": "df46558cc8844924821bd0181881871e",
|
|
||||||
"return": null,
|
|
||||||
"start_time": "2019-01-23 16-34-54",
|
|
||||||
"status": "running"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
:query include_unavailable: return json representations of captures that have been completely deleted
|
|
||||||
|
|
||||||
:>header Content-Type: application/json
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = tasks.states()
|
|
||||||
|
|
||||||
return jsonify(data)
|
|
||||||
|
|
||||||
def delete(self):
|
|
||||||
"""
|
|
||||||
Clean list of long-running tasks (running tasks persist).
|
|
||||||
|
|
||||||
.. :quickref: Tasks; Clean collection of tasks
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:>header Content-Type: application/json
|
|
||||||
"""
|
|
||||||
|
|
||||||
tasks.cleanup_tasks()
|
|
||||||
data = tasks.states()
|
|
||||||
|
|
||||||
return jsonify(data)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskAPI(MethodView):
|
|
||||||
def get(self, task_id):
|
|
||||||
"""
|
|
||||||
Get JSON representation of a task
|
|
||||||
|
|
||||||
.. :quickref: Tasks; Get task
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /task/db13a66787e1419bb06b1504e4d80b0c/ HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
**Example response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Vary: Accept
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"end_time": "2019-01-23 16-33-33",
|
|
||||||
"id": "db13a66787e1419bb06b1504e4d80b0c",
|
|
||||||
"return": [
|
|
||||||
0.848622546386467,
|
|
||||||
0.6106785018091292,
|
|
||||||
],
|
|
||||||
"start_time": "2019-01-23 16-33-13",
|
|
||||||
"status": "success"
|
|
||||||
}
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
task = tasks.tasks()[task_id]
|
|
||||||
except KeyError:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
# Return task state
|
|
||||||
return jsonify(task.state)
|
|
||||||
|
|
||||||
def delete(self, task_id):
|
|
||||||
"""
|
|
||||||
Terminate a particular task.
|
|
||||||
|
|
||||||
.. :quickref: Tasks; Terminate a task
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:>header Content-Type: application/json
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
task = tasks.tasks()[task_id]
|
|
||||||
except KeyError:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
task.terminate()
|
|
||||||
|
|
||||||
return jsonify(task.state)
|
|
||||||
|
|
||||||
|
|
||||||
def construct_blueprint(microscope_obj):
|
|
||||||
|
|
||||||
blueprint = Blueprint("task_blueprint", __name__)
|
|
||||||
|
|
||||||
blueprint.add_url_rule("/", view_func=TaskListAPI.as_view("task_list"))
|
|
||||||
|
|
||||||
blueprint.add_url_rule("/<task_id>/", view_func=TaskAPI.as_view("task"))
|
|
||||||
|
|
||||||
return blueprint
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
from . import blueprints
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
from . import root, captures, settings, status, tasks, streams, plugins, actions
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
"""
|
|
||||||
Top-level representation of enabled actions
|
|
||||||
"""
|
|
||||||
|
|
||||||
from flask import Blueprint, url_for, jsonify
|
|
||||||
|
|
||||||
from openflexure_microscope.api.utilities import blueprint_for_module
|
|
||||||
from openflexure_microscope.utilities import get_docstring, description_from_view
|
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
|
||||||
|
|
||||||
from . import camera, stage, system
|
|
||||||
|
|
||||||
|
|
||||||
class ActionsAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
return jsonify(actions_representation())
|
|
||||||
|
|
||||||
|
|
||||||
_actions = {
|
|
||||||
"capture": {
|
|
||||||
"rule": "/camera/capture/",
|
|
||||||
"view_class": camera.CaptureAPI,
|
|
||||||
"conditions": True,
|
|
||||||
},
|
|
||||||
"previewStart": {
|
|
||||||
"rule": "/camera/preview/start",
|
|
||||||
"view_class": camera.GPUPreviewStartAPI,
|
|
||||||
"conditions": True,
|
|
||||||
},
|
|
||||||
"previewStop": {
|
|
||||||
"rule": "/camera/preview/stop",
|
|
||||||
"view_class": camera.GPUPreviewStopAPI,
|
|
||||||
"conditions": True,
|
|
||||||
},
|
|
||||||
"move": {
|
|
||||||
"rule": "/stage/move/",
|
|
||||||
"view_class": stage.MoveStageAPI,
|
|
||||||
"conditions": True,
|
|
||||||
},
|
|
||||||
"zeroStage": {
|
|
||||||
"rule": "/stage/zero/",
|
|
||||||
"view_class": stage.ZeroStageAPI,
|
|
||||||
"conditions": True,
|
|
||||||
},
|
|
||||||
"shutdown": {
|
|
||||||
"rule": "/system/shutdown/",
|
|
||||||
"view_class": system.ShutdownAPI,
|
|
||||||
"conditions": system.is_raspberrypi(),
|
|
||||||
},
|
|
||||||
"reboot": {
|
|
||||||
"rule": "/system/reboot/",
|
|
||||||
"view_class": system.RebootAPI,
|
|
||||||
"conditions": system.is_raspberrypi(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def enabled_actions():
|
|
||||||
global _actions
|
|
||||||
return {k: v for k, v in _actions.items() if v["conditions"]}
|
|
||||||
|
|
||||||
|
|
||||||
def actions_representation():
|
|
||||||
global _actions
|
|
||||||
|
|
||||||
actions = {}
|
|
||||||
for name, action in enabled_actions().items():
|
|
||||||
d = {
|
|
||||||
"links": {"self": url_for(f".{name}")},
|
|
||||||
"rule": action["rule"],
|
|
||||||
"view_class": str(action["view_class"]),
|
|
||||||
}
|
|
||||||
|
|
||||||
d.update(description_from_view(action["view_class"]))
|
|
||||||
|
|
||||||
actions[name] = d
|
|
||||||
|
|
||||||
return actions
|
|
||||||
|
|
||||||
|
|
||||||
def construct_blueprint(microscope_obj):
|
|
||||||
global _actions
|
|
||||||
|
|
||||||
blueprint = blueprint_for_module(__name__)
|
|
||||||
|
|
||||||
# For each enabled action route defined in our dictionary above
|
|
||||||
for name, action in enabled_actions().items():
|
|
||||||
# Add the action to our blueprint
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
action["rule"],
|
|
||||||
view_func=action["view_class"].as_view(name, microscope=microscope_obj),
|
|
||||||
)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/", view_func=ActionsAPI.as_view("actions", microscope=microscope_obj)
|
|
||||||
)
|
|
||||||
|
|
||||||
return blueprint
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
from openflexure_microscope.api.utilities import get_bool, JsonResponse
|
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
|
||||||
from openflexure_microscope.utilities import filter_dict
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from flask import jsonify, request, abort, url_for, redirect, send_file
|
|
||||||
|
|
||||||
|
|
||||||
class CaptureAPI(MicroscopeView):
|
|
||||||
"""
|
|
||||||
Create a new image capture.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def post(self):
|
|
||||||
"""
|
|
||||||
Create a new image capture.
|
|
||||||
|
|
||||||
.. :quickref: Actions; New capture
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
POST /actions/camera/capture HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"filename": "myfirstcapture",
|
|
||||||
"temporary": false,
|
|
||||||
"use_video_port": true,
|
|
||||||
"bayer": true,
|
|
||||||
"size": {
|
|
||||||
"width": 640,
|
|
||||||
"height": 480
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<json string filename: filename of stored capture
|
|
||||||
:<json boolean temporary: delete the capture file on microscope after closing
|
|
||||||
:<json boolean use_video_port: capture still image from the video port
|
|
||||||
:<json boolean bayer: keep raw capture data in the image file
|
|
||||||
:<json json size: - **x** *(int)*: x-axis resize
|
|
||||||
- **y** *(int)*: y-axis resize
|
|
||||||
|
|
||||||
:>json boolean available: availability of capture data
|
|
||||||
:>json string filename: filename of capture
|
|
||||||
:>json string id: unique id of the capture object
|
|
||||||
:>json boolean temporary: delete the capture file on microscope after closing
|
|
||||||
:>json boolean locked: file locked for modifications (mostly used for video recording)
|
|
||||||
:>json string path: path on pi storage to the capture file, if available
|
|
||||||
:>json boolean stream: capture stored in-memory as a BytesIO stream
|
|
||||||
:>json json uri: - **download** *(string)*: api uri to the capture file download
|
|
||||||
- **state** *(string)*: api uri to the capture json representation
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: capture created
|
|
||||||
"""
|
|
||||||
payload = JsonResponse(request)
|
|
||||||
|
|
||||||
filename = payload.param("filename")
|
|
||||||
temporary = payload.param("temporary", default=False, convert=bool)
|
|
||||||
use_video_port = payload.param("use_video_port", default=False, convert=bool)
|
|
||||||
bayer = payload.param("bayer", default=True, convert=bool)
|
|
||||||
metadata = payload.param("metadata", default={}, convert=dict)
|
|
||||||
tags = payload.param("tags", default=[], convert=list)
|
|
||||||
|
|
||||||
resize = payload.param("size", default=None)
|
|
||||||
if resize:
|
|
||||||
if ("width" in resize) and ("height" in resize):
|
|
||||||
resize = (
|
|
||||||
int(resize["width"]),
|
|
||||||
int(resize["height"]),
|
|
||||||
) # Convert dict to tuple
|
|
||||||
else:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
# Explicitally acquire lock (prevents empty files being created if lock is unavailable)
|
|
||||||
with self.microscope.camera.lock:
|
|
||||||
output = self.microscope.camera.new_image(
|
|
||||||
temporary=temporary, filename=filename
|
|
||||||
)
|
|
||||||
|
|
||||||
self.microscope.camera.capture(
|
|
||||||
output.file, use_video_port=use_video_port, resize=resize, bayer=bayer
|
|
||||||
)
|
|
||||||
|
|
||||||
# Inject system metadata
|
|
||||||
output.put_metadata(self.microscope.metadata, system=True)
|
|
||||||
|
|
||||||
# Insert custom metadata
|
|
||||||
output.put_metadata(metadata)
|
|
||||||
|
|
||||||
# Insert custom tags
|
|
||||||
output.put_tags(tags)
|
|
||||||
|
|
||||||
return jsonify(output.state)
|
|
||||||
|
|
||||||
|
|
||||||
class GPUPreviewStartAPI(MicroscopeView):
|
|
||||||
"""
|
|
||||||
Start the onboard GPU preview.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def post(self):
|
|
||||||
"""
|
|
||||||
Start the onboard GPU preview.
|
|
||||||
Optional "window" parameter can be passed to control the position and size of the preview window,
|
|
||||||
in the format ``[x, y, width, height]``.
|
|
||||||
|
|
||||||
.. :quickref: Actions; Start on-board preview
|
|
||||||
|
|
||||||
**Example requests**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
POST /actions/camera/preview/start HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"window": [0, 0, 480, 320],
|
|
||||||
}
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: preview started/stopped
|
|
||||||
"""
|
|
||||||
payload = JsonResponse(request)
|
|
||||||
|
|
||||||
window = payload.param("window", default=[])
|
|
||||||
logging.debug(window)
|
|
||||||
|
|
||||||
if len(window) != 4:
|
|
||||||
fullscreen = True
|
|
||||||
window = None
|
|
||||||
else:
|
|
||||||
fullscreen = False
|
|
||||||
window = [int(w) for w in window]
|
|
||||||
|
|
||||||
self.microscope.camera.start_preview(fullscreen=fullscreen, window=window)
|
|
||||||
|
|
||||||
return jsonify(self.microscope.state)
|
|
||||||
|
|
||||||
|
|
||||||
class GPUPreviewStopAPI(MicroscopeView):
|
|
||||||
"""
|
|
||||||
Stop the onboard GPU preview.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def post(self):
|
|
||||||
"""
|
|
||||||
Stop the onboard GPU preview.
|
|
||||||
|
|
||||||
.. :quickref: Actions; Stop on-board preview
|
|
||||||
|
|
||||||
**Example requests**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
POST /actions/camera/preview/stop HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: preview started/stopped
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.microscope.camera.stop_preview()
|
|
||||||
return jsonify(self.microscope.state)
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
from openflexure_microscope.api.utilities import JsonResponse
|
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
|
||||||
from openflexure_microscope.utilities import axes_to_array, filter_dict
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
|
|
||||||
class MoveStageAPI(MicroscopeView):
|
|
||||||
"""
|
|
||||||
Handle stage movements.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def post(self):
|
|
||||||
"""
|
|
||||||
Set x, y and z positions of the stage.
|
|
||||||
|
|
||||||
.. :quickref: Actions; Move stage
|
|
||||||
|
|
||||||
:reqheader Accept: application/json
|
|
||||||
:<json boolean absolute: (true) move to absolute position, (false) move by relative amount
|
|
||||||
:<json boolean force: allow moving by more than programmed limit
|
|
||||||
:<json int x: x steps
|
|
||||||
:<json int y: y steps
|
|
||||||
:<json int z: z steps
|
|
||||||
|
|
||||||
"""
|
|
||||||
# Create response object
|
|
||||||
payload = JsonResponse(request)
|
|
||||||
logging.debug(payload.json)
|
|
||||||
|
|
||||||
# Handle absolute positioning (calculate a relative move from current position and target)
|
|
||||||
if (payload.param("absolute") is True) and (
|
|
||||||
self.microscope.stage
|
|
||||||
): # Only if stage exists
|
|
||||||
target_position = axes_to_array(payload.json, ["x", "y", "z"])
|
|
||||||
logging.debug("TARGET: {}".format(target_position))
|
|
||||||
position = [
|
|
||||||
target_position[i] - self.microscope.stage.position[i] for i in range(3)
|
|
||||||
]
|
|
||||||
logging.debug("DELTA: {}".format(position))
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Get coordinates from payload
|
|
||||||
position = axes_to_array(payload.json, ["x", "y", "z"], [0, 0, 0])
|
|
||||||
|
|
||||||
logging.debug(position)
|
|
||||||
|
|
||||||
# Move if stage exists
|
|
||||||
if self.microscope.stage:
|
|
||||||
# Explicitally acquire lock
|
|
||||||
with self.microscope.stage.lock:
|
|
||||||
self.microscope.stage.move_rel(position)
|
|
||||||
else:
|
|
||||||
logging.warning("Unable to move. No stage found.")
|
|
||||||
|
|
||||||
return jsonify(self.microscope.status["stage"]["position"])
|
|
||||||
|
|
||||||
|
|
||||||
class ZeroStageAPI(MicroscopeView):
|
|
||||||
"""
|
|
||||||
Zero stage coordinates
|
|
||||||
"""
|
|
||||||
|
|
||||||
def post(self):
|
|
||||||
"""
|
|
||||||
Set the current position to zero
|
|
||||||
|
|
||||||
.. :quickref: Actions; Zero stage
|
|
||||||
|
|
||||||
:reqheader Accept: application/json
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.microscope.stage.zero_position()
|
|
||||||
|
|
||||||
return jsonify(self.microscope.status["stage"])
|
|
||||||
|
|
@ -1,416 +0,0 @@
|
||||||
"""
|
|
||||||
Top-level representation of all acquired captures
|
|
||||||
"""
|
|
||||||
|
|
||||||
from openflexure_microscope.api.utilities import get_bool, JsonResponse
|
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
|
||||||
from openflexure_microscope.utilities import filter_dict
|
|
||||||
from openflexure_microscope.api.utilities import blueprint_for_module
|
|
||||||
|
|
||||||
from flask import jsonify, request, abort, url_for, redirect, send_file, Blueprint
|
|
||||||
|
|
||||||
|
|
||||||
def captures_representation(capture_list: list, include_unavailable: bool = False):
|
|
||||||
"""
|
|
||||||
Generate a dictionary representation of all captures, including Flask route URLs
|
|
||||||
|
|
||||||
Args:
|
|
||||||
capture_list (list): List of capture objects
|
|
||||||
include_unavailable (bool): Include unavailable captures in response?
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Dictionary representation of all captures
|
|
||||||
|
|
||||||
"""
|
|
||||||
if include_unavailable:
|
|
||||||
captures = {image.id: image.state for image in capture_list}
|
|
||||||
else:
|
|
||||||
captures = {
|
|
||||||
image.id: image.state for image in capture_list if image.state["available"]
|
|
||||||
}
|
|
||||||
|
|
||||||
for capture_key, capture_repr in captures.items():
|
|
||||||
# Add API routes to returned representations
|
|
||||||
extra_state = {
|
|
||||||
"links": {
|
|
||||||
"self": "{}".format(url_for(".capture", capture_id=capture_key)),
|
|
||||||
"download": "{}".format(
|
|
||||||
url_for(
|
|
||||||
".capture_download",
|
|
||||||
capture_id=capture_key,
|
|
||||||
filename=capture_repr["filename"],
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"tags": "{}".format(url_for(".capture_tags", capture_id=capture_key)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
captures[capture_key].update(extra_state)
|
|
||||||
|
|
||||||
return captures
|
|
||||||
|
|
||||||
|
|
||||||
class ListAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
Get list of image captures.
|
|
||||||
|
|
||||||
.. :quickref: Captures; Get collection of captures
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
:query include_unavailable: return json representations of captures that have been completely deleted
|
|
||||||
|
|
||||||
:>jsonarr boolean available: availability of capture data
|
|
||||||
:>jsonarr string filename: filename of capture
|
|
||||||
:>jsonarr string id: unique id of the capture object
|
|
||||||
:>jsonarr boolean keep_on_disk: keep the capture file on microscope after closing
|
|
||||||
:>jsonarr boolean locked: file locked for modifications (mostly used for video recording)
|
|
||||||
:>jsonarr string path: path on pi storage to the capture file, if available
|
|
||||||
:>jsonarr boolean stream: capture stored in-memory as a BytesIO stream
|
|
||||||
:>jsonarr json uri: - **download** *(string)*: api uri to the capture file download
|
|
||||||
- **state** *(string)*: api uri to the capture json representation
|
|
||||||
|
|
||||||
:>header Content-Type: application/json
|
|
||||||
:status 200: capture found
|
|
||||||
:status 404: no capture found with that id
|
|
||||||
"""
|
|
||||||
representation = captures_representation(self.microscope.camera.images)
|
|
||||||
|
|
||||||
return jsonify(representation)
|
|
||||||
|
|
||||||
def delete(self):
|
|
||||||
"""
|
|
||||||
Delete all captures
|
|
||||||
|
|
||||||
.. :quickref: Captures; Delete all captures
|
|
||||||
"""
|
|
||||||
for image in self.microscope.camera.images:
|
|
||||||
image.delete()
|
|
||||||
|
|
||||||
captures = [image.state for image in self.microscope.camera.images]
|
|
||||||
|
|
||||||
return jsonify(captures)
|
|
||||||
|
|
||||||
|
|
||||||
class CaptureAPI(MicroscopeView):
|
|
||||||
def get(self, capture_id):
|
|
||||||
"""
|
|
||||||
Get JSON representation of a capture
|
|
||||||
|
|
||||||
.. :quickref: Capture; Get capture
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/ HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
**Example response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Vary: Accept
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"available": true,
|
|
||||||
"bytestream": false,
|
|
||||||
"keep_on_disk": false,
|
|
||||||
"locked": false,
|
|
||||||
"metadata": {
|
|
||||||
"filename": "2018-11-16_10-21-53.jpeg",
|
|
||||||
"id": "d0b2067abbb946f19351e075c5e7cd5b",
|
|
||||||
"path": "capture/2018-11-16_10-21-53.jpeg",
|
|
||||||
"time": "2018-11-16_10-21-53"
|
|
||||||
}
|
|
||||||
"uri": {
|
|
||||||
"download": "/api/v1/capture/d0b2067abbb946f19351e075c5e7cd5b/download",
|
|
||||||
"state": "/api/v1/capture/d0b2067abbb946f19351e075c5e7cd5b/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:>json boolean available: availability of capture data
|
|
||||||
:>json string filename: filename of capture
|
|
||||||
:>json string id: unique id of the capture object
|
|
||||||
:>json boolean keep_on_disk: keep the capture file on microscope after closing
|
|
||||||
:>json boolean locked: file locked for modifications (mostly used for video recording)
|
|
||||||
:>json string path: path on pi storage to the capture file, if available
|
|
||||||
:>json boolean stream: capture stored in-memory as a BytesIO stream
|
|
||||||
:>json json uri: - **download** *(string)*: api uri to the capture file download
|
|
||||||
- **state** *(string)*: api uri to the capture json representation
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
representation = captures_representation(self.microscope.camera.images)[
|
|
||||||
capture_id
|
|
||||||
]
|
|
||||||
except KeyError:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
return jsonify(representation)
|
|
||||||
|
|
||||||
def delete(self, capture_id):
|
|
||||||
"""
|
|
||||||
Delete all capture data from the Pi, even if `keep_on_disk=true;`.
|
|
||||||
|
|
||||||
.. :quickref: Capture; Delete capture.
|
|
||||||
"""
|
|
||||||
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
|
||||||
|
|
||||||
if not capture_obj:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
capture_obj.delete()
|
|
||||||
|
|
||||||
return jsonify({"return": capture_id})
|
|
||||||
|
|
||||||
def put(self, capture_id):
|
|
||||||
"""
|
|
||||||
Add arbitrary metadata to the capture
|
|
||||||
|
|
||||||
.. :quickref: Capture; Update capture metadata
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
PUT /camera/capture/d0b2067abbb946f19351e075c5e7cd5b HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"user_id": "ofm_1234",
|
|
||||||
"patient_number": 1452,
|
|
||||||
}
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: metadata updated
|
|
||||||
"""
|
|
||||||
|
|
||||||
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
|
||||||
|
|
||||||
if not capture_obj:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
data_dict = JsonResponse(request).json
|
|
||||||
|
|
||||||
capture_obj.put_metadata(data_dict)
|
|
||||||
|
|
||||||
capture_metadata = capture_obj.state
|
|
||||||
|
|
||||||
return jsonify(capture_metadata)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadRedirectAPI(MicroscopeView):
|
|
||||||
def get(self, capture_id):
|
|
||||||
"""
|
|
||||||
Return image data for a capture.
|
|
||||||
|
|
||||||
Return capture data as an image file with the requested filename.
|
|
||||||
I.e., `/(capture_id)/download/foo.jpeg` will download the image as
|
|
||||||
`foo.jpeg`, regardless of the capture's initially set filename.
|
|
||||||
|
|
||||||
Route automatically redirects to download the capture under it's currently set filename.
|
|
||||||
I.e., `/(capture_id)/download` will
|
|
||||||
redirect to `/(capture_id)/download/(filename)`.
|
|
||||||
|
|
||||||
.. :quickref: Capture; Download capture image
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/download/2018-11-20_16-04-17.jpeg HTTP/1.1
|
|
||||||
Accept: image/jpeg
|
|
||||||
|
|
||||||
:>header Accept: image/jpeg
|
|
||||||
:query thumbnail: return an image thumbnail e.g. ?thumbnail=true
|
|
||||||
|
|
||||||
:>header Content-Type: image/jpeg
|
|
||||||
:status 200: capture data found
|
|
||||||
:status 404: no capture found with that id
|
|
||||||
|
|
||||||
"""
|
|
||||||
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
|
||||||
|
|
||||||
if not capture_obj or not capture_obj.state["available"]:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
thumbnail = get_bool(request.args.get("thumbnail"))
|
|
||||||
|
|
||||||
return redirect(
|
|
||||||
url_for(
|
|
||||||
".capture_download",
|
|
||||||
capture_id=capture_id,
|
|
||||||
filename=capture_obj.filename,
|
|
||||||
thumbnail=thumbnail,
|
|
||||||
),
|
|
||||||
code=307,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadAPI(MicroscopeView):
|
|
||||||
def get(self, capture_id, filename):
|
|
||||||
|
|
||||||
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
|
||||||
|
|
||||||
if not capture_obj or not capture_obj.state["available"]:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
thumbnail = get_bool(request.args.get("thumbnail"))
|
|
||||||
|
|
||||||
# If no filename is specified, redirect to the capture's currently set filename
|
|
||||||
if not filename:
|
|
||||||
return redirect(
|
|
||||||
url_for(
|
|
||||||
"capture_download",
|
|
||||||
capture_id=capture_id,
|
|
||||||
filename=capture_obj.filename,
|
|
||||||
thumbnail=thumbnail,
|
|
||||||
),
|
|
||||||
code=307,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Download the image data using the requested filename
|
|
||||||
if thumbnail:
|
|
||||||
img = capture_obj.thumbnail
|
|
||||||
else:
|
|
||||||
img = capture_obj.data
|
|
||||||
|
|
||||||
return send_file(img, mimetype="image/jpeg")
|
|
||||||
|
|
||||||
|
|
||||||
class TagsAPI(MicroscopeView):
|
|
||||||
def get(self, capture_id):
|
|
||||||
"""
|
|
||||||
Return tag list for a capture.
|
|
||||||
|
|
||||||
.. :quickref: Capture; Show capture tags
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1
|
|
||||||
Accept: text/json
|
|
||||||
|
|
||||||
:>header Accept: text/json
|
|
||||||
|
|
||||||
:>header Content-Type: text/json
|
|
||||||
:status 200: capture data found
|
|
||||||
:status 404: no capture found with that id
|
|
||||||
"""
|
|
||||||
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
|
||||||
|
|
||||||
if not capture_obj or not capture_obj.state["available"]:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
return jsonify(capture_obj.tags)
|
|
||||||
|
|
||||||
def put(self, capture_id):
|
|
||||||
"""
|
|
||||||
Add tags to the capture
|
|
||||||
|
|
||||||
.. :quickref: Capture; Update capture tags
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
PUT /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
["tests", "mytag", "someothertag"]
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: metadata updated
|
|
||||||
"""
|
|
||||||
|
|
||||||
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
|
||||||
|
|
||||||
if not capture_obj or not capture_obj.state["available"]:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
data_dict = JsonResponse(request).json
|
|
||||||
|
|
||||||
if type(data_dict) != list:
|
|
||||||
return abort(400)
|
|
||||||
|
|
||||||
capture_obj.put_tags(data_dict)
|
|
||||||
|
|
||||||
return jsonify(capture_obj.tags)
|
|
||||||
|
|
||||||
def delete(self, capture_id):
|
|
||||||
"""
|
|
||||||
Delete tags from the capture
|
|
||||||
|
|
||||||
.. :quickref: Capture; Delete tags.
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
DELETE /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
["tests", "mytag"]
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: metadata updated
|
|
||||||
"""
|
|
||||||
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
|
||||||
|
|
||||||
if not capture_obj:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
data_dict = JsonResponse(request).json
|
|
||||||
|
|
||||||
if type(data_dict) != list:
|
|
||||||
return abort(400)
|
|
||||||
|
|
||||||
for tag in data_dict:
|
|
||||||
capture_obj.delete_tag(str(tag))
|
|
||||||
|
|
||||||
return jsonify(capture_obj.tags)
|
|
||||||
|
|
||||||
|
|
||||||
def construct_blueprint(microscope_obj):
|
|
||||||
|
|
||||||
blueprint = blueprint_for_module(__name__)
|
|
||||||
|
|
||||||
# Tag routes
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/<capture_id>/tags",
|
|
||||||
view_func=TagsAPI.as_view("capture_tags", microscope=microscope_obj),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Capture routes
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/<capture_id>/download/<filename>",
|
|
||||||
view_func=DownloadAPI.as_view("capture_download", microscope=microscope_obj),
|
|
||||||
)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/<capture_id>/download",
|
|
||||||
view_func=DownloadRedirectAPI.as_view(
|
|
||||||
"capture_download_redirect", microscope=microscope_obj
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/<capture_id>/",
|
|
||||||
view_func=CaptureAPI.as_view("capture", microscope=microscope_obj),
|
|
||||||
)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/", view_func=ListAPI.as_view("captures", microscope=microscope_obj)
|
|
||||||
)
|
|
||||||
return blueprint
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
"""
|
|
||||||
Top-level representation of attached and enabled plugins
|
|
||||||
"""
|
|
||||||
|
|
||||||
from openflexure_microscope.plugins import PluginLoader, MicroscopePlugin
|
|
||||||
from openflexure_microscope.api.views import MicroscopeViewPlugin
|
|
||||||
from openflexure_microscope.utilities import get_docstring, description_from_view
|
|
||||||
from openflexure_microscope.api.utilities import blueprint_for_module
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, url_for
|
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
|
||||||
|
|
||||||
import copy
|
|
||||||
import logging
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
|
|
||||||
def plugins_representation(plugin_loader_object: PluginLoader):
|
|
||||||
"""
|
|
||||||
Generate a dictionary representation of all plugins, including Flask route URLs
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_loader_object (:py:class:`openflexure_microscope.plugins.PluginLoader`): Microscope plugin loader
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Dictionary representation of all plugins
|
|
||||||
"""
|
|
||||||
plugins = {}
|
|
||||||
|
|
||||||
for plugin in plugin_loader_object.active:
|
|
||||||
logging.debug(f"Representing plugin {plugin._name}")
|
|
||||||
d = {
|
|
||||||
"python_name": plugin._name_python_safe,
|
|
||||||
"plugin": str(plugin),
|
|
||||||
"views": {},
|
|
||||||
"gui": plugin.gui,
|
|
||||||
"description": get_docstring(plugin),
|
|
||||||
}
|
|
||||||
|
|
||||||
for view_id, view_data in plugin.views.items():
|
|
||||||
logging.debug(f"Representing view {view_id}")
|
|
||||||
uri = url_for(f"v2_plugins_blueprint.plugins") + view_data["rule"][1:]
|
|
||||||
# uri = view_data["rule"]
|
|
||||||
# Make links dictionary if it doesn't yet exist
|
|
||||||
view_d = {"links": {"self": uri}}
|
|
||||||
|
|
||||||
view_d.update(description_from_view(view_data["view"]))
|
|
||||||
|
|
||||||
d["views"][view_id] = view_d
|
|
||||||
|
|
||||||
plugins[plugin._name] = d
|
|
||||||
|
|
||||||
return plugins
|
|
||||||
|
|
||||||
|
|
||||||
class PluginFormAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
Return the current plugin forms
|
|
||||||
|
|
||||||
.. :quickref: Plugin; Get forms
|
|
||||||
|
|
||||||
Returns an array of present plugin forms (describing plugin user interfaces.)
|
|
||||||
Please note, this is *not* a list of all enabled plugins, only those with associated
|
|
||||||
user interface forms.
|
|
||||||
|
|
||||||
A complete list of enabled plugins can be found in the microscope state.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return jsonify(plugins_representation(self.microscope.plugins))
|
|
||||||
|
|
||||||
|
|
||||||
def construct_blueprint(microscope_obj):
|
|
||||||
|
|
||||||
blueprint = blueprint_for_module(__name__)
|
|
||||||
|
|
||||||
# Create a base route to return plugin API forms, if any exist
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/", view_func=PluginFormAPI.as_view("plugins", microscope=microscope_obj)
|
|
||||||
)
|
|
||||||
|
|
||||||
# For each plugin attached to the microscope object
|
|
||||||
for plugin in microscope_obj.plugins.active:
|
|
||||||
|
|
||||||
for plugin_view_id, plugin_view in plugin.views.items():
|
|
||||||
# Add route to the plugins blueprint
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
plugin_view["rule"],
|
|
||||||
view_func=plugin_view["view"].as_view(
|
|
||||||
f"{plugin._name_python_safe}_{plugin_view_id}",
|
|
||||||
microscope=microscope_obj,
|
|
||||||
plugin=plugin,
|
|
||||||
),
|
|
||||||
**plugin_view["kwargs"],
|
|
||||||
)
|
|
||||||
|
|
||||||
return blueprint
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
from flask import Blueprint, jsonify, url_for
|
|
||||||
from openflexure_microscope import Microscope
|
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
|
||||||
|
|
||||||
from openflexure_microscope.utilities import get_docstring, bottom_level_name
|
|
||||||
from openflexure_microscope.api.utilities import blueprint_name_for_module
|
|
||||||
from openflexure_microscope.api.v2.blueprints import (
|
|
||||||
settings,
|
|
||||||
status,
|
|
||||||
plugins,
|
|
||||||
captures,
|
|
||||||
actions,
|
|
||||||
streams,
|
|
||||||
)
|
|
||||||
|
|
||||||
# List of submodules containing create_blueprint methods using standard blueprint_for_module naming
|
|
||||||
_root_blueprint_modules = [settings, status, plugins, captures, actions, streams]
|
|
||||||
|
|
||||||
|
|
||||||
def root_representation():
|
|
||||||
"""
|
|
||||||
Generate a dictionar representation of all top-level blueprint rules
|
|
||||||
"""
|
|
||||||
global _root_blueprint_modules
|
|
||||||
d = {}
|
|
||||||
|
|
||||||
for blueprint_module in _root_blueprint_modules:
|
|
||||||
module_short_name = bottom_level_name(blueprint_module)
|
|
||||||
blueprint_name = blueprint_name_for_module(blueprint_module.__name__)
|
|
||||||
|
|
||||||
d[module_short_name] = {
|
|
||||||
"name": blueprint_module.__name__,
|
|
||||||
"description": get_docstring(blueprint_module),
|
|
||||||
"links": {"self": url_for(f"{blueprint_name}.{module_short_name}")},
|
|
||||||
}
|
|
||||||
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
class RootAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
|
|
||||||
return jsonify(root_representation())
|
|
||||||
|
|
||||||
|
|
||||||
def construct_blueprint(microscope_obj):
|
|
||||||
blueprint = Blueprint("root_blueprint", __name__)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/", view_func=RootAPI.as_view("root_repr", microscope=microscope_obj)
|
|
||||||
)
|
|
||||||
return blueprint
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
"""
|
|
||||||
Writeable settings for the microscope, and attached hardware
|
|
||||||
"""
|
|
||||||
|
|
||||||
from openflexure_microscope.api.utilities import gen, JsonResponse
|
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
|
||||||
from openflexure_microscope.api.utilities import blueprint_for_module
|
|
||||||
|
|
||||||
from openflexure_microscope.utilities import get_by_path, set_by_path, create_from_path
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, abort
|
|
||||||
import logging
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
JSON representation of the microscope settings.
|
|
||||||
|
|
||||||
.. :quickref: Settings; Get microscope settings
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /settings/ HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
**Example response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Vary: Accept
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"id": "0e2c6fac5421429aac67c7903107bdd8",
|
|
||||||
"name": "docuscope-2000",
|
|
||||||
"fov": [4100, 3146],
|
|
||||||
"camera_settings": {
|
|
||||||
"image_resolution": [2592, 1944],
|
|
||||||
"numpy_resolution": [1312, 976],
|
|
||||||
"video_resolution": [832, 624],
|
|
||||||
"jpeg_quality": 75,
|
|
||||||
"picamera_settings": {
|
|
||||||
"analog_gain": 1.0,
|
|
||||||
"digital_gain": 1.0,
|
|
||||||
"awb_gains": [0.92578125, 2.94921875],
|
|
||||||
"awb_mode": "off",
|
|
||||||
"exposure_mode": "off",
|
|
||||||
"framerate": 24.0,
|
|
||||||
"saturation": 0,
|
|
||||||
"shutter_speed": 5378
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"stage_settings": {
|
|
||||||
"backlash": {
|
|
||||||
"x": 256,
|
|
||||||
"y": 256,
|
|
||||||
"z": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"plugins": [
|
|
||||||
"openflexure_microscope.plugins.default.autofocus:AutofocusPlugin",
|
|
||||||
"openflexure_microscope.plugins.default.scan:ScanPlugin",
|
|
||||||
"openflexure_microscope.plugins.default.camera_calibration:Plugin"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
:>header Content-Type: application/json
|
|
||||||
:status 200: state available
|
|
||||||
"""
|
|
||||||
return jsonify(self.microscope.read_settings())
|
|
||||||
|
|
||||||
def put(self):
|
|
||||||
"""
|
|
||||||
Modify microscope configuration
|
|
||||||
|
|
||||||
.. :quickref: Settings; Update microscope settings
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
PUT /config HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"id": "0e2c6fac5421429aac67c7903107bdd8",
|
|
||||||
"name": "docuscope-2000",
|
|
||||||
"fov": [4100, 3146],
|
|
||||||
"camera_settings": {
|
|
||||||
"image_resolution": [2592, 1944],
|
|
||||||
"numpy_resolution": [1312, 976],
|
|
||||||
"video_resolution": [832, 624],
|
|
||||||
"jpeg_quality": 75,
|
|
||||||
"picamera_settings": {
|
|
||||||
"analog_gain": 1.0,
|
|
||||||
"digital_gain": 1.0,
|
|
||||||
"awb_gains": [0.92578125, 2.94921875],
|
|
||||||
"awb_mode": "off",
|
|
||||||
"exposure_mode": "off",
|
|
||||||
"framerate": 24.0,
|
|
||||||
"saturation": 0,
|
|
||||||
"shutter_speed": 5378
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"stage_settings": {
|
|
||||||
"backlash": {
|
|
||||||
"x": 256,
|
|
||||||
"y": 256,
|
|
||||||
"z": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"plugins": [
|
|
||||||
"openflexure_microscope.plugins.default.autofocus:AutofocusPlugin",
|
|
||||||
"openflexure_microscope.plugins.default.scan:ScanPlugin",
|
|
||||||
"openflexure_microscope.plugins.default.camera_calibration:Plugin"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:<json string id: Unique string identifier of the microscope.
|
|
||||||
:<json string name: Friendly name for the microscope
|
|
||||||
:<json array fov: Field of view (motor steps per full width and height of frame)
|
|
||||||
:<json json camera_settings: - **image_resolution** *(array)*: Resolution of full image captures
|
|
||||||
- **numpy_resolution** *(array)*: Resolution of full numpy array captures
|
|
||||||
- **video_resolution** *(array)*: Resolution of video recordings, low res image captures, and the preview stream
|
|
||||||
- **jpeg_quality** *(int)*: Quality in which to store JPEG capture data
|
|
||||||
- **picamera_settings** *(json)*: Key-value pairs to apply directly to any attached PiCamera object
|
|
||||||
:<json json stage_settings: - **backlash** *(json)*: x, y, and z backlash compensation, in motor steps
|
|
||||||
:<json array plugins: Array of plugin paths to load. Requires reloading the microscope object after applying
|
|
||||||
|
|
||||||
:<header Content-Type: application/json
|
|
||||||
:status 200: capture created
|
|
||||||
|
|
||||||
"""
|
|
||||||
payload = JsonResponse(request)
|
|
||||||
|
|
||||||
logging.debug("Updating settings from PUT request:")
|
|
||||||
logging.debug(payload.json)
|
|
||||||
|
|
||||||
self.microscope.apply_settings(payload.json)
|
|
||||||
self.microscope.save_settings()
|
|
||||||
|
|
||||||
return jsonify(self.microscope.read_settings())
|
|
||||||
|
|
||||||
|
|
||||||
class NestedSettingsAPI(MicroscopeView):
|
|
||||||
def get(self, route):
|
|
||||||
|
|
||||||
keys = route.split("/")
|
|
||||||
|
|
||||||
try:
|
|
||||||
value = get_by_path(self.microscope.read_settings(), keys)
|
|
||||||
except KeyError:
|
|
||||||
return abort(404)
|
|
||||||
|
|
||||||
return jsonify(value)
|
|
||||||
|
|
||||||
def put(self, route):
|
|
||||||
keys = route.split("/")
|
|
||||||
|
|
||||||
payload = JsonResponse(request)
|
|
||||||
|
|
||||||
logging.debug("Updating settings from PUT request:")
|
|
||||||
logging.debug(payload.json)
|
|
||||||
|
|
||||||
dictionary = create_from_path(keys)
|
|
||||||
set_by_path(dictionary, keys, payload.json)
|
|
||||||
|
|
||||||
logging.debug(f"Applying settings: {dictionary}")
|
|
||||||
|
|
||||||
self.microscope.apply_settings(dictionary)
|
|
||||||
self.microscope.save_settings()
|
|
||||||
|
|
||||||
return self.get(route)
|
|
||||||
|
|
||||||
|
|
||||||
def construct_blueprint(microscope_obj):
|
|
||||||
|
|
||||||
blueprint = blueprint_for_module(__name__)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/", view_func=SettingsAPI.as_view("settings", microscope=microscope_obj)
|
|
||||||
)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/<path:route>",
|
|
||||||
view_func=NestedSettingsAPI.as_view(
|
|
||||||
"nested_settings", microscope=microscope_obj
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return blueprint
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
"""
|
|
||||||
Read-only status of the microscope, and attached hardware info
|
|
||||||
"""
|
|
||||||
|
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
|
||||||
from openflexure_microscope.api.utilities import blueprint_for_module
|
|
||||||
from openflexure_microscope.utilities import get_by_path
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, abort
|
|
||||||
|
|
||||||
|
|
||||||
class StatusAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
JSON representation of the microscope status (read-only properties, modifiable with actions)
|
|
||||||
|
|
||||||
.. :quickref: Status; Microscope status
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /status/ HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
**Example response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Vary: Accept
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"camera": {
|
|
||||||
"preview_active": false,
|
|
||||||
"record_active": false,
|
|
||||||
"stream_active": true
|
|
||||||
},
|
|
||||||
"plugin": {},
|
|
||||||
"stage": {
|
|
||||||
"backlash": {
|
|
||||||
"x": 128,
|
|
||||||
"y": 128,
|
|
||||||
"z": 128
|
|
||||||
},
|
|
||||||
"position": {
|
|
||||||
"x": -8080,
|
|
||||||
"y": 5665,
|
|
||||||
"z": -12600
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
:>header Content-Type: application/json
|
|
||||||
:status 200: state available
|
|
||||||
"""
|
|
||||||
return jsonify(self.microscope.status)
|
|
||||||
|
|
||||||
|
|
||||||
class NestedStatusAPI(MicroscopeView):
|
|
||||||
def get(self, route):
|
|
||||||
|
|
||||||
keys = route.split("/")
|
|
||||||
|
|
||||||
try:
|
|
||||||
value = get_by_path(self.microscope.status, keys)
|
|
||||||
except KeyError:
|
|
||||||
return abort(404)
|
|
||||||
|
|
||||||
return jsonify(value)
|
|
||||||
|
|
||||||
|
|
||||||
def construct_blueprint(microscope_obj):
|
|
||||||
|
|
||||||
blueprint = blueprint_for_module(__name__)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/", view_func=StatusAPI.as_view("status", microscope=microscope_obj)
|
|
||||||
)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/<path:route>",
|
|
||||||
view_func=NestedStatusAPI.as_view("nested_status", microscope=microscope_obj),
|
|
||||||
)
|
|
||||||
|
|
||||||
return blueprint
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
"""
|
|
||||||
Top-level description of routes related to live camera stream data
|
|
||||||
"""
|
|
||||||
|
|
||||||
from openflexure_microscope.api.utilities import gen, JsonResponse
|
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
|
||||||
from openflexure_microscope.api.utilities import (
|
|
||||||
blueprint_for_module,
|
|
||||||
blueprint_name_for_module,
|
|
||||||
)
|
|
||||||
from openflexure_microscope.utilities import description_from_view
|
|
||||||
|
|
||||||
from flask import Response, Blueprint, jsonify, request, url_for
|
|
||||||
|
|
||||||
|
|
||||||
class StreamAPI(MicroscopeView):
|
|
||||||
def get(self):
|
|
||||||
return jsonify(streams_representation())
|
|
||||||
|
|
||||||
|
|
||||||
class MjpegAPI(MicroscopeView):
|
|
||||||
"""
|
|
||||||
Real-time MJPEG stream from the microscope camera
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
Real-time MJPEG stream from the microscope camera
|
|
||||||
|
|
||||||
.. :quickref: Streams; Camera MJPEG stream
|
|
||||||
|
|
||||||
:>header Accept: image/jpeg
|
|
||||||
:>header Content-Type: image/jpeg
|
|
||||||
:status 200: stream active
|
|
||||||
"""
|
|
||||||
# Restart stream worker thread
|
|
||||||
self.microscope.camera.start_worker()
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
gen(self.microscope.camera),
|
|
||||||
mimetype="multipart/x-mixed-replace; boundary=frame",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SnapshotAPI(MicroscopeView):
|
|
||||||
"""
|
|
||||||
Single JPEG snapshot from the camera stream
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
Single snapshot from the camera stream
|
|
||||||
|
|
||||||
.. :quickref: Streams; Camera snapshot
|
|
||||||
|
|
||||||
:>header Accept: image/jpeg
|
|
||||||
:>header Content-Type: image/jpeg
|
|
||||||
:status 200: stream active
|
|
||||||
"""
|
|
||||||
# Restart stream worker thread
|
|
||||||
self.microscope.camera.start_worker()
|
|
||||||
|
|
||||||
return Response(self.microscope.camera.get_frame(), mimetype="image/jpeg")
|
|
||||||
|
|
||||||
|
|
||||||
_streams = {
|
|
||||||
"mjpeg": {"rule": "/mjpeg", "view_class": MjpegAPI, "conditions": True},
|
|
||||||
"snapshot": {"rule": "/snapshot", "view_class": SnapshotAPI, "conditions": True},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def enabled_streams():
|
|
||||||
global _streams
|
|
||||||
return {k: v for k, v in _streams.items() if v["conditions"]}
|
|
||||||
|
|
||||||
|
|
||||||
def streams_representation():
|
|
||||||
global _streams
|
|
||||||
|
|
||||||
streams = {}
|
|
||||||
for name, stream in enabled_streams().items():
|
|
||||||
d = {"links": {"self": url_for(f".{name}")}}
|
|
||||||
|
|
||||||
d.update(description_from_view(stream["view_class"]))
|
|
||||||
|
|
||||||
streams[name] = d
|
|
||||||
|
|
||||||
return streams
|
|
||||||
|
|
||||||
|
|
||||||
def construct_blueprint(microscope_obj):
|
|
||||||
|
|
||||||
blueprint = blueprint_for_module(__name__)
|
|
||||||
|
|
||||||
# For each enabled stream route defined in our dictionary above
|
|
||||||
for name, stream in enabled_streams().items():
|
|
||||||
# Add the action to our blueprint
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
stream["rule"],
|
|
||||||
view_func=stream["view_class"].as_view(name, microscope=microscope_obj),
|
|
||||||
)
|
|
||||||
|
|
||||||
blueprint.add_url_rule(
|
|
||||||
"/", view_func=StreamAPI.as_view("streams", microscope=microscope_obj)
|
|
||||||
)
|
|
||||||
|
|
||||||
return blueprint
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
from openflexure_microscope.api.views import MethodView
|
|
||||||
from flask import jsonify, abort, Blueprint, url_for
|
|
||||||
|
|
||||||
from openflexure_microscope.common import tasks
|
|
||||||
|
|
||||||
|
|
||||||
def tasks_representation():
|
|
||||||
"""
|
|
||||||
Generate a dictionary representation of all tasks, including Flask route URLs
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Dictionary representation of all tasks
|
|
||||||
"""
|
|
||||||
tasks_dict = tasks.states()
|
|
||||||
|
|
||||||
for task_key, task_repr in tasks_dict.items():
|
|
||||||
# Add API routes to returned representations
|
|
||||||
extra_state = {
|
|
||||||
"links": {"self": "{}".format(url_for(".task", task_id=task_key))}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks_dict[task_key].update(extra_state)
|
|
||||||
|
|
||||||
return tasks_dict
|
|
||||||
|
|
||||||
|
|
||||||
class TaskListAPI(MethodView):
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
Get list of long-running tasks.
|
|
||||||
|
|
||||||
.. :quickref: Tasks; Get collection of tasks
|
|
||||||
|
|
||||||
**Example response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Vary: Accept
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"end_time": "2019-01-23 16-33-33",
|
|
||||||
"id": "db13a66787e1419bb06b1504e4d80b0c",
|
|
||||||
"return": [
|
|
||||||
0.848622546386467,
|
|
||||||
0.6106785018091292,
|
|
||||||
],
|
|
||||||
"start_time": "2019-01-23 16-33-13",
|
|
||||||
"status": "success"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"end_time": null,
|
|
||||||
"id": "df46558cc8844924821bd0181881871e",
|
|
||||||
"return": null,
|
|
||||||
"start_time": "2019-01-23 16-34-54",
|
|
||||||
"status": "running"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
:query include_unavailable: return json representations of captures that have been completely deleted
|
|
||||||
|
|
||||||
:>header Content-Type: application/json
|
|
||||||
"""
|
|
||||||
|
|
||||||
return jsonify(tasks_representation())
|
|
||||||
|
|
||||||
def delete(self):
|
|
||||||
"""
|
|
||||||
Clean list of long-running tasks (running tasks persist).
|
|
||||||
|
|
||||||
.. :quickref: Tasks; Clean collection of tasks
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:>header Content-Type: application/json
|
|
||||||
"""
|
|
||||||
|
|
||||||
tasks.cleanup_tasks()
|
|
||||||
|
|
||||||
return jsonify(tasks_representation())
|
|
||||||
|
|
||||||
|
|
||||||
class TaskAPI(MethodView):
|
|
||||||
def get(self, task_id):
|
|
||||||
"""
|
|
||||||
Get JSON representation of a task
|
|
||||||
|
|
||||||
.. :quickref: Tasks; Get task
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /task/db13a66787e1419bb06b1504e4d80b0c/ HTTP/1.1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
**Example response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Vary: Accept
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"end_time": "2019-01-23 16-33-33",
|
|
||||||
"id": "db13a66787e1419bb06b1504e4d80b0c",
|
|
||||||
"return": [
|
|
||||||
0.848622546386467,
|
|
||||||
0.6106785018091292,
|
|
||||||
],
|
|
||||||
"start_time": "2019-01-23 16-33-13",
|
|
||||||
"status": "success"
|
|
||||||
}
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
task = tasks_representation()[task_id]
|
|
||||||
except KeyError:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
# Return task state
|
|
||||||
return jsonify(task)
|
|
||||||
|
|
||||||
def delete(self, task_id):
|
|
||||||
"""
|
|
||||||
Terminate a particular task.
|
|
||||||
|
|
||||||
.. :quickref: Tasks; Terminate a task
|
|
||||||
|
|
||||||
:>header Accept: application/json
|
|
||||||
|
|
||||||
:>header Content-Type: application/json
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
task = tasks_representation()[task_id]
|
|
||||||
except KeyError:
|
|
||||||
return abort(404) # 404 Not Found
|
|
||||||
|
|
||||||
task.terminate()
|
|
||||||
|
|
||||||
return jsonify(task.state)
|
|
||||||
|
|
||||||
|
|
||||||
def construct_blueprint(microscope_obj):
|
|
||||||
|
|
||||||
blueprint = Blueprint("v2_tasks_blueprint", __name__)
|
|
||||||
|
|
||||||
blueprint.add_url_rule("/", view_func=TaskListAPI.as_view("task_list"))
|
|
||||||
|
|
||||||
blueprint.add_url_rule("/<task_id>/", view_func=TaskAPI.as_view("task"))
|
|
||||||
|
|
||||||
return blueprint
|
|
||||||
4
openflexure_microscope/api/v2/views/__init__.py
Normal file
4
openflexure_microscope/api/v2/views/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from .actions import enabled_root_actions
|
||||||
|
from .captures import *
|
||||||
|
from .state import *
|
||||||
|
from .streams import *
|
||||||
83
openflexure_microscope/api/v2/views/actions/__init__.py
Normal file
83
openflexure_microscope/api/v2/views/actions/__init__.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
"""
|
||||||
|
Top-level representation of enabled actions
|
||||||
|
"""
|
||||||
|
|
||||||
|
from . import camera, stage, system
|
||||||
|
|
||||||
|
from openflexure_microscope.common.flask_labthings.view import View
|
||||||
|
from openflexure_microscope.common.flask_labthings.find import current_labthing
|
||||||
|
from openflexure_microscope.common.flask_labthings.utilities import description_from_view
|
||||||
|
from openflexure_microscope.common.flask_labthings.decorators import Tag
|
||||||
|
|
||||||
|
_actions = {
|
||||||
|
"capture": {
|
||||||
|
"rule": "/camera/capture/",
|
||||||
|
"view_class": camera.CaptureAPI,
|
||||||
|
"conditions": True,
|
||||||
|
},
|
||||||
|
"previewStart": {
|
||||||
|
"rule": "/camera/preview/start",
|
||||||
|
"view_class": camera.GPUPreviewStartAPI,
|
||||||
|
"conditions": True,
|
||||||
|
},
|
||||||
|
"previewStop": {
|
||||||
|
"rule": "/camera/preview/stop",
|
||||||
|
"view_class": camera.GPUPreviewStopAPI,
|
||||||
|
"conditions": True,
|
||||||
|
},
|
||||||
|
"move": {
|
||||||
|
"rule": "/stage/move/",
|
||||||
|
"view_class": stage.MoveStageAPI,
|
||||||
|
"conditions": True,
|
||||||
|
},
|
||||||
|
"zeroStage": {
|
||||||
|
"rule": "/stage/zero/",
|
||||||
|
"view_class": stage.ZeroStageAPI,
|
||||||
|
"conditions": True,
|
||||||
|
},
|
||||||
|
"shutdown": {
|
||||||
|
"rule": "/system/shutdown/",
|
||||||
|
"view_class": system.ShutdownAPI,
|
||||||
|
"conditions": system.is_raspberrypi(),
|
||||||
|
},
|
||||||
|
"reboot": {
|
||||||
|
"rule": "/system/reboot/",
|
||||||
|
"view_class": system.RebootAPI,
|
||||||
|
"conditions": system.is_raspberrypi(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def enabled_root_actions():
|
||||||
|
global _actions
|
||||||
|
return {k: v for k, v in _actions.items() if v["conditions"]}
|
||||||
|
|
||||||
|
|
||||||
|
@Tag("actions")
|
||||||
|
class ActionsView(View):
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
List of enabled default API actions.
|
||||||
|
|
||||||
|
This list does not include any actions added by LabThings extensions, only
|
||||||
|
those part of the default OpenFlexure Microscope API.
|
||||||
|
"""
|
||||||
|
global _actions
|
||||||
|
|
||||||
|
actions = {}
|
||||||
|
for name, action in enabled_root_actions().items():
|
||||||
|
d = {
|
||||||
|
"links": {
|
||||||
|
"self": {
|
||||||
|
"href": current_labthing().url_for(action["view_class"]),
|
||||||
|
"mimetype": "application/json",
|
||||||
|
**description_from_view(action["view_class"])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rule": action["rule"],
|
||||||
|
"view_class": str(action["view_class"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
actions[name] = d
|
||||||
|
|
||||||
|
return actions
|
||||||
130
openflexure_microscope/api/v2/views/actions/camera.py
Normal file
130
openflexure_microscope/api/v2/views/actions/camera.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
from openflexure_microscope.api.utilities import get_bool, JsonResponse
|
||||||
|
from openflexure_microscope.common.flask_labthings.view import View
|
||||||
|
from openflexure_microscope.common.flask_labthings.find import find_component
|
||||||
|
from openflexure_microscope.common.flask_labthings.decorators import (
|
||||||
|
use_args,
|
||||||
|
marshal_with,
|
||||||
|
doc,
|
||||||
|
tag,
|
||||||
|
ThingAction,
|
||||||
|
doc_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
from openflexure_microscope.common.flask_labthings import fields
|
||||||
|
from openflexure_microscope.utilities import filter_dict
|
||||||
|
|
||||||
|
from openflexure_microscope.api.v2.views.captures import capture_schema
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from flask import jsonify, request, abort, url_for, redirect, send_file
|
||||||
|
|
||||||
|
|
||||||
|
@ThingAction
|
||||||
|
class CaptureAPI(View):
|
||||||
|
"""
|
||||||
|
Create a new image capture.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@use_args(
|
||||||
|
{
|
||||||
|
"filename": fields.String(example="MyFileName"),
|
||||||
|
"temporary": fields.Boolean(
|
||||||
|
missing=False, description="Delete capture on shutdown"
|
||||||
|
),
|
||||||
|
"use_video_port": fields.Boolean(missing=False),
|
||||||
|
"bayer": fields.Boolean(
|
||||||
|
missing=False, description="Store raw bayer data in file"
|
||||||
|
),
|
||||||
|
"metadata": fields.Dict(missing={}, example={"Client": "SwaggerUI"}),
|
||||||
|
"tags": fields.List(fields.String, missing=[], example=["docs"]),
|
||||||
|
"resize": fields.Dict(
|
||||||
|
missing=None, example={"width": 640, "height": 480}
|
||||||
|
), # TODO: Validate keys
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@marshal_with(capture_schema)
|
||||||
|
@doc_response(200, description="Capture successful")
|
||||||
|
def post(self, args):
|
||||||
|
"""
|
||||||
|
Create a new capture
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
|
||||||
|
resize = args.get("resize", None)
|
||||||
|
if resize:
|
||||||
|
if ("width" in resize) and ("height" in resize):
|
||||||
|
resize = (
|
||||||
|
int(resize["width"]),
|
||||||
|
int(resize["height"]),
|
||||||
|
) # Convert dict to tuple
|
||||||
|
else:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Explicitally acquire lock (prevents empty files being created if lock is unavailable)
|
||||||
|
with microscope.camera.lock:
|
||||||
|
output = microscope.camera.new_image(
|
||||||
|
temporary=args.get("temporary"), filename=args.get("filename")
|
||||||
|
)
|
||||||
|
|
||||||
|
microscope.camera.capture(
|
||||||
|
output.file,
|
||||||
|
use_video_port=args.get("use_video_port"),
|
||||||
|
resize=resize,
|
||||||
|
bayer=args.get("bayer"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Inject system metadata
|
||||||
|
output.put_metadata(microscope.metadata, system=True)
|
||||||
|
|
||||||
|
# Insert custom metadata
|
||||||
|
output.put_metadata(args.get("metadata"))
|
||||||
|
|
||||||
|
# Insert custom tags
|
||||||
|
output.put_tags(args.get("tags"))
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
@ThingAction
|
||||||
|
class GPUPreviewStartAPI(View):
|
||||||
|
"""
|
||||||
|
Start the onboard GPU preview.
|
||||||
|
Optional "window" parameter can be passed to control the position and size of the preview window,
|
||||||
|
in the format ``[x, y, width, height]``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@use_args(
|
||||||
|
{"window": fields.List(fields.Integer, missing=[], example=[0, 0, 640, 480])}
|
||||||
|
)
|
||||||
|
def post(self, args):
|
||||||
|
"""
|
||||||
|
Start the onboard GPU preview.
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
|
||||||
|
window = args.get("window")
|
||||||
|
logging.debug(window)
|
||||||
|
|
||||||
|
if len(window) != 4:
|
||||||
|
fullscreen = True
|
||||||
|
window = None
|
||||||
|
else:
|
||||||
|
fullscreen = False
|
||||||
|
window = [int(w) for w in window]
|
||||||
|
|
||||||
|
microscope.camera.start_preview(fullscreen=fullscreen, window=window)
|
||||||
|
|
||||||
|
# TODO: Make schema for microscope status
|
||||||
|
return jsonify(microscope.status)
|
||||||
|
|
||||||
|
|
||||||
|
@ThingAction
|
||||||
|
class GPUPreviewStopAPI(View):
|
||||||
|
def post(self):
|
||||||
|
"""
|
||||||
|
Stop the onboard GPU preview.
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
microscope.camera.stop_preview()
|
||||||
|
# TODO: Make schema for microscope status
|
||||||
|
return jsonify(microscope.status)
|
||||||
75
openflexure_microscope/api/v2/views/actions/stage.py
Normal file
75
openflexure_microscope/api/v2/views/actions/stage.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
from openflexure_microscope.api.utilities import JsonResponse
|
||||||
|
from openflexure_microscope.common.flask_labthings.view import View
|
||||||
|
from openflexure_microscope.common.flask_labthings.find import find_component
|
||||||
|
from openflexure_microscope.common.flask_labthings.decorators import (
|
||||||
|
use_args,
|
||||||
|
marshal_with,
|
||||||
|
doc,
|
||||||
|
ThingAction,
|
||||||
|
)
|
||||||
|
from openflexure_microscope.common.flask_labthings import fields
|
||||||
|
|
||||||
|
from openflexure_microscope.utilities import axes_to_array, filter_dict
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
@ThingAction
|
||||||
|
class MoveStageAPI(View):
|
||||||
|
@use_args(
|
||||||
|
{
|
||||||
|
"absolute": fields.Boolean(
|
||||||
|
default=False, example=False, description="Move to an absolute position"
|
||||||
|
),
|
||||||
|
"x": fields.Int(default=0, example=100),
|
||||||
|
"y": fields.Int(default=0, example=100),
|
||||||
|
"z": fields.Int(default=0, example=20),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def post(self, args):
|
||||||
|
"""
|
||||||
|
Move the microscope stage in x, y, z
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
|
||||||
|
# Handle absolute positioning (calculate a relative move from current position and target)
|
||||||
|
if (args.get("absolute")) and (microscope.stage): # Only if stage exists
|
||||||
|
target_position = axes_to_array(args, ["x", "y", "z"])
|
||||||
|
logging.debug("TARGET: {}".format(target_position))
|
||||||
|
position = [
|
||||||
|
target_position[i] - microscope.stage.position[i] for i in range(3)
|
||||||
|
]
|
||||||
|
logging.debug("DELTA: {}".format(position))
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Get coordinates from payload
|
||||||
|
position = axes_to_array(args, ["x", "y", "z"], [0, 0, 0])
|
||||||
|
|
||||||
|
logging.debug(position)
|
||||||
|
|
||||||
|
# Move if stage exists
|
||||||
|
if microscope.stage:
|
||||||
|
# Explicitally acquire lock
|
||||||
|
with microscope.stage.lock:
|
||||||
|
microscope.stage.move_rel(position)
|
||||||
|
else:
|
||||||
|
logging.warning("Unable to move. No stage found.")
|
||||||
|
|
||||||
|
# TODO: Make schema for microscope status
|
||||||
|
return jsonify(microscope.status["stage"]["position"])
|
||||||
|
|
||||||
|
|
||||||
|
@ThingAction
|
||||||
|
class ZeroStageAPI(View):
|
||||||
|
def post(self):
|
||||||
|
"""
|
||||||
|
Zero the stage coordinates.
|
||||||
|
Does not move the stage, but rather makes the current position read as [0, 0, 0]
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
microscope.stage.zero_position()
|
||||||
|
|
||||||
|
# TODO: Make schema for microscope status
|
||||||
|
return jsonify(microscope.status["stage"])
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
from openflexure_microscope.api.views import MicroscopeView
|
from openflexure_microscope.common.flask_labthings.view import View
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
from sys import platform
|
from sys import platform
|
||||||
|
|
||||||
|
from openflexure_microscope.common.flask_labthings.decorators import (
|
||||||
|
ThingAction,
|
||||||
|
doc_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_raspberrypi(raise_on_errors=False):
|
def is_raspberrypi(raise_on_errors=False):
|
||||||
"""
|
"""
|
||||||
|
|
@ -13,34 +17,32 @@ def is_raspberrypi(raise_on_errors=False):
|
||||||
return os.path.exists("/usr/bin/raspi-config")
|
return os.path.exists("/usr/bin/raspi-config")
|
||||||
|
|
||||||
|
|
||||||
class ShutdownAPI(MicroscopeView):
|
@ThingAction
|
||||||
|
class ShutdownAPI(View):
|
||||||
"""
|
"""
|
||||||
Attempt to shutdown the device
|
Attempt to shutdown the device
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@doc_response(201)
|
||||||
def post(self):
|
def post(self):
|
||||||
"""
|
"""
|
||||||
Attempt to shutdown the device
|
Attempt to shutdown the device
|
||||||
|
|
||||||
.. :quickref: Actions; Shutdown
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
subprocess.Popen(["sudo", "shutdown", "-h", "now"])
|
subprocess.Popen(["sudo", "shutdown", "-h", "now"])
|
||||||
|
|
||||||
return "{}", 201
|
return "{}", 201
|
||||||
|
|
||||||
|
|
||||||
class RebootAPI(MicroscopeView):
|
@ThingAction
|
||||||
|
class RebootAPI(View):
|
||||||
"""
|
"""
|
||||||
Attempt to reboot the device
|
Attempt to reboot the device
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@doc_response(201)
|
||||||
def post(self):
|
def post(self):
|
||||||
"""
|
"""
|
||||||
Attempt to shutdown the device
|
Attempt to reboot the device
|
||||||
|
|
||||||
.. :quickref: Actions; Shutdown
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
subprocess.Popen(["sudo", "shutdown", "-r", "now"])
|
subprocess.Popen(["sudo", "shutdown", "-r", "now"])
|
||||||
|
|
||||||
234
openflexure_microscope/api/v2/views/captures.py
Normal file
234
openflexure_microscope/api/v2/views/captures.py
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
import logging
|
||||||
|
from flask import abort, request, redirect, url_for, send_file, jsonify
|
||||||
|
|
||||||
|
from openflexure_microscope.api.utilities import get_bool, JsonResponse
|
||||||
|
|
||||||
|
from openflexure_microscope.common.flask_labthings.schema import Schema
|
||||||
|
from openflexure_microscope.common.flask_labthings import fields
|
||||||
|
from openflexure_microscope.common.flask_labthings.view import View
|
||||||
|
from openflexure_microscope.common.flask_labthings.utilities import (
|
||||||
|
description_from_view,
|
||||||
|
)
|
||||||
|
from openflexure_microscope.common.flask_labthings.decorators import marshal_with, doc_response, Tag, ThingProperty
|
||||||
|
|
||||||
|
from openflexure_microscope.common.flask_labthings.find import find_component
|
||||||
|
|
||||||
|
from marshmallow import pre_dump
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureSchema(Schema):
|
||||||
|
id = fields.String()
|
||||||
|
file = fields.String(
|
||||||
|
data_key="path", description="Path of file on microscope device"
|
||||||
|
)
|
||||||
|
exists = fields.Bool(data_key="available")
|
||||||
|
filename = fields.String()
|
||||||
|
metadata = fields.Dict()
|
||||||
|
|
||||||
|
links = fields.Dict()
|
||||||
|
|
||||||
|
# TODO: Automate this somewhat
|
||||||
|
@pre_dump
|
||||||
|
def generate_links(self, data, **kwargs):
|
||||||
|
data.links = {
|
||||||
|
"self": {
|
||||||
|
"href": url_for(CaptureView.endpoint, id=data.id, _external=True),
|
||||||
|
"mimetype": "application/json",
|
||||||
|
**description_from_view(CaptureView),
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"href": url_for(CaptureTags.endpoint, id=data.id, _external=True),
|
||||||
|
"mimetype": "application/json",
|
||||||
|
**description_from_view(CaptureTags),
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"href": url_for(CaptureMetadata.endpoint, id=data.id, _external=True),
|
||||||
|
"mimetype": "application/json",
|
||||||
|
**description_from_view(CaptureMetadata),
|
||||||
|
},
|
||||||
|
"download": {
|
||||||
|
"href": url_for(
|
||||||
|
CaptureDownload.endpoint,
|
||||||
|
id=data.id,
|
||||||
|
filename=data.filename,
|
||||||
|
_external=True,
|
||||||
|
),
|
||||||
|
"mimetype": "image/jpeg",
|
||||||
|
**description_from_view(CaptureDownload),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
capture_schema = CaptureSchema()
|
||||||
|
capture_list_schema = CaptureSchema(many=True)
|
||||||
|
|
||||||
|
|
||||||
|
@ThingProperty
|
||||||
|
@Tag("captures")
|
||||||
|
class CaptureList(View):
|
||||||
|
@marshal_with(CaptureSchema(many=True))
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
List all image captures
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
image_list = microscope.camera.images
|
||||||
|
return image_list
|
||||||
|
|
||||||
|
|
||||||
|
@Tag("captures")
|
||||||
|
class CaptureView(View):
|
||||||
|
@marshal_with(CaptureSchema())
|
||||||
|
def get(self, id):
|
||||||
|
"""
|
||||||
|
Description of a single image capture
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
capture_obj = microscope.camera.image_from_id(id)
|
||||||
|
|
||||||
|
if not capture_obj:
|
||||||
|
return abort(404) # 404 Not Found
|
||||||
|
|
||||||
|
return capture_obj
|
||||||
|
|
||||||
|
def delete(self, id):
|
||||||
|
"""
|
||||||
|
Delete a single image capture
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
capture_obj = microscope.camera.image_from_id(id)
|
||||||
|
|
||||||
|
if not capture_obj:
|
||||||
|
return abort(404) # 404 Not Found
|
||||||
|
|
||||||
|
capture_obj.delete()
|
||||||
|
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@Tag("captures")
|
||||||
|
class CaptureDownload(View):
|
||||||
|
@doc_response(200, mimetype="image/jpeg")
|
||||||
|
def get(self, id, filename):
|
||||||
|
"""
|
||||||
|
Image data for a single image capture
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
capture_obj = microscope.camera.image_from_id(id)
|
||||||
|
|
||||||
|
if not capture_obj:
|
||||||
|
return abort(404) # 404 Not Found
|
||||||
|
|
||||||
|
thumbnail = get_bool(request.args.get("thumbnail"))
|
||||||
|
|
||||||
|
# If no filename is specified, redirect to the capture's currently set filename
|
||||||
|
if not filename:
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
"DownloadAPI",
|
||||||
|
id=id,
|
||||||
|
filename=capture_obj.filename,
|
||||||
|
thumbnail=thumbnail,
|
||||||
|
),
|
||||||
|
code=307,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Download the image data using the requested filename
|
||||||
|
if thumbnail:
|
||||||
|
img = capture_obj.thumbnail
|
||||||
|
else:
|
||||||
|
img = capture_obj.data
|
||||||
|
|
||||||
|
return send_file(img, mimetype="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
|
@Tag("captures")
|
||||||
|
class CaptureTags(View):
|
||||||
|
def get(self, id):
|
||||||
|
"""
|
||||||
|
Get tags associated with a single image capture
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
capture_obj = microscope.camera.image_from_id(id)
|
||||||
|
|
||||||
|
if not capture_obj:
|
||||||
|
return abort(404) # 404 Not Found
|
||||||
|
|
||||||
|
return jsonify(capture_obj.tags)
|
||||||
|
|
||||||
|
def put(self, id):
|
||||||
|
"""
|
||||||
|
Add tags to a single image capture
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
capture_obj = microscope.camera.image_from_id(id)
|
||||||
|
|
||||||
|
if not capture_obj:
|
||||||
|
return abort(404) # 404 Not Found
|
||||||
|
|
||||||
|
# TODO: Replace with normal Flask request JSON thing
|
||||||
|
data_dict = JsonResponse(request).json
|
||||||
|
|
||||||
|
if type(data_dict) != list:
|
||||||
|
return abort(400)
|
||||||
|
|
||||||
|
capture_obj.put_tags(data_dict)
|
||||||
|
|
||||||
|
return jsonify(capture_obj.tags)
|
||||||
|
|
||||||
|
def delete(self, capture_id):
|
||||||
|
"""
|
||||||
|
Delete tags from a single image capture
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
capture_obj = microscope.camera.image_from_id(id)
|
||||||
|
|
||||||
|
if not capture_obj:
|
||||||
|
return abort(404) # 404 Not Found
|
||||||
|
|
||||||
|
data_dict = JsonResponse(request).json
|
||||||
|
|
||||||
|
if type(data_dict) != list:
|
||||||
|
return abort(400)
|
||||||
|
|
||||||
|
for tag in data_dict:
|
||||||
|
capture_obj.delete_tag(str(tag))
|
||||||
|
|
||||||
|
return jsonify(capture_obj.tags)
|
||||||
|
|
||||||
|
|
||||||
|
@Tag("captures")
|
||||||
|
class CaptureMetadata(View):
|
||||||
|
def get(self, id):
|
||||||
|
"""
|
||||||
|
Get metadata associated with a single image capture
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
capture_obj = microscope.camera.image_from_id(id)
|
||||||
|
|
||||||
|
if not capture_obj:
|
||||||
|
return abort(404) # 404 Not Found
|
||||||
|
|
||||||
|
return jsonify(capture_obj.metadata)
|
||||||
|
|
||||||
|
def put(self, id):
|
||||||
|
"""
|
||||||
|
Update metadata for a single image capture
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
capture_obj = microscope.camera.image_from_id(id)
|
||||||
|
|
||||||
|
if not capture_obj:
|
||||||
|
return abort(404) # 404 Not Found
|
||||||
|
|
||||||
|
data_dict = JsonResponse(request).json
|
||||||
|
logging.debug(data_dict)
|
||||||
|
|
||||||
|
if type(data_dict) != dict:
|
||||||
|
return abort(400)
|
||||||
|
|
||||||
|
# TODO: Allow putting system metadata maybe?
|
||||||
|
capture_obj.put_metadata(data_dict)
|
||||||
|
|
||||||
|
return jsonify(capture_obj.metadata)
|
||||||
103
openflexure_microscope/api/v2/views/state.py
Normal file
103
openflexure_microscope/api/v2/views/state.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
from openflexure_microscope.api.utilities import JsonResponse
|
||||||
|
|
||||||
|
from openflexure_microscope.common.labthings_core.utilities import (
|
||||||
|
get_by_path,
|
||||||
|
set_by_path,
|
||||||
|
create_from_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
from openflexure_microscope.common.flask_labthings.find import find_component
|
||||||
|
from openflexure_microscope.common.flask_labthings.view import View
|
||||||
|
|
||||||
|
from openflexure_microscope.common.flask_labthings.decorators import ThingProperty, Tag, doc_response
|
||||||
|
|
||||||
|
from flask import jsonify, request, abort
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
@ThingProperty
|
||||||
|
class SettingsProperty(View):
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
Current microscope settings, including camera and stage
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
return jsonify(microscope.read_settings())
|
||||||
|
|
||||||
|
def put(self):
|
||||||
|
"""
|
||||||
|
Update current microscope settings, including camera and stage
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
payload = JsonResponse(request)
|
||||||
|
|
||||||
|
logging.debug("Updating settings from PUT request:")
|
||||||
|
logging.debug(payload.json)
|
||||||
|
|
||||||
|
microscope.apply_settings(payload.json)
|
||||||
|
microscope.save_settings()
|
||||||
|
|
||||||
|
return self.get()
|
||||||
|
|
||||||
|
|
||||||
|
@Tag("properties")
|
||||||
|
class NestedSettingsProperty(View):
|
||||||
|
@doc_response(404, description="Settings key cannot be found")
|
||||||
|
def get(self, route):
|
||||||
|
"""
|
||||||
|
Show a nested section of the current microscope settings
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
keys = route.split("/")
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = get_by_path(microscope.read_settings(), keys)
|
||||||
|
except KeyError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
return jsonify(value)
|
||||||
|
|
||||||
|
@doc_response(404, description="Settings key cannot be found")
|
||||||
|
def put(self, route):
|
||||||
|
"""
|
||||||
|
Update a nested section of the current microscope settings
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
keys = route.split("/")
|
||||||
|
payload = JsonResponse(request)
|
||||||
|
|
||||||
|
dictionary = create_from_path(keys)
|
||||||
|
set_by_path(dictionary, keys, payload.json)
|
||||||
|
|
||||||
|
microscope.apply_settings(dictionary)
|
||||||
|
microscope.save_settings()
|
||||||
|
|
||||||
|
return self.get(route)
|
||||||
|
|
||||||
|
|
||||||
|
@ThingProperty
|
||||||
|
class StatusProperty(View):
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
Show current read-only state of the microscope
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
return jsonify(microscope.status)
|
||||||
|
|
||||||
|
|
||||||
|
@Tag("properties")
|
||||||
|
class NestedStatusProperty(View):
|
||||||
|
@doc_response(404, description="Status key cannot be found")
|
||||||
|
def get(self, route):
|
||||||
|
"""
|
||||||
|
Show a nested section of the current microscope state
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
keys = route.split("/")
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = get_by_path(microscope.status, keys)
|
||||||
|
except KeyError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
return jsonify(value)
|
||||||
51
openflexure_microscope/api/v2/views/streams.py
Normal file
51
openflexure_microscope/api/v2/views/streams.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
from openflexure_microscope.api.utilities import gen, JsonResponse
|
||||||
|
|
||||||
|
from openflexure_microscope.common.labthings_core.utilities import (
|
||||||
|
get_by_path,
|
||||||
|
set_by_path,
|
||||||
|
create_from_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
from openflexure_microscope.common.flask_labthings.find import find_component
|
||||||
|
from openflexure_microscope.common.flask_labthings.view import View
|
||||||
|
from openflexure_microscope.common.flask_labthings.decorators import doc_response, ThingProperty
|
||||||
|
|
||||||
|
from flask import Response
|
||||||
|
|
||||||
|
|
||||||
|
@ThingProperty
|
||||||
|
class MjpegStream(View):
|
||||||
|
"""
|
||||||
|
Real-time MJPEG stream from the microscope camera
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc_response(200, mimetype="multipart/x-mixed-replace")
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
MJPEG stream from the microscope camera
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
# Restart stream worker thread
|
||||||
|
microscope.camera.start_worker()
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
gen(microscope.camera), mimetype="multipart/x-mixed-replace; boundary=frame"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ThingProperty
|
||||||
|
class SnapshotStream(View):
|
||||||
|
"""
|
||||||
|
Single JPEG snapshot from the camera stream
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc_response(200, description="Snapshot taken", mimetype="image/jpeg")
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
Single snapshot from the camera stream
|
||||||
|
"""
|
||||||
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
# Restart stream worker thread
|
||||||
|
microscope.camera.start_worker()
|
||||||
|
|
||||||
|
return Response(microscope.camera.get_frame(), mimetype="image/jpeg")
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
from flask.views import MethodView
|
|
||||||
|
|
||||||
|
|
||||||
class MicroscopeView(MethodView):
|
|
||||||
"""
|
|
||||||
Create a generic MethodView with a globally available
|
|
||||||
microscope object passed as an argument.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, microscope, **kwargs):
|
|
||||||
|
|
||||||
self.microscope = microscope
|
|
||||||
|
|
||||||
MethodView.__init__(self, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class MicroscopeViewPlugin(MicroscopeView):
|
|
||||||
"""
|
|
||||||
Create a generic MethodView with a globally available
|
|
||||||
microscope object passed as an argument, and a plugin
|
|
||||||
reference stored in 'self'. Initially None.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, microscope, plugin=None, **kwargs):
|
|
||||||
|
|
||||||
self.plugin = plugin
|
|
||||||
|
|
||||||
MicroscopeView.__init__(self, microscope=microscope, **kwargs)
|
|
||||||
|
|
@ -9,8 +9,8 @@ import logging
|
||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
|
|
||||||
from .capture import CaptureObject
|
from .capture import CaptureObject
|
||||||
from openflexure_microscope.utilities import entry_by_id
|
from openflexure_microscope.utilities import entry_by_uuid
|
||||||
from openflexure_microscope.common.lock import StrictLock
|
from openflexure_microscope.common.labthings_core.lock import StrictLock
|
||||||
|
|
||||||
|
|
||||||
BASE_CAPTURE_PATH = os.path.join(os.path.expanduser("~"), "micrographs")
|
BASE_CAPTURE_PATH = os.path.join(os.path.expanduser("~"), "micrographs")
|
||||||
|
|
@ -250,11 +250,11 @@ class BaseCamera(metaclass=ABCMeta):
|
||||||
|
|
||||||
def image_from_id(self, image_id):
|
def image_from_id(self, image_id):
|
||||||
"""Return an image StreamObject with a matching ID."""
|
"""Return an image StreamObject with a matching ID."""
|
||||||
return entry_by_id(image_id, self.images)
|
return entry_by_uuid(image_id, self.images)
|
||||||
|
|
||||||
def video_from_id(self, video_id):
|
def video_from_id(self, video_id):
|
||||||
"""Return a video StreamObject with a matching ID."""
|
"""Return a video StreamObject with a matching ID."""
|
||||||
return entry_by_id(video_id, self.videos)
|
return entry_by_uuid(video_id, self.videos)
|
||||||
|
|
||||||
# CREATING NEW CAPTURES
|
# CREATING NEW CAPTURES
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ class CaptureObject(object):
|
||||||
"""Create a new StreamObject, to manage capture data."""
|
"""Create a new StreamObject, to manage capture data."""
|
||||||
|
|
||||||
# Store a nice ID
|
# Store a nice ID
|
||||||
self.id = uuid.uuid4().hex #: str: Unique capture ID
|
self.id = uuid.uuid4() #: str: Unique capture ID
|
||||||
logging.debug("Created StreamObject {}".format(self.id))
|
logging.debug("Created StreamObject {}".format(self.id))
|
||||||
self.timestring = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
self.timestring = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -317,9 +317,6 @@ class PiCameraStreamer(BaseCamera):
|
||||||
"""
|
"""
|
||||||
Change the camera zoom, handling re-centering and scaling.
|
Change the camera zoom, handling re-centering and scaling.
|
||||||
"""
|
"""
|
||||||
logging.warning(
|
|
||||||
"set_zoom is deprecated. Please use the 'zoom' property in picamera_settings."
|
|
||||||
)
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.status["zoom_value"] = float(zoom_value)
|
self.status["zoom_value"] = float(zoom_value)
|
||||||
if self.status["zoom_value"] < 1:
|
if self.status["zoom_value"] < 1:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
from . import flask_labthings
|
||||||
|
from openflexure_microscope.common.labthings_core import tasks
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
EXTENSION_NAME = "flask-lab"
|
||||||
178
openflexure_microscope/common/flask_labthings/decorators.py
Normal file
178
openflexure_microscope/common/flask_labthings/decorators.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
from webargs import flaskparser
|
||||||
|
from functools import wraps, update_wrapper
|
||||||
|
from flask import make_response
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from openflexure_microscope.common.labthings_core.utilities import rupdate
|
||||||
|
|
||||||
|
from .spec import update_spec
|
||||||
|
from .schema import TaskSchema
|
||||||
|
|
||||||
|
|
||||||
|
def unpack(value):
|
||||||
|
"""Return a three tuple of data, code, and headers"""
|
||||||
|
if not isinstance(value, tuple):
|
||||||
|
return value, 200, {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
data, code, headers = value
|
||||||
|
return data, code, headers
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
data, code = value
|
||||||
|
return data, code, {}
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return value, 200, {}
|
||||||
|
|
||||||
|
|
||||||
|
class marshal_with(object):
|
||||||
|
def __init__(self, schema, code=200):
|
||||||
|
"""
|
||||||
|
:param schema: a dict of whose keys will make up the final
|
||||||
|
serialized response output
|
||||||
|
"""
|
||||||
|
self.schema = schema
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
def __call__(self, f):
|
||||||
|
# Pass params to call function attribute for external access
|
||||||
|
update_spec(f, {"_schema": {self.code: self.schema}})
|
||||||
|
# Wrapper function
|
||||||
|
@wraps(f)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
resp = f(*args, **kwargs)
|
||||||
|
if isinstance(resp, tuple):
|
||||||
|
data, code, headers = unpack(resp)
|
||||||
|
return make_response(self.schema.jsonify(data), code, headers)
|
||||||
|
else:
|
||||||
|
return make_response(self.schema.jsonify(resp))
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def marshal_task(f):
|
||||||
|
# Pass params to call function attribute for external access
|
||||||
|
update_spec(f, {"responses": {201: {"description": "Task started successfully"}}})
|
||||||
|
update_spec(f, {"_schema": {201: TaskSchema()}})
|
||||||
|
# Wrapper function
|
||||||
|
@wraps(f)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
resp = f(*args, **kwargs)
|
||||||
|
if isinstance(resp, tuple):
|
||||||
|
data, code, headers = unpack(resp)
|
||||||
|
return make_response(TaskSchema().jsonify(data), code, headers)
|
||||||
|
else:
|
||||||
|
return make_response(TaskSchema().jsonify(resp))
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def ThingAction(viewcls):
|
||||||
|
# Pass params to call function attribute for external access
|
||||||
|
update_spec(viewcls, {"tags": ["actions"]})
|
||||||
|
update_spec(viewcls, {"_groups": ["actions"]})
|
||||||
|
return viewcls
|
||||||
|
|
||||||
|
|
||||||
|
thing_action = ThingAction
|
||||||
|
|
||||||
|
|
||||||
|
def ThingProperty(viewcls):
|
||||||
|
# Pass params to call function attribute for external access
|
||||||
|
update_spec(viewcls, {"tags": ["properties"]})
|
||||||
|
update_spec(viewcls, {"_groups": ["properties"]})
|
||||||
|
return viewcls
|
||||||
|
|
||||||
|
|
||||||
|
thing_property = ThingProperty
|
||||||
|
|
||||||
|
|
||||||
|
class use_args(object):
|
||||||
|
def __init__(self, schema, **kwargs):
|
||||||
|
"""
|
||||||
|
Equivalent to webargs.flask_parser.use_args
|
||||||
|
"""
|
||||||
|
self.schema = schema
|
||||||
|
self.wrapper = flaskparser.use_args(schema, **kwargs)
|
||||||
|
|
||||||
|
def __call__(self, f):
|
||||||
|
# Pass params to call function attribute for external access
|
||||||
|
update_spec(f, {"_params": self.schema})
|
||||||
|
# Wrapper function
|
||||||
|
update_wrapper(self.wrapper, f)
|
||||||
|
return self.wrapper(f)
|
||||||
|
|
||||||
|
|
||||||
|
class use_kwargs(use_args):
|
||||||
|
def __init__(self, schema, **kwargs):
|
||||||
|
"""
|
||||||
|
Equivalent to webargs.flask_parser.use_kwargs
|
||||||
|
"""
|
||||||
|
kwargs["as_kwargs"] = True
|
||||||
|
use_args.__init__(self, schema, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class Doc(object):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
def __call__(self, f):
|
||||||
|
# Pass params to call function attribute for external access
|
||||||
|
update_spec(f, self.kwargs)
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
doc = Doc
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(object):
|
||||||
|
def __init__(self, tags):
|
||||||
|
if isinstance(tags, str):
|
||||||
|
self.tags = [tags]
|
||||||
|
elif isinstance(tags, list) and all([isinstance(e, str) for e in tags]):
|
||||||
|
self.tags = tags
|
||||||
|
else:
|
||||||
|
raise TypeError("Tags must be a string or list of strings")
|
||||||
|
|
||||||
|
def __call__(self, f):
|
||||||
|
# Pass params to call function attribute for external access
|
||||||
|
update_spec(f, {"tags": self.tags})
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
tag = Tag
|
||||||
|
|
||||||
|
|
||||||
|
class doc_response(object):
|
||||||
|
def __init__(self, code, description=None, mimetype=None, **kwargs):
|
||||||
|
self.code = code
|
||||||
|
self.description = description
|
||||||
|
self.kwargs = kwargs
|
||||||
|
self.mimetype = mimetype
|
||||||
|
|
||||||
|
self.response_dict = {
|
||||||
|
"responses": {
|
||||||
|
self.code: {
|
||||||
|
"description": self.description or HTTPStatus(self.code).phrase,
|
||||||
|
**self.kwargs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.mimetype:
|
||||||
|
self.response_dict.update({
|
||||||
|
"responses": {
|
||||||
|
self.code: {
|
||||||
|
"content": {self.mimetype: {}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
def __call__(self, f):
|
||||||
|
# Pass params to call function attribute for external access
|
||||||
|
update_spec(f, self.response_dict)
|
||||||
|
return f
|
||||||
|
|
@ -24,7 +24,7 @@ class JSONExceptionHandler(object):
|
||||||
|
|
||||||
status_code = error.code if isinstance(error, HTTPException) else 500
|
status_code = error.code if isinstance(error, HTTPException) else 500
|
||||||
|
|
||||||
response = {"status_code": status_code, "message": escape(message)}
|
response = {"code": status_code, "message": escape(message)}
|
||||||
return jsonify(response), status_code
|
return jsonify(response), status_code
|
||||||
|
|
||||||
def init_app(self, app):
|
def init_app(self, app):
|
||||||
142
openflexure_microscope/common/flask_labthings/extensions.py
Normal file
142
openflexure_microscope/common/flask_labthings/extensions.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import logging
|
||||||
|
import collections
|
||||||
|
import copy
|
||||||
|
|
||||||
|
from importlib import util
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
|
||||||
|
from openflexure_microscope.common.labthings_core.utilities import (
|
||||||
|
get_docstring,
|
||||||
|
camel_to_snake,
|
||||||
|
snake_to_spine,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseExtension:
|
||||||
|
"""
|
||||||
|
Parent class for all extensions.
|
||||||
|
|
||||||
|
Handles binding route views and forms.
|
||||||
|
"""
|
||||||
|
# TODO: Allow adding components to extensions
|
||||||
|
|
||||||
|
def __init__(self, name: str, description="", version="0.0.0"):
|
||||||
|
self._views = (
|
||||||
|
{}
|
||||||
|
) # Key: Full, Python-safe ID. Val: Original rule, and view class
|
||||||
|
self._rules = {} # Key: Original rule. Val: View class
|
||||||
|
self._meta = {} # Extra metadata to add to the extension description
|
||||||
|
|
||||||
|
self._cls = str(self) # String description of extension instance
|
||||||
|
|
||||||
|
self.actions = []
|
||||||
|
self.properties = []
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.description = get_docstring(self)
|
||||||
|
self.version = str(version)
|
||||||
|
|
||||||
|
self.methods = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def views(self):
|
||||||
|
return self._views
|
||||||
|
|
||||||
|
def add_view(self, view_class, rule, **kwargs):
|
||||||
|
# Remove all leading slashes from view route
|
||||||
|
cleaned_rule = rule
|
||||||
|
while cleaned_rule[0] == "/":
|
||||||
|
cleaned_rule = cleaned_rule[1:]
|
||||||
|
|
||||||
|
# Expand the rule to include extension name
|
||||||
|
full_rule = "/{}/{}".format(self._name_uri_safe, cleaned_rule)
|
||||||
|
|
||||||
|
view_id = cleaned_rule.replace("/", "_").replace("<", "").replace(">", "")
|
||||||
|
|
||||||
|
# Create a Python-safe route ID
|
||||||
|
logging.debug(view_id)
|
||||||
|
|
||||||
|
# Store route information in a dictionary
|
||||||
|
d = {"rule": full_rule, "view": view_class, "kwargs": kwargs}
|
||||||
|
|
||||||
|
# Add view to private views dictionary
|
||||||
|
self._views[view_id] = d
|
||||||
|
# Store the rule expansion information
|
||||||
|
self._rules[rule] = self._views[view_id]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def meta(self):
|
||||||
|
d = {}
|
||||||
|
for k, v in self._meta.items():
|
||||||
|
if callable(v):
|
||||||
|
d[k] = v()
|
||||||
|
else:
|
||||||
|
d[k] = v
|
||||||
|
return d
|
||||||
|
|
||||||
|
def add_meta(self, key, val):
|
||||||
|
self._meta[key] = val
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _name(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _name_python_safe(self):
|
||||||
|
name = camel_to_snake(self._name) # Camel to snake
|
||||||
|
name = name.replace(" ", "_") # Spaces to snake
|
||||||
|
return name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _name_uri_safe(self):
|
||||||
|
return snake_to_spine(self._name_python_safe)
|
||||||
|
|
||||||
|
def add_method(self, method, method_name):
|
||||||
|
self.methods[method_name] = method
|
||||||
|
|
||||||
|
if not hasattr(self, method_name):
|
||||||
|
setattr(self, method_name, method)
|
||||||
|
else:
|
||||||
|
logging.warning(
|
||||||
|
"Unable to bind method to extension. Method name already exists."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_instances_in_module(module, class_to_find):
|
||||||
|
objs = []
|
||||||
|
for attribute in dir(module):
|
||||||
|
if not attribute.startswith("__"):
|
||||||
|
if isinstance(getattr(module, attribute), class_to_find):
|
||||||
|
objs.append(getattr(module, attribute))
|
||||||
|
return objs
|
||||||
|
|
||||||
|
|
||||||
|
def find_extensions_in_file(extension_path: str, module_name="extensions"):
|
||||||
|
logging.debug(f"Loading extensions from {extension_path}")
|
||||||
|
|
||||||
|
spec = util.spec_from_file_location(module_name, extension_path)
|
||||||
|
mod = util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = mod
|
||||||
|
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
|
||||||
|
if hasattr(mod, "__extensions__"):
|
||||||
|
return [getattr(mod, ext_name) for ext_name in mod.__extensions__]
|
||||||
|
else:
|
||||||
|
return find_instances_in_module(mod, BaseExtension)
|
||||||
|
|
||||||
|
|
||||||
|
def find_extensions(extension_dir: str, module_name="extensions"):
|
||||||
|
logging.debug(f"Loading extensions from {extension_dir}")
|
||||||
|
|
||||||
|
extensions = []
|
||||||
|
extension_paths = glob.glob(os.path.join(extension_dir, "*.py"))
|
||||||
|
|
||||||
|
for extension_path in extension_paths:
|
||||||
|
extensions.extend(
|
||||||
|
find_extensions_in_file(extension_path, module_name=module_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
return extensions
|
||||||
2
openflexure_microscope/common/flask_labthings/fields.py
Normal file
2
openflexure_microscope/common/flask_labthings/fields.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Marshmallow fields
|
||||||
|
from marshmallow.fields import *
|
||||||
50
openflexure_microscope/common/flask_labthings/find.py
Normal file
50
openflexure_microscope/common/flask_labthings/find.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import logging
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from . import EXTENSION_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def current_labthing():
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
if not app:
|
||||||
|
return None
|
||||||
|
logging.debug("Active app extensions:")
|
||||||
|
logging.debug(app.extensions)
|
||||||
|
logging.debug("Active labthing:")
|
||||||
|
logging.debug(app.extensions[EXTENSION_NAME])
|
||||||
|
return app.extensions[EXTENSION_NAME]
|
||||||
|
|
||||||
|
|
||||||
|
def registered_extensions(labthing_instance=None):
|
||||||
|
if not labthing_instance:
|
||||||
|
labthing_instance = current_labthing()
|
||||||
|
return labthing_instance.extensions
|
||||||
|
|
||||||
|
|
||||||
|
def registered_components(labthing_instance=None):
|
||||||
|
if not labthing_instance:
|
||||||
|
labthing_instance = current_labthing()
|
||||||
|
return labthing_instance.components
|
||||||
|
|
||||||
|
|
||||||
|
def find_component(device_name, labthing_instance=None):
|
||||||
|
if not labthing_instance:
|
||||||
|
labthing_instance = current_labthing()
|
||||||
|
|
||||||
|
if device_name in labthing_instance.components:
|
||||||
|
return labthing_instance.components[device_name]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_extension(extension_name, labthing_instance=None):
|
||||||
|
if not labthing_instance:
|
||||||
|
labthing_instance = current_labthing()
|
||||||
|
|
||||||
|
logging.debug("Current labthing:")
|
||||||
|
logging.debug(current_labthing())
|
||||||
|
|
||||||
|
if extension_name in labthing_instance.extensions:
|
||||||
|
return labthing_instance.extensions[extension_name]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
288
openflexure_microscope/common/flask_labthings/labthing.py
Normal file
288
openflexure_microscope/common/flask_labthings/labthing.py
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
from flask import url_for, jsonify
|
||||||
|
from apispec import APISpec
|
||||||
|
from apispec.ext.marshmallow import MarshmallowPlugin
|
||||||
|
|
||||||
|
from . import EXTENSION_NAME # TODO: Move into .names
|
||||||
|
from .names import TASK_ENDPOINT, TASK_LIST_ENDPOINT, EXTENSION_LIST_ENDPOINT
|
||||||
|
from .extensions import BaseExtension
|
||||||
|
from .utilities import description_from_view
|
||||||
|
from .spec import rule2path, get_spec
|
||||||
|
from .decorators import tag
|
||||||
|
|
||||||
|
from .views.extensions import ExtensionList
|
||||||
|
from .views.tasks import TaskList, TaskView
|
||||||
|
from .views.docs import docs_blueprint, SwaggerUIView, W3CThingDescriptionView
|
||||||
|
|
||||||
|
from openflexure_microscope.common.labthings_core.utilities import get_docstring
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
class LabThing(object):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app=None,
|
||||||
|
prefix: str = "",
|
||||||
|
title: str = "",
|
||||||
|
description: str = "",
|
||||||
|
version: str = "0.0.0",
|
||||||
|
):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
self.components = {}
|
||||||
|
|
||||||
|
self.extensions = {}
|
||||||
|
|
||||||
|
self.views = []
|
||||||
|
self.properties = {}
|
||||||
|
self.actions = {}
|
||||||
|
|
||||||
|
self.custom_root_links = {}
|
||||||
|
|
||||||
|
self.endpoints = set()
|
||||||
|
|
||||||
|
self.url_prefix = prefix
|
||||||
|
self._description = description
|
||||||
|
self._title = title
|
||||||
|
self._version = version
|
||||||
|
|
||||||
|
# Store handlers for things like errors and CORS
|
||||||
|
self.handlers = {}
|
||||||
|
|
||||||
|
self.spec = APISpec(
|
||||||
|
title=self.title,
|
||||||
|
version=self.version,
|
||||||
|
openapi_version="3.0.2",
|
||||||
|
plugins=[MarshmallowPlugin()],
|
||||||
|
)
|
||||||
|
|
||||||
|
if app is not None:
|
||||||
|
self.init_app(app)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self,):
|
||||||
|
return self._description
|
||||||
|
|
||||||
|
@description.setter
|
||||||
|
def description(self, description: str):
|
||||||
|
self._description = description
|
||||||
|
self.spec.description = description
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self,):
|
||||||
|
return self._title
|
||||||
|
|
||||||
|
@title.setter
|
||||||
|
def title(self, title: str):
|
||||||
|
self._title = title
|
||||||
|
self.spec.title = title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def version(self,):
|
||||||
|
return str(self._version)
|
||||||
|
|
||||||
|
@version.setter
|
||||||
|
def version(self, version: str):
|
||||||
|
self._version = version
|
||||||
|
self.spec.version = version
|
||||||
|
|
||||||
|
### Flask stuff
|
||||||
|
|
||||||
|
def init_app(self, app):
|
||||||
|
app.teardown_appcontext(self.teardown)
|
||||||
|
|
||||||
|
# Register Flask extension
|
||||||
|
app.extensions = getattr(app, "extensions", {})
|
||||||
|
app.extensions[EXTENSION_NAME] = self
|
||||||
|
|
||||||
|
# Add resources, if registered before tying to a Flask app
|
||||||
|
if len(self.views) > 0:
|
||||||
|
for resource, urls, endpoint, kwargs in self.views:
|
||||||
|
self._register_view(app, resource, *urls, endpoint=endpoint, **kwargs)
|
||||||
|
|
||||||
|
# Create base routes
|
||||||
|
self._create_base_routes()
|
||||||
|
|
||||||
|
def teardown(self, exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _create_base_routes(self):
|
||||||
|
# Add root representation
|
||||||
|
self.app.add_url_rule(self._complete_url("/", ""), "rootrep", self.rootrep)
|
||||||
|
# Add thing descriptions
|
||||||
|
self.app.register_blueprint(docs_blueprint, url_prefix=self.url_prefix)
|
||||||
|
|
||||||
|
# Add extension overview
|
||||||
|
self.add_view(ExtensionList, "/extensions", endpoint=EXTENSION_LIST_ENDPOINT)
|
||||||
|
# Add task routes
|
||||||
|
self.add_view(TaskList, "/tasks", endpoint=TASK_LIST_ENDPOINT)
|
||||||
|
self.add_view(TaskView, "/tasks/<id>", endpoint=TASK_ENDPOINT)
|
||||||
|
|
||||||
|
### Device stuff
|
||||||
|
|
||||||
|
def add_component(self, device_object, device_name: str):
|
||||||
|
self.components[device_name] = device_object
|
||||||
|
|
||||||
|
### Extension stuff
|
||||||
|
|
||||||
|
def register_extension(self, extension_object):
|
||||||
|
if isinstance(extension_object, BaseExtension):
|
||||||
|
self.extensions[extension_object.name] = extension_object
|
||||||
|
else:
|
||||||
|
raise TypeError("Extension object must be an instance of BaseExtension")
|
||||||
|
|
||||||
|
for extension_view_id, extension_view in extension_object.views.items():
|
||||||
|
# Add route to the extensions blueprint
|
||||||
|
self.add_view(
|
||||||
|
tag("extensions")(extension_view["view"]),
|
||||||
|
"/extensions" + extension_view["rule"],
|
||||||
|
**extension_view["kwargs"],
|
||||||
|
)
|
||||||
|
|
||||||
|
### Resource stuff
|
||||||
|
|
||||||
|
def _complete_url(self, url_part, registration_prefix):
|
||||||
|
"""This method is used to defer the construction of the final url in
|
||||||
|
the case that the Api is created with a Blueprint.
|
||||||
|
:param url_part: The part of the url the endpoint is registered with
|
||||||
|
:param registration_prefix: The part of the url contributed by the
|
||||||
|
blueprint. Generally speaking, BlueprintSetupState.url_prefix
|
||||||
|
"""
|
||||||
|
parts = [registration_prefix, self.url_prefix, url_part]
|
||||||
|
return "".join([part for part in parts if part])
|
||||||
|
|
||||||
|
def add_view(self, resource, *urls, endpoint=None, **kwargs):
|
||||||
|
"""Adds a view to the api.
|
||||||
|
:param resource: the class name of your resource
|
||||||
|
:type resource: :class:`Type[Resource]`
|
||||||
|
:param urls: one or more url routes to match for the resource, standard
|
||||||
|
flask routing rules apply. Any url variables will be
|
||||||
|
passed to the resource method as args.
|
||||||
|
:type urls: str
|
||||||
|
:param endpoint: endpoint name (defaults to :meth:`Resource.__name__`
|
||||||
|
Can be used to reference this route in :class:`fields.Url` fields
|
||||||
|
:type endpoint: str
|
||||||
|
:param resource_class_args: args to be forwarded to the constructor of
|
||||||
|
the resource.
|
||||||
|
:type resource_class_args: tuple
|
||||||
|
:param resource_class_kwargs: kwargs to be forwarded to the constructor
|
||||||
|
of the resource.
|
||||||
|
:type resource_class_kwargs: dict
|
||||||
|
Additional keyword arguments not specified above will be passed as-is
|
||||||
|
to :meth:`flask.Flask.add_url_rule`.
|
||||||
|
Examples::
|
||||||
|
api.add_resource(HelloWorld, '/', '/hello')
|
||||||
|
api.add_resource(Foo, '/foo', endpoint="foo")
|
||||||
|
api.add_resource(FooSpecial, '/special/foo', endpoint="foo")
|
||||||
|
"""
|
||||||
|
endpoint = endpoint or resource.__name__.lower()
|
||||||
|
|
||||||
|
logging.debug(f"{endpoint}: {type(resource)}")
|
||||||
|
|
||||||
|
if self.app is not None:
|
||||||
|
self._register_view(self.app, resource, *urls, endpoint=endpoint, **kwargs)
|
||||||
|
|
||||||
|
self.views.append((resource, urls, endpoint, kwargs))
|
||||||
|
|
||||||
|
def view(self, *urls, **kwargs):
|
||||||
|
def decorator(cls):
|
||||||
|
self.add_view(cls, *urls, **kwargs)
|
||||||
|
return cls
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
def _register_view(self, app, view, *urls, endpoint=None, **kwargs):
|
||||||
|
endpoint = endpoint or view.__name__.lower()
|
||||||
|
self.endpoints.add(endpoint)
|
||||||
|
resource_class_args = kwargs.pop("resource_class_args", ())
|
||||||
|
resource_class_kwargs = kwargs.pop("resource_class_kwargs", {})
|
||||||
|
|
||||||
|
# NOTE: 'view_functions' is cleaned up from Blueprint class in Flask 1.0
|
||||||
|
if endpoint in getattr(app, "view_functions", {}):
|
||||||
|
previous_view_class = app.view_functions[endpoint].__dict__["view_class"]
|
||||||
|
|
||||||
|
# if you override the endpoint with a different class, avoid the collision by raising an exception
|
||||||
|
if previous_view_class != view:
|
||||||
|
raise ValueError(
|
||||||
|
"This endpoint (%s) is already set to the class %s."
|
||||||
|
% (endpoint, previous_view_class.__name__)
|
||||||
|
)
|
||||||
|
|
||||||
|
view.endpoint = endpoint
|
||||||
|
resource_func = view.as_view(
|
||||||
|
endpoint, *resource_class_args, **resource_class_kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
for url in urls:
|
||||||
|
# If we've got no Blueprint, just build a url with no prefix
|
||||||
|
rule = self._complete_url(url, "")
|
||||||
|
# Add the url to the application or blueprint
|
||||||
|
app.add_url_rule(rule, view_func=resource_func, **kwargs)
|
||||||
|
# Add the resource to our API spec
|
||||||
|
#self.spec.path(**view2path(rule, view, self.spec))
|
||||||
|
|
||||||
|
# TEST: Getting Flask rule objects
|
||||||
|
flask_rules = app.url_map._rules_by_endpoint.get(endpoint)
|
||||||
|
for flask_rule in flask_rules:
|
||||||
|
self.spec.path(**rule2path(flask_rule, view, self.spec))
|
||||||
|
|
||||||
|
# Handle resource groups listed in API spec
|
||||||
|
view_spec = get_spec(view)
|
||||||
|
view_groups = view_spec.get("_groups", {})
|
||||||
|
if "actions" in view_groups:
|
||||||
|
self.actions[view.endpoint] = view
|
||||||
|
if "properties" in view_groups:
|
||||||
|
self.properties[view.endpoint] = view
|
||||||
|
|
||||||
|
### Utilities
|
||||||
|
|
||||||
|
def url_for(self, view, **values):
|
||||||
|
"""Generates a URL to the given resource.
|
||||||
|
Works like :func:`flask.url_for`."""
|
||||||
|
endpoint = view.endpoint
|
||||||
|
return url_for(endpoint, **values)
|
||||||
|
|
||||||
|
def owns_endpoint(self, endpoint):
|
||||||
|
return endpoint in self.endpoints
|
||||||
|
|
||||||
|
def add_root_link(self, view, title, kwargs={}):
|
||||||
|
self.custom_root_links[title] = (view, kwargs)
|
||||||
|
|
||||||
|
### Description
|
||||||
|
def rootrep(self):
|
||||||
|
"""
|
||||||
|
Root representation
|
||||||
|
"""
|
||||||
|
# TODO: Allow custom root representations
|
||||||
|
|
||||||
|
rr = {
|
||||||
|
"id": url_for("rootrep", _external=True),
|
||||||
|
"title": self.title,
|
||||||
|
"description": self.description,
|
||||||
|
"links": {
|
||||||
|
"thingDescription": {
|
||||||
|
"href": url_for("labthings_docs.w3c_td", _external=True),
|
||||||
|
"description": get_docstring(W3CThingDescriptionView),
|
||||||
|
},
|
||||||
|
"swaggerUI": {
|
||||||
|
"href": url_for("labthings_docs.swagger_ui", _external=True),
|
||||||
|
**description_from_view(SwaggerUIView),
|
||||||
|
},
|
||||||
|
"extensions": {
|
||||||
|
"href": self.url_for(ExtensionList, _external=True),
|
||||||
|
**description_from_view(ExtensionList),
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"href": self.url_for(TaskList, _external=True),
|
||||||
|
**description_from_view(TaskList),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for title, (view, kwargs) in self.custom_root_links.items():
|
||||||
|
rr["links"][title] = {
|
||||||
|
"href": self.url_for(view, **kwargs, _external=True),
|
||||||
|
**description_from_view(view),
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(rr)
|
||||||
3
openflexure_microscope/common/flask_labthings/names.py
Normal file
3
openflexure_microscope/common/flask_labthings/names.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
TASK_ENDPOINT = "labthing_task"
|
||||||
|
TASK_LIST_ENDPOINT = "labthing_task_list"
|
||||||
|
EXTENSION_LIST_ENDPOINT = "labthing_extension_list"
|
||||||
41
openflexure_microscope/common/flask_labthings/quick.py
Normal file
41
openflexure_microscope/common/flask_labthings/quick.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
from flask import Flask
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
from .labthing import LabThing
|
||||||
|
from .exceptions import JSONExceptionHandler
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(
|
||||||
|
import_name,
|
||||||
|
prefix: str = "",
|
||||||
|
title: str = "",
|
||||||
|
description: str = "",
|
||||||
|
version: str = "0.0.0",
|
||||||
|
handle_errors: bool = True,
|
||||||
|
handle_cors: bool = True,
|
||||||
|
flask_kwargs: dict = {},
|
||||||
|
):
|
||||||
|
app = Flask(import_name, **flask_kwargs)
|
||||||
|
app.url_map.strict_slashes = False
|
||||||
|
|
||||||
|
# Handle CORS
|
||||||
|
if handle_cors:
|
||||||
|
cors_handler = CORS(app, resources=f"{prefix}/*")
|
||||||
|
|
||||||
|
# Handle errors
|
||||||
|
if handle_errors:
|
||||||
|
error_handler = JSONExceptionHandler()
|
||||||
|
error_handler.init_app(app)
|
||||||
|
|
||||||
|
# Create a LabThing
|
||||||
|
labthing = LabThing(
|
||||||
|
app, prefix=prefix, title=title, description=description, version=str(version)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store references to added-in handlers
|
||||||
|
if cors_handler:
|
||||||
|
labthing.handlers["cors"] = cors_handler
|
||||||
|
if error_handler:
|
||||||
|
labthing.handlers["error"] = error_handler
|
||||||
|
|
||||||
|
return app, labthing
|
||||||
93
openflexure_microscope/common/flask_labthings/schema.py
Normal file
93
openflexure_microscope/common/flask_labthings/schema.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from flask import jsonify, url_for
|
||||||
|
import marshmallow
|
||||||
|
|
||||||
|
from .names import TASK_ENDPOINT, TASK_LIST_ENDPOINT, EXTENSION_LIST_ENDPOINT
|
||||||
|
from .utilities import view_class_from_endpoint, description_from_view
|
||||||
|
from . import fields
|
||||||
|
|
||||||
|
MARSHMALLOW_VERSION_INFO = tuple(
|
||||||
|
[int(part) for part in marshmallow.__version__.split(".") if part.isdigit()]
|
||||||
|
)
|
||||||
|
|
||||||
|
sentinel = object()
|
||||||
|
|
||||||
|
|
||||||
|
class Schema(marshmallow.Schema):
|
||||||
|
"""Base serializer with which to define custom serializers.
|
||||||
|
See `marshmallow.Schema` for more details about the `Schema` API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def jsonify(self, obj, many=sentinel, *args, **kwargs):
|
||||||
|
"""Return a JSON response containing the serialized data.
|
||||||
|
:param obj: Object to serialize.
|
||||||
|
:param bool many: Whether `obj` should be serialized as an instance
|
||||||
|
or as a collection. If unset, defaults to the value of the
|
||||||
|
`many` attribute on this Schema.
|
||||||
|
:param kwargs: Additional keyword arguments passed to `flask.jsonify`.
|
||||||
|
.. versionchanged:: 0.6.0
|
||||||
|
Takes the same arguments as `marshmallow.Schema.dump`. Additional
|
||||||
|
keyword arguments are passed to `flask.jsonify`.
|
||||||
|
.. versionchanged:: 0.6.3
|
||||||
|
The `many` argument for this method defaults to the value of
|
||||||
|
the `many` attribute on the Schema. Previously, the `many`
|
||||||
|
argument of this method defaulted to False, regardless of the
|
||||||
|
value of `Schema.many`.
|
||||||
|
"""
|
||||||
|
if many is sentinel:
|
||||||
|
many = self.many
|
||||||
|
if MARSHMALLOW_VERSION_INFO[0] >= 3:
|
||||||
|
data = self.dump(obj, many=many)
|
||||||
|
else:
|
||||||
|
data = self.dump(obj, many=many).data
|
||||||
|
return jsonify(data, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskSchema(Schema):
|
||||||
|
_ID = fields.String(data_key="id")
|
||||||
|
target_string = fields.String(data_key="function")
|
||||||
|
_status = fields.String(data_key="status")
|
||||||
|
progress = fields.String()
|
||||||
|
data = fields.Raw()
|
||||||
|
_return_value = fields.Raw(data_key="return")
|
||||||
|
_start_time = fields.String(data_key="start_time")
|
||||||
|
_end_time = fields.String(data_key="end_time")
|
||||||
|
|
||||||
|
links = fields.Dict()
|
||||||
|
|
||||||
|
@marshmallow.pre_dump
|
||||||
|
def generate_links(self, data, **kwargs):
|
||||||
|
data.links = {
|
||||||
|
"self": {
|
||||||
|
"href": url_for(TASK_ENDPOINT, id=data.id, _external=True),
|
||||||
|
"mimetype": "application/json",
|
||||||
|
**description_from_view(view_class_from_endpoint(TASK_ENDPOINT)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionSchema(Schema):
|
||||||
|
name = fields.String(data_key="title")
|
||||||
|
_name_python_safe = fields.String(data_key="pythonName")
|
||||||
|
_cls = fields.String(data_key="pythonObject")
|
||||||
|
meta = fields.Dict()
|
||||||
|
description = fields.String()
|
||||||
|
|
||||||
|
links = fields.Dict()
|
||||||
|
|
||||||
|
@marshmallow.pre_dump
|
||||||
|
def generate_links(self, data, **kwargs):
|
||||||
|
d = {}
|
||||||
|
for view_id, view_data in data.views.items():
|
||||||
|
view_cls = view_data["view"]
|
||||||
|
view_rule = view_data["rule"]
|
||||||
|
# Make links dictionary if it doesn't yet exist
|
||||||
|
d[view_id] = {
|
||||||
|
"href": url_for(EXTENSION_LIST_ENDPOINT, _external=True) + view_rule,
|
||||||
|
**description_from_view(view_cls),
|
||||||
|
}
|
||||||
|
|
||||||
|
data.links = d
|
||||||
|
|
||||||
|
return data
|
||||||
164
openflexure_microscope/common/flask_labthings/spec/__init__.py
Normal file
164
openflexure_microscope/common/flask_labthings/spec/__init__.py
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
from ..view import View
|
||||||
|
from apispec import APISpec
|
||||||
|
from apispec.ext.marshmallow import MarshmallowPlugin
|
||||||
|
|
||||||
|
from openflexure_microscope.common.labthings_core.utilities import (
|
||||||
|
get_docstring,
|
||||||
|
get_summary,
|
||||||
|
rupdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..fields import Field
|
||||||
|
from marshmallow import Schema as BaseSchema
|
||||||
|
|
||||||
|
from .paths import rule_to_path, rule_to_params
|
||||||
|
|
||||||
|
from werkzeug.routing import Rule
|
||||||
|
from collections import Mapping
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
|
||||||
|
def update_spec(obj, spec):
|
||||||
|
obj.__apispec__ = obj.__dict__.get("__apispec__", {})
|
||||||
|
rupdate(obj.__apispec__, spec)
|
||||||
|
return obj.__apispec__
|
||||||
|
|
||||||
|
|
||||||
|
def get_spec(obj):
|
||||||
|
obj.__apispec__ = obj.__dict__.get("__apispec__", {})
|
||||||
|
return obj.__apispec__
|
||||||
|
|
||||||
|
|
||||||
|
def rule2path(rule: Rule, view: View, spec: APISpec):
|
||||||
|
params = {
|
||||||
|
"path": rule_to_path(rule),
|
||||||
|
"operations": view2operations(view, spec),
|
||||||
|
"description": get_docstring(view),
|
||||||
|
"summary": get_summary(view),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add URL arguments
|
||||||
|
if rule.arguments:
|
||||||
|
for op in params.get("operations").keys():
|
||||||
|
params["operations"][op].update({
|
||||||
|
"parameters": rule_to_params(rule)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add extra parameters
|
||||||
|
if hasattr(view, "__apispec__"):
|
||||||
|
# Recursively update params
|
||||||
|
rupdate(params, view.__apispec__)
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def view2operations(view: View, spec: APISpec):
|
||||||
|
# Operations inherit tags from parent
|
||||||
|
inherited_tags = []
|
||||||
|
if hasattr(view, "__apispec__"):
|
||||||
|
inherited_tags = getattr(view, "__apispec__").get("tags", [])
|
||||||
|
|
||||||
|
# Build dictionary of operations (HTTP methods)
|
||||||
|
ops = {}
|
||||||
|
for method in View.methods:
|
||||||
|
if hasattr(view, method):
|
||||||
|
ops[method] = {}
|
||||||
|
|
||||||
|
rupdate(
|
||||||
|
ops[method],
|
||||||
|
{
|
||||||
|
"description": get_docstring(getattr(view, method)),
|
||||||
|
"summary": get_summary(getattr(view, method)),
|
||||||
|
"tags": inherited_tags,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
rupdate(ops[method], method2operation(getattr(view, method), spec))
|
||||||
|
|
||||||
|
return ops
|
||||||
|
|
||||||
|
|
||||||
|
def method2operation(method: callable, spec: APISpec):
|
||||||
|
if hasattr(method, "__apispec__"):
|
||||||
|
apispec = getattr(method, "__apispec__")
|
||||||
|
else:
|
||||||
|
apispec = {}
|
||||||
|
|
||||||
|
op = {}
|
||||||
|
if "_params" in apispec:
|
||||||
|
rupdate(
|
||||||
|
op,
|
||||||
|
{
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": convert_schema(apispec.get("_params"), spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if "_schema" in apispec:
|
||||||
|
for code, schema in apispec.get("_schema", {}).items():
|
||||||
|
rupdate(
|
||||||
|
op,
|
||||||
|
{
|
||||||
|
"responses": {
|
||||||
|
code: {
|
||||||
|
"description": HTTPStatus(code).phrase,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": convert_schema(schema, spec)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# If no explicit responses are known, populate with defaults
|
||||||
|
rupdate(
|
||||||
|
op,
|
||||||
|
{
|
||||||
|
"responses": {
|
||||||
|
200: {"description": get_summary(method) or HTTPStatus(200).phrase}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bung in any extra swagger fields supplied
|
||||||
|
for key, val in apispec.items():
|
||||||
|
if not key in ["_params", "_schema"]:
|
||||||
|
rupdate(op, {key: val})
|
||||||
|
|
||||||
|
return op
|
||||||
|
|
||||||
|
|
||||||
|
def convert_schema(schema, spec: APISpec):
|
||||||
|
if isinstance(schema, BaseSchema):
|
||||||
|
return schema
|
||||||
|
elif isinstance(schema, Mapping):
|
||||||
|
return map2properties(schema, spec)
|
||||||
|
else:
|
||||||
|
raise TypeError(
|
||||||
|
"Unsupported schema type. Ensure schema is a Schema class, or dictionary of Field objects"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map2properties(schema, spec: APISpec):
|
||||||
|
marshmallow_plugin = next(
|
||||||
|
plugin for plugin in spec.plugins if isinstance(plugin, MarshmallowPlugin)
|
||||||
|
)
|
||||||
|
converter = marshmallow_plugin.converter
|
||||||
|
|
||||||
|
d = {}
|
||||||
|
for k, v in schema.items():
|
||||||
|
if isinstance(v, Field):
|
||||||
|
d[k] = converter.field2property(v)
|
||||||
|
elif isinstance(v, Mapping):
|
||||||
|
d[k] = map2properties(v, spec)
|
||||||
|
else:
|
||||||
|
d[k] = v
|
||||||
|
|
||||||
|
return {"properties": d}
|
||||||
50
openflexure_microscope/common/flask_labthings/spec/paths.py
Normal file
50
openflexure_microscope/common/flask_labthings/spec/paths.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import werkzeug.routing
|
||||||
|
|
||||||
|
PATH_RE = re.compile(r'<(?:[^:<>]+:)?([^<>]+)>')
|
||||||
|
|
||||||
|
|
||||||
|
def rule_to_path(rule):
|
||||||
|
return PATH_RE.sub(r'{\1}', rule.rule)
|
||||||
|
|
||||||
|
|
||||||
|
# Conversion map of werkzeug rule converters to Javascript schema types
|
||||||
|
CONVERTER_MAPPING = {
|
||||||
|
werkzeug.routing.UnicodeConverter: ('string', None),
|
||||||
|
werkzeug.routing.IntegerConverter: ('integer', 'int32'),
|
||||||
|
werkzeug.routing.FloatConverter: ('number', 'float'),
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_TYPE = ('string', None)
|
||||||
|
|
||||||
|
|
||||||
|
def rule_to_params(rule, overrides=None):
|
||||||
|
overrides = (overrides or {})
|
||||||
|
result = [
|
||||||
|
argument_to_param(argument, rule, overrides.get(argument, {}))
|
||||||
|
for argument in rule.arguments
|
||||||
|
]
|
||||||
|
for key in overrides.keys():
|
||||||
|
if overrides[key].get('in') in ('header', 'query'):
|
||||||
|
overrides[key]['name'] = overrides[key].get('name', key)
|
||||||
|
result.append(overrides[key])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def argument_to_param(argument, rule, override=None):
|
||||||
|
param = {
|
||||||
|
'in': 'path',
|
||||||
|
'name': argument,
|
||||||
|
'required': True,
|
||||||
|
}
|
||||||
|
type_, format_ = CONVERTER_MAPPING.get(type(rule._converters[argument]), DEFAULT_TYPE)
|
||||||
|
param['type'] = type_
|
||||||
|
if format_ is not None:
|
||||||
|
param['format'] = format_
|
||||||
|
if rule.defaults and argument in rule.defaults:
|
||||||
|
param['default'] = rule.defaults[argument]
|
||||||
|
param.update(override or {})
|
||||||
|
return param
|
||||||
66
openflexure_microscope/common/flask_labthings/types.py
Normal file
66
openflexure_microscope/common/flask_labthings/types.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Marshmallow fields to JSON schema types
|
||||||
|
# Note: We shouldn't ever need to use this directly. We should go via the apispec converter
|
||||||
|
from apispec.ext.marshmallow.field_converter import DEFAULT_FIELD_MAPPING
|
||||||
|
|
||||||
|
from . import fields
|
||||||
|
|
||||||
|
# Extra standard library Python types
|
||||||
|
from datetime import date, datetime, time, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Dict, List, Tuple, Union
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
"""
|
||||||
|
TODO: Use this to convert arbitrary dictionary into its own schema, for W3C TD
|
||||||
|
|
||||||
|
First: Convert Python non-builtins to builtins using DEFAULT_BUILTIN_CONVERSIONS
|
||||||
|
Then match types of each element to Field using DEFAULT_TYPE_MAPPING
|
||||||
|
Finally convert Fields to JSON using converter (preferred due to extra metadata), or DEFAULT_FIELD_MAPPING
|
||||||
|
"""
|
||||||
|
# Python types to Marshmallow fields
|
||||||
|
DEFAULT_TYPE_MAPPING = {
|
||||||
|
bool: fields.Boolean,
|
||||||
|
date: fields.Date,
|
||||||
|
datetime: fields.DateTime,
|
||||||
|
Decimal: fields.Decimal,
|
||||||
|
float: fields.Float,
|
||||||
|
int: fields.Integer,
|
||||||
|
str: fields.String,
|
||||||
|
time: fields.Time,
|
||||||
|
timedelta: fields.TimeDelta,
|
||||||
|
UUID: fields.UUID,
|
||||||
|
dict: fields.Dict,
|
||||||
|
Dict: fields.Dict,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Functions to handle conversion of common Python types into serialisable Python types
|
||||||
|
|
||||||
|
|
||||||
|
def ndarray_to_list(o):
|
||||||
|
return o.tolist()
|
||||||
|
|
||||||
|
|
||||||
|
def to_int(o):
|
||||||
|
return int(o)
|
||||||
|
|
||||||
|
|
||||||
|
def to_float(o):
|
||||||
|
return float(o)
|
||||||
|
|
||||||
|
|
||||||
|
def to_string(o):
|
||||||
|
return str(o)
|
||||||
|
|
||||||
|
|
||||||
|
# Map of Python type conversions
|
||||||
|
DEFAULT_BUILTIN_CONVERSIONS = {
|
||||||
|
"numpy.ndarray": ndarray_to_list,
|
||||||
|
"numpy.int": to_int,
|
||||||
|
"fractions.Fraction": to_float,
|
||||||
|
}
|
||||||
|
|
||||||
|
# TODO: Deserialiser with inverse defaults
|
||||||
|
# TODO: Option to switch to .npy serialisation/deserialisation (or look for a better common array format)
|
||||||
|
|
||||||
|
# Use with [x.__module__+"."+x.__name__ for x in inspect.getmro(type(POSSIBLE_MATCHER))]
|
||||||
|
# Resulting array will contain strings with the same format as keys in DEFAULT_BUILTIN_CONVERSIONS
|
||||||
29
openflexure_microscope/common/flask_labthings/utilities.py
Normal file
29
openflexure_microscope/common/flask_labthings/utilities.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
from openflexure_microscope.common.labthings_core.utilities import (
|
||||||
|
get_docstring,
|
||||||
|
get_summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .view import View
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
|
||||||
|
def description_from_view(view_class):
|
||||||
|
summary = get_summary(view_class)
|
||||||
|
|
||||||
|
methods = []
|
||||||
|
for method_key in View.methods:
|
||||||
|
if hasattr(view_class, method_key):
|
||||||
|
methods.append(method_key.upper())
|
||||||
|
|
||||||
|
# If no class summary was given, try using summaries from method functions
|
||||||
|
if not summary:
|
||||||
|
summary = get_summary(getattr(view_class, method_key))
|
||||||
|
|
||||||
|
d = {"methods": methods, "description": summary}
|
||||||
|
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def view_class_from_endpoint(endpoint: str):
|
||||||
|
return current_app.view_functions[endpoint].view_class
|
||||||
27
openflexure_microscope/common/flask_labthings/view.py
Normal file
27
openflexure_microscope/common/flask_labthings/view.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
from flask.views import MethodView
|
||||||
|
|
||||||
|
|
||||||
|
class View(MethodView):
|
||||||
|
"""
|
||||||
|
A LabThing Resource class should make use of functions get(), put(), post(), and delete()
|
||||||
|
corresponding to HTTP methods.
|
||||||
|
|
||||||
|
These functions will allow for automated documentation generation
|
||||||
|
"""
|
||||||
|
|
||||||
|
methods = ["get", "post", "put", "delete"]
|
||||||
|
endpoint = None
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
MethodView.__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
|
def doc(self):
|
||||||
|
docs = {"operations": {}}
|
||||||
|
if hasattr(self, "__apispec__"):
|
||||||
|
docs.update(self.__apispec__)
|
||||||
|
|
||||||
|
for meth in View.methods:
|
||||||
|
if hasattr(self, meth) and hasattr(getattr(self, meth), "__apispec__"):
|
||||||
|
docs["operations"][meth] = {}
|
||||||
|
docs["operations"][meth] = getattr(self, meth).__apispec__
|
||||||
|
return docs
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
from flask import abort, url_for, jsonify, render_template, Blueprint, current_app, request
|
||||||
|
|
||||||
|
from openflexure_microscope.common.labthings_core.utilities import get_docstring
|
||||||
|
|
||||||
|
from ...view import View
|
||||||
|
from ...find import current_labthing
|
||||||
|
from ...spec import rule_to_path, rule_to_params
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class APISpecView(View):
|
||||||
|
"""
|
||||||
|
OpenAPI v3 documentation
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
OpenAPI v3 documentation
|
||||||
|
"""
|
||||||
|
return jsonify(current_labthing().spec.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
class SwaggerUIView(View):
|
||||||
|
"""
|
||||||
|
Swagger UI documentation
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return render_template("swagger-ui.html")
|
||||||
|
|
||||||
|
|
||||||
|
class W3CThingDescriptionView(View):
|
||||||
|
"""
|
||||||
|
W3C-style Thing Description
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
base_url = request.host_url.rstrip('/')
|
||||||
|
|
||||||
|
props = {}
|
||||||
|
for key, prop in current_labthing().properties.items():
|
||||||
|
prop_rules = current_app.url_map._rules_by_endpoint.get(prop.endpoint)
|
||||||
|
prop_urls = [rule_to_path(rule) for rule in prop_rules]
|
||||||
|
|
||||||
|
props[key] = {}
|
||||||
|
props[key]["title"] = prop.__name__
|
||||||
|
# TODO: Get description from __apispec__ preferentially
|
||||||
|
props[key]["description"] = get_docstring(prop) or (
|
||||||
|
get_docstring(prop.get) if hasattr(prop, "get") else ""
|
||||||
|
)
|
||||||
|
props[key]["readOnly"] = not (
|
||||||
|
hasattr(prop, "post") or hasattr(prop, "put") or hasattr(prop, "delete")
|
||||||
|
)
|
||||||
|
props[key]["writeOnly"] = not hasattr(prop, "get")
|
||||||
|
props[key]["links"] = [
|
||||||
|
{"href": f"{base_url}{url}"} for url in prop_urls
|
||||||
|
]
|
||||||
|
|
||||||
|
props[key]["uriVariables"] = {}
|
||||||
|
for prop_rule in prop_rules:
|
||||||
|
params = rule_to_params(prop_rule)
|
||||||
|
params_dict = {}
|
||||||
|
for param in params:
|
||||||
|
params_dict.update({
|
||||||
|
param.get("name"): {
|
||||||
|
"type": param.get("type")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
props[key]["uriVariables"].update(params_dict)
|
||||||
|
if not props[key]["uriVariables"]:
|
||||||
|
del props[key]["uriVariables"]
|
||||||
|
|
||||||
|
actions = {}
|
||||||
|
for key, action in current_labthing().actions.items():
|
||||||
|
action_rules = current_app.url_map._rules_by_endpoint.get(action.endpoint)
|
||||||
|
action_urls = [rule_to_path(rule) for rule in action_rules]
|
||||||
|
|
||||||
|
actions[key] = {}
|
||||||
|
actions[key]["title"] = action.__name__
|
||||||
|
# TODO: Get description from __apispec__ preferentially
|
||||||
|
actions[key]["description"] = get_docstring(action) or (
|
||||||
|
get_docstring(action.post) if hasattr(action, "post") else ""
|
||||||
|
)
|
||||||
|
actions[key]["links"] = [
|
||||||
|
{"href": f"{base_url}{url}"} for url in action_urls
|
||||||
|
]
|
||||||
|
|
||||||
|
td = {
|
||||||
|
"@context": "https://www.w3.org/2019/wot/td/v1",
|
||||||
|
"id": url_for("labthings_docs.w3c_td", _external=True),
|
||||||
|
"title": current_labthing().title,
|
||||||
|
"description": current_labthing().description,
|
||||||
|
"properties": props,
|
||||||
|
"actions": actions,
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(td)
|
||||||
|
|
||||||
|
|
||||||
|
docs_blueprint = Blueprint(
|
||||||
|
"labthings_docs", __name__, static_folder="./static", template_folder="./templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
docs_blueprint.add_url_rule(
|
||||||
|
"/swagger", view_func=APISpecView.as_view("swagger_json")
|
||||||
|
)
|
||||||
|
docs_blueprint.add_url_rule(
|
||||||
|
"/swagger-ui", view_func=SwaggerUIView.as_view("swagger_ui")
|
||||||
|
)
|
||||||
|
docs_blueprint.add_url_rule(
|
||||||
|
"/td", view_func=W3CThingDescriptionView.as_view("w3c_td")
|
||||||
|
)
|
||||||
BIN
openflexure_microscope/common/flask_labthings/views/docs/static/favicon-16x16.png
vendored
Normal file
BIN
openflexure_microscope/common/flask_labthings/views/docs/static/favicon-16x16.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 665 B |
BIN
openflexure_microscope/common/flask_labthings/views/docs/static/favicon-32x32.png
vendored
Normal file
BIN
openflexure_microscope/common/flask_labthings/views/docs/static/favicon-32x32.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 628 B |
60
openflexure_microscope/common/flask_labthings/views/docs/static/index.html
vendored
Normal file
60
openflexure_microscope/common/flask_labthings/views/docs/static/index.html
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<!-- HTML for static distribution bundle build -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Swagger UI</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" >
|
||||||
|
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
|
||||||
|
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
|
||||||
|
<style>
|
||||||
|
html
|
||||||
|
{
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: -moz-scrollbars-vertical;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*:before,
|
||||||
|
*:after
|
||||||
|
{
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
body
|
||||||
|
{
|
||||||
|
margin:0;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
|
||||||
|
<script src="./swagger-ui-bundle.js"> </script>
|
||||||
|
<script src="./swagger-ui-standalone-preset.js"> </script>
|
||||||
|
<script>
|
||||||
|
window.onload = function() {
|
||||||
|
// Begin Swagger UI call region
|
||||||
|
const ui = SwaggerUIBundle({
|
||||||
|
url: "https://petstore.swagger.io/v2/swagger.json",
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
SwaggerUIBundle.plugins.DownloadUrl
|
||||||
|
],
|
||||||
|
layout: "StandaloneLayout"
|
||||||
|
})
|
||||||
|
// End Swagger UI call region
|
||||||
|
|
||||||
|
window.ui = ui
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
68
openflexure_microscope/common/flask_labthings/views/docs/static/oauth2-redirect.html
vendored
Normal file
68
openflexure_microscope/common/flask_labthings/views/docs/static/oauth2-redirect.html
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en-US">
|
||||||
|
<title>Swagger UI: OAuth2 Redirect</title>
|
||||||
|
<body onload="run()">
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
function run () {
|
||||||
|
var oauth2 = window.opener.swaggerUIRedirectOauth2;
|
||||||
|
var sentState = oauth2.state;
|
||||||
|
var redirectUrl = oauth2.redirectUrl;
|
||||||
|
var isValid, qp, arr;
|
||||||
|
|
||||||
|
if (/code|token|error/.test(window.location.hash)) {
|
||||||
|
qp = window.location.hash.substring(1);
|
||||||
|
} else {
|
||||||
|
qp = location.search.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
arr = qp.split("&")
|
||||||
|
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';})
|
||||||
|
qp = qp ? JSON.parse('{' + arr.join() + '}',
|
||||||
|
function (key, value) {
|
||||||
|
return key === "" ? value : decodeURIComponent(value)
|
||||||
|
}
|
||||||
|
) : {}
|
||||||
|
|
||||||
|
isValid = qp.state === sentState
|
||||||
|
|
||||||
|
if ((
|
||||||
|
oauth2.auth.schema.get("flow") === "accessCode"||
|
||||||
|
oauth2.auth.schema.get("flow") === "authorizationCode"
|
||||||
|
) && !oauth2.auth.code) {
|
||||||
|
if (!isValid) {
|
||||||
|
oauth2.errCb({
|
||||||
|
authId: oauth2.auth.name,
|
||||||
|
source: "auth",
|
||||||
|
level: "warning",
|
||||||
|
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qp.code) {
|
||||||
|
delete oauth2.state;
|
||||||
|
oauth2.auth.code = qp.code;
|
||||||
|
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
|
||||||
|
} else {
|
||||||
|
let oauthErrorMsg
|
||||||
|
if (qp.error) {
|
||||||
|
oauthErrorMsg = "["+qp.error+"]: " +
|
||||||
|
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
|
||||||
|
(qp.error_uri ? "More info: "+qp.error_uri : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
oauth2.errCb({
|
||||||
|
authId: oauth2.auth.name,
|
||||||
|
source: "auth",
|
||||||
|
level: "error",
|
||||||
|
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
|
||||||
|
}
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
134
openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui-bundle.js
vendored
Normal file
134
openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui-bundle.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui-bundle.js.map
vendored
Normal file
1
openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui-bundle.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
22
openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui-standalone-preset.js
vendored
Normal file
22
openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui-standalone-preset.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui.css
vendored
Normal file
4
openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui.css.map
vendored
Normal file
1
openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
9
openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui.js
vendored
Normal file
9
openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui.js.map
vendored
Normal file
1
openflexure_microscope/common/flask_labthings/views/docs/static/swagger-ui.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
38
openflexure_microscope/common/flask_labthings/views/docs/templates/swagger-ui.html
vendored
Normal file
38
openflexure_microscope/common/flask_labthings/views/docs/templates/swagger-ui.html
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Swagger UI</title>
|
||||||
|
<link rel="icon" type="image/png" href="{{ url_for('labthings_docs.static', filename='favicon-32x32.png') }}"
|
||||||
|
sizes="32x32" />
|
||||||
|
<link rel="icon" type="image/png" href="{{ url_for('labthings_docs.static', filename='favicon-16x16.png') }}"
|
||||||
|
sizes="16x16" />
|
||||||
|
<link href="{{ url_for('labthings_docs.static', filename='swagger-ui.css') }}" rel="stylesheet" type="text/css" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="swagger-section">
|
||||||
|
<div id="message-bar" class="swagger-ui-wrap" data-sw-translate> </div>
|
||||||
|
<div id="swagger-ui-container" class="swagger-ui-wrap"></div>
|
||||||
|
<script src="{{ url_for('labthings_docs.static', filename='swagger-ui-bundle.js') }}" type="text/javascript"></script>
|
||||||
|
<script src="{{ url_for('labthings_docs.static', filename='swagger-ui-standalone-preset.js') }}"
|
||||||
|
type="text/javascript"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var ui = SwaggerUIBundle({
|
||||||
|
url: "{{ url_for('labthings_docs.swagger_json') }}",
|
||||||
|
dom_id: '#swagger-ui-container',
|
||||||
|
deepLinking: true,
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
SwaggerUIBundle.plugins.DownloadUrl
|
||||||
|
],
|
||||||
|
layout: "BaseLayout"
|
||||||
|
})
|
||||||
|
window.ui = ui
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""
|
||||||
|
Top-level representation of attached and enabled Extensions
|
||||||
|
"""
|
||||||
|
from ..view import View
|
||||||
|
from ..find import registered_extensions
|
||||||
|
from ..schema import ExtensionSchema
|
||||||
|
from ..decorators import marshal_with, ThingProperty
|
||||||
|
|
||||||
|
|
||||||
|
@ThingProperty
|
||||||
|
class ExtensionList(View):
|
||||||
|
"""
|
||||||
|
List and basic documentation for all enabled Extensions
|
||||||
|
"""
|
||||||
|
|
||||||
|
@marshal_with(ExtensionSchema(many=True))
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
Return the current Extension forms
|
||||||
|
|
||||||
|
Returns an array of present Extension forms (describing Extension user interfaces.)
|
||||||
|
Please note, this is *not* a list of all enabled Extensions, only those with associated
|
||||||
|
user interface forms.
|
||||||
|
|
||||||
|
A complete list of enabled Extensions can be found in the microscope state.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return registered_extensions().values()
|
||||||
51
openflexure_microscope/common/flask_labthings/views/tasks.py
Normal file
51
openflexure_microscope/common/flask_labthings/views/tasks.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
from flask import abort, url_for
|
||||||
|
|
||||||
|
from ..decorators import marshal_with, ThingProperty, Tag
|
||||||
|
from ..view import View
|
||||||
|
from ..schema import TaskSchema
|
||||||
|
|
||||||
|
from openflexure_microscope.common.labthings_core import tasks
|
||||||
|
|
||||||
|
|
||||||
|
@ThingProperty
|
||||||
|
@Tag("tasks")
|
||||||
|
class TaskList(View):
|
||||||
|
@marshal_with(TaskSchema(many=True))
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
List of all session tasks
|
||||||
|
"""
|
||||||
|
return tasks.tasks()
|
||||||
|
|
||||||
|
|
||||||
|
@Tag(["properties", "tasks"])
|
||||||
|
class TaskView(View):
|
||||||
|
@marshal_with(TaskSchema())
|
||||||
|
def get(self, id):
|
||||||
|
"""
|
||||||
|
Show status of a session task
|
||||||
|
|
||||||
|
Includes progress and intermediate data.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
task = tasks.dict()[id]
|
||||||
|
except KeyError:
|
||||||
|
return abort(404) # 404 Not Found
|
||||||
|
|
||||||
|
return task
|
||||||
|
|
||||||
|
@marshal_with(TaskSchema())
|
||||||
|
def delete(self, id):
|
||||||
|
"""
|
||||||
|
Terminate a running task.
|
||||||
|
|
||||||
|
If the task is finished, deletes its entry.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
task = tasks.dict()[id]
|
||||||
|
except KeyError:
|
||||||
|
return abort(404) # 404 Not Found
|
||||||
|
|
||||||
|
task.terminate()
|
||||||
|
|
||||||
|
return task
|
||||||
0
openflexure_microscope/common/labthings_core/__init__.py
Normal file
0
openflexure_microscope/common/labthings_core/__init__.py
Normal file
|
|
@ -1,6 +1,7 @@
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"taskify",
|
"taskify",
|
||||||
"tasks",
|
"tasks",
|
||||||
|
"dict",
|
||||||
"states",
|
"states",
|
||||||
"current_task",
|
"current_task",
|
||||||
"update_task_progress",
|
"update_task_progress",
|
||||||
|
|
@ -12,6 +13,7 @@ __all__ = [
|
||||||
|
|
||||||
from .pool import (
|
from .pool import (
|
||||||
tasks,
|
tasks,
|
||||||
|
dict,
|
||||||
states,
|
states,
|
||||||
current_task,
|
current_task,
|
||||||
update_task_progress,
|
update_task_progress,
|
||||||
|
|
@ -4,6 +4,8 @@ from functools import wraps
|
||||||
|
|
||||||
from .thread import TaskThread
|
from .thread import TaskThread
|
||||||
|
|
||||||
|
from flask import copy_current_request_context
|
||||||
|
|
||||||
|
|
||||||
class TaskMaster:
|
class TaskMaster:
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
@ -11,11 +13,19 @@ class TaskMaster:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tasks(self):
|
def tasks(self):
|
||||||
|
"""
|
||||||
|
Returns:
|
||||||
|
list: List of TaskThread objects.
|
||||||
|
"""
|
||||||
|
return self._tasks
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dict(self):
|
||||||
"""
|
"""
|
||||||
Returns:
|
Returns:
|
||||||
dict: Dictionary of TaskThread objects. Key is TaskThread ID.
|
dict: Dictionary of TaskThread objects. Key is TaskThread ID.
|
||||||
"""
|
"""
|
||||||
return {t.id: t for t in self._tasks}
|
return {str(t.id): t for t in self._tasks}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def states(self):
|
def states(self):
|
||||||
|
|
@ -23,10 +33,13 @@ class TaskMaster:
|
||||||
Returns:
|
Returns:
|
||||||
dict: Dictionary of TaskThread.state dictionaries. Key is TaskThread ID.
|
dict: Dictionary of TaskThread.state dictionaries. Key is TaskThread ID.
|
||||||
"""
|
"""
|
||||||
return {t.id: t.state for t in self._tasks}
|
return {str(t.id): t.state for t in self._tasks}
|
||||||
|
|
||||||
def new(self, f, *args, **kwargs):
|
def new(self, f, *args, **kwargs):
|
||||||
task = TaskThread(target=f, args=args, kwargs=kwargs)
|
# copy_current_request_context allows threads to access flask current_app
|
||||||
|
task = TaskThread(
|
||||||
|
target=copy_current_request_context(f), args=args, kwargs=kwargs
|
||||||
|
)
|
||||||
self._tasks.append(task)
|
self._tasks.append(task)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
@ -45,13 +58,23 @@ class TaskMaster:
|
||||||
|
|
||||||
|
|
||||||
def tasks():
|
def tasks():
|
||||||
|
"""
|
||||||
|
List of tasks in default taskmaster
|
||||||
|
Returns:
|
||||||
|
list: List of tasks in default taskmaster
|
||||||
|
"""
|
||||||
|
global _default_task_master
|
||||||
|
return _default_task_master.tasks
|
||||||
|
|
||||||
|
|
||||||
|
def dict():
|
||||||
"""
|
"""
|
||||||
Dictionary of tasks in default taskmaster
|
Dictionary of tasks in default taskmaster
|
||||||
Returns:
|
Returns:
|
||||||
dict: Dictionary of tasks in default taskmaster
|
dict: Dictionary of tasks in default taskmaster
|
||||||
"""
|
"""
|
||||||
global _default_task_master
|
global _default_task_master
|
||||||
return _default_task_master.tasks
|
return _default_task_master.dict
|
||||||
|
|
||||||
|
|
||||||
def states():
|
def states():
|
||||||
|
|
@ -26,7 +26,7 @@ class TaskThread(threading.Thread):
|
||||||
)
|
)
|
||||||
|
|
||||||
# A UUID for the TaskThread (not the same as the threading.Thread ident)
|
# A UUID for the TaskThread (not the same as the threading.Thread ident)
|
||||||
self._ID = uuid.uuid4().hex # Task ID
|
self._ID = uuid.uuid4() # Task ID
|
||||||
|
|
||||||
# Make _target, _args, and _kwargs available to the subclass
|
# Make _target, _args, and _kwargs available to the subclass
|
||||||
self._target = target
|
self._target = target
|
||||||
71
openflexure_microscope/common/labthings_core/utilities.py
Normal file
71
openflexure_microscope/common/labthings_core/utilities.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import collections.abc
|
||||||
|
import re
|
||||||
|
import base64
|
||||||
|
import operator
|
||||||
|
from functools import reduce
|
||||||
|
|
||||||
|
|
||||||
|
def get_docstring(obj):
|
||||||
|
ds = obj.__doc__
|
||||||
|
if ds:
|
||||||
|
return ds.strip()
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_summary(obj):
|
||||||
|
return get_docstring(obj).partition("\n")[0].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def rupdate(d, u):
|
||||||
|
for k, v in u.items():
|
||||||
|
# Merge lists if they're present in both objects
|
||||||
|
if isinstance(v, list):
|
||||||
|
if not k in d:
|
||||||
|
d[k] = []
|
||||||
|
if isinstance(d[k], list):
|
||||||
|
d[k].extend(v)
|
||||||
|
# Recursively merge dictionaries if the element is a dictionary
|
||||||
|
elif isinstance(v, collections.abc.Mapping):
|
||||||
|
if not k in d:
|
||||||
|
d[k] = {}
|
||||||
|
d[k] = rupdate(d.get(k, {}), v)
|
||||||
|
# If not a list or dictionary, overwrite old value with new value
|
||||||
|
else:
|
||||||
|
d[k] = v
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_path(root, items):
|
||||||
|
"""Access a nested object in root by item sequence."""
|
||||||
|
return reduce(operator.getitem, items, root)
|
||||||
|
|
||||||
|
|
||||||
|
def set_by_path(root, items, value):
|
||||||
|
"""Set a value in a nested object in root by item sequence."""
|
||||||
|
get_by_path(root, items[:-1])[items[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
|
def create_from_path(items):
|
||||||
|
tree_dict = {}
|
||||||
|
for key in reversed(items):
|
||||||
|
tree_dict = {key: tree_dict}
|
||||||
|
return tree_dict
|
||||||
|
|
||||||
|
|
||||||
|
def bottom_level_name(obj):
|
||||||
|
return obj.__name__.split(".")[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def camel_to_snake(name):
|
||||||
|
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
|
||||||
|
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def camel_to_spine(name):
|
||||||
|
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1-\2", name)
|
||||||
|
return re.sub("([a-z0-9])([A-Z])", r"\1-\2", s1).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def snake_to_spine(name):
|
||||||
|
return name.replace("_", "-")
|
||||||
|
|
@ -3,6 +3,7 @@ import os
|
||||||
import errno
|
import errno
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
from uuid import UUID
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
|
|
||||||
|
|
@ -23,8 +24,10 @@ class JSONEncoder(json.JSONEncoder):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def default(self, o, markers=None):
|
def default(self, o, markers=None):
|
||||||
|
if isinstance(o, UUID):
|
||||||
|
return str(o)
|
||||||
# PiCamera fractions
|
# PiCamera fractions
|
||||||
if isinstance(o, Fraction):
|
elif isinstance(o, Fraction):
|
||||||
return float(o)
|
return float(o)
|
||||||
# Numpy integers
|
# Numpy integers
|
||||||
elif isinstance(o, np.integer):
|
elif isinstance(o, np.integer):
|
||||||
|
|
@ -54,17 +57,11 @@ class OpenflexureSettingsFile:
|
||||||
expand (bool): Expand paths to valid auxillary config files.
|
expand (bool): Expand paths to valid auxillary config files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config_path: str = None, expand: bool = True):
|
def __init__(self, config_path: str = None):
|
||||||
global DEFAULT_CONFIG, USER_CONFIG_FILE_PATH
|
global DEFAULT_CONFIG, USER_CONFIG_FILE_PATH
|
||||||
|
|
||||||
self.expandable_keys = {
|
|
||||||
"camera_settings": None,
|
|
||||||
"stage_settings": None,
|
|
||||||
} #: Dictionary of keys that can be passed as a file path string and expanded automatically
|
|
||||||
|
|
||||||
# Set arguments
|
# Set arguments
|
||||||
self.config_path = config_path or USER_CONFIG_FILE_PATH
|
self.config_path = config_path or USER_CONFIG_FILE_PATH
|
||||||
self.expand = expand
|
|
||||||
|
|
||||||
# Initialise basic config file with defaults if it doesn't exist
|
# Initialise basic config file with defaults if it doesn't exist
|
||||||
initialise_file(self.config_path, populate=DEFAULT_CONFIG)
|
initialise_file(self.config_path, populate=DEFAULT_CONFIG)
|
||||||
|
|
@ -76,11 +73,6 @@ class OpenflexureSettingsFile:
|
||||||
# Unexpanded config dictionary (used at load/save time)
|
# Unexpanded config dictionary (used at load/save time)
|
||||||
loaded_config = load_json_file(self.config_path)
|
loaded_config = load_json_file(self.config_path)
|
||||||
|
|
||||||
# If the loaded config is in contracted format
|
|
||||||
if self.expand:
|
|
||||||
# Expand self.raw_config into self._config
|
|
||||||
loaded_config = self.expand_config(loaded_config)
|
|
||||||
|
|
||||||
logging.debug("Reading settings from disk")
|
logging.debug("Reading settings from disk")
|
||||||
return loaded_config
|
return loaded_config
|
||||||
|
|
||||||
|
|
@ -88,12 +80,8 @@ class OpenflexureSettingsFile:
|
||||||
"""
|
"""
|
||||||
Save config to a file on-disk, and splits into auxillary config files if available.
|
Save config to a file on-disk, and splits into auxillary config files if available.
|
||||||
"""
|
"""
|
||||||
# If the loaded config was in contracted format
|
|
||||||
if self.expand:
|
save_settings = config
|
||||||
# Contract self._config into self.raw_config
|
|
||||||
save_settings = self.contract_config(config)
|
|
||||||
else:
|
|
||||||
save_settings = config
|
|
||||||
|
|
||||||
if backup:
|
if backup:
|
||||||
if os.path.isfile(self.config_path):
|
if os.path.isfile(self.config_path):
|
||||||
|
|
@ -109,64 +97,6 @@ class OpenflexureSettingsFile:
|
||||||
|
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
def expand_config(self, config_dict):
|
|
||||||
"""
|
|
||||||
Search a config dictionary for paths to valid auxillary config files,
|
|
||||||
and expand into the config dictionary if available.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config_dict (dict): Dictionary of config data to expand
|
|
||||||
"""
|
|
||||||
return_config = {}
|
|
||||||
if not config_dict:
|
|
||||||
config_dict = {}
|
|
||||||
# For each value in the raw loaded config
|
|
||||||
for key, value in config_dict.items():
|
|
||||||
# If it's a valid expandable parameter
|
|
||||||
if key in self.expandable_keys and type(value) is str:
|
|
||||||
|
|
||||||
logging.debug("Expanding {}".format(value))
|
|
||||||
|
|
||||||
# Store expansion path
|
|
||||||
self.expandable_keys[key] = config_dict[key]
|
|
||||||
# Create the expansion file if it doesn't yet exist
|
|
||||||
initialise_file(value)
|
|
||||||
# Load the expansion file into _config
|
|
||||||
return_config[key] = load_json_file(value) or {}
|
|
||||||
else:
|
|
||||||
return_config[key] = value
|
|
||||||
|
|
||||||
return return_config
|
|
||||||
|
|
||||||
def contract_config(self, config_dict):
|
|
||||||
"""
|
|
||||||
Split a config dictionary into auxillary config files, if available.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config_dict (dict): Dictionary of config data to contract/split
|
|
||||||
"""
|
|
||||||
return_config = {}
|
|
||||||
# For each value in the expanded config
|
|
||||||
for key, value in config_dict.items():
|
|
||||||
# If it's a valid expandable parameter
|
|
||||||
if (
|
|
||||||
key in self.expandable_keys
|
|
||||||
and self.expandable_keys[key] is not None
|
|
||||||
and type(value) is dict
|
|
||||||
):
|
|
||||||
|
|
||||||
logging.debug("Saving to {}".format(self.expandable_keys[key]))
|
|
||||||
# Create the file if it doesn't exist
|
|
||||||
initialise_file(self.expandable_keys[key])
|
|
||||||
# Save the expanded config dictionary to the file
|
|
||||||
save_json_file(self.expandable_keys[key], value)
|
|
||||||
# Replace the expanded dictionary with a file path
|
|
||||||
return_config[key] = self.expandable_keys[key]
|
|
||||||
else:
|
|
||||||
return_config[key] = value
|
|
||||||
|
|
||||||
return return_config
|
|
||||||
|
|
||||||
|
|
||||||
# HANDLE BASIC LOADING AND SAVING OF SETTINGS FILES
|
# HANDLE BASIC LOADING AND SAVING OF SETTINGS FILES
|
||||||
|
|
||||||
|
|
@ -255,10 +185,11 @@ DEFAULT_CONFIG_FILE_PATH = os.path.join(HERE, "microscope_settings.default.json"
|
||||||
|
|
||||||
USER_CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".openflexure")
|
USER_CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".openflexure")
|
||||||
USER_CONFIG_FILE_PATH = os.path.join(USER_CONFIG_DIR, "microscope_settings.json")
|
USER_CONFIG_FILE_PATH = os.path.join(USER_CONFIG_DIR, "microscope_settings.json")
|
||||||
|
USER_EXTENSIONS_PATH = os.path.join(USER_CONFIG_DIR, "microscope_extensions")
|
||||||
|
|
||||||
# Load the default config
|
# Load the default config
|
||||||
with open(DEFAULT_CONFIG_FILE_PATH, "r") as default_rc:
|
with open(DEFAULT_CONFIG_FILE_PATH, "r") as default_rc:
|
||||||
DEFAULT_CONFIG = default_rc.read()
|
DEFAULT_CONFIG = default_rc.read()
|
||||||
|
|
||||||
# Create the default user settings object
|
# Create the default user settings object
|
||||||
user_settings = OpenflexureSettingsFile(config_path=USER_CONFIG_FILE_PATH, expand=True)
|
user_settings = OpenflexureSettingsFile(config_path=USER_CONFIG_FILE_PATH)
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,10 @@ Here we include some classes used frequently in plugin development,
|
||||||
as well as some Flask imports to simplify API route development
|
as well as some Flask imports to simplify API route development
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Plugin classes
|
|
||||||
from openflexure_microscope.plugins import MicroscopePlugin
|
|
||||||
from openflexure_microscope.api.views import MicroscopeViewPlugin
|
|
||||||
from openflexure_microscope.api.utilities import JsonResponse
|
from openflexure_microscope.api.utilities import JsonResponse
|
||||||
|
|
||||||
# Task management
|
# Task management
|
||||||
from openflexure_microscope.common.tasks import (
|
from openflexure_microscope.common.labthings_core.tasks import (
|
||||||
current_task,
|
current_task,
|
||||||
update_task_progress,
|
update_task_progress,
|
||||||
update_task_data,
|
update_task_data,
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,10 @@ from openflexure_microscope.camera.base import BaseCamera
|
||||||
from openflexure_microscope.camera.mock import MockStreamer
|
from openflexure_microscope.camera.mock import MockStreamer
|
||||||
|
|
||||||
from openflexure_microscope.utilities import serialise_array_b64
|
from openflexure_microscope.utilities import serialise_array_b64
|
||||||
from openflexure_microscope.plugins import PluginLoader
|
|
||||||
from openflexure_microscope.task import TaskOrchestrator
|
|
||||||
from openflexure_microscope.common.lock import CompositeLock
|
|
||||||
from openflexure_microscope.config import user_settings
|
from openflexure_microscope.config import user_settings
|
||||||
|
|
||||||
|
from openflexure_microscope.common.labthings_core.lock import CompositeLock
|
||||||
|
|
||||||
|
|
||||||
class Microscope:
|
class Microscope:
|
||||||
"""
|
"""
|
||||||
|
|
@ -31,31 +30,22 @@ class Microscope:
|
||||||
stage (:py:class:`openflexure_microscope.stage.base.BaseStage`): Stage object
|
stage (:py:class:`openflexure_microscope.stage.base.BaseStage`): Stage object
|
||||||
task: (:py:class:`openflexure_microscope.task.TaskOrchestrator`): Threaded ask orchestrator for managing
|
task: (:py:class:`openflexure_microscope.task.TaskOrchestrator`): Threaded ask orchestrator for managing
|
||||||
background tasks using microscope hardware
|
background tasks using microscope hardware
|
||||||
plugins (:py:class:`openflexure_microscope.plugins.PluginLoader`): Mounting point for all microscope plugins
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Initial attributes
|
# Initial attributes
|
||||||
self.id = uuid.uuid4().hex
|
self.id = uuid.uuid4()
|
||||||
self.name = self.id
|
self.name = self.id
|
||||||
self.fov = [0, 0]
|
self.fov = [0, 0]
|
||||||
self.plugin_maps = []
|
|
||||||
self.camera = None
|
self.camera = None
|
||||||
self.stage = None
|
self.stage = None
|
||||||
|
|
||||||
# Initialise with an empty composite lock
|
# Initialise with an empty composite lock
|
||||||
self.lock = CompositeLock([])
|
self.lock = CompositeLock([])
|
||||||
|
|
||||||
# Create a task orchestrator
|
|
||||||
self.task = TaskOrchestrator()
|
|
||||||
|
|
||||||
# Apply settings loaded from file
|
# Apply settings loaded from file
|
||||||
self.apply_settings(user_settings.load())
|
self.apply_settings(user_settings.load())
|
||||||
|
|
||||||
# Create plugin mount-point and attach plugins from maps
|
|
||||||
self.plugins = PluginLoader(self)
|
|
||||||
self.attach_plugins(self.plugin_maps)
|
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
"""Create microscope on context enter."""
|
"""Create microscope on context enter."""
|
||||||
return self
|
return self
|
||||||
|
|
@ -120,25 +110,6 @@ class Microscope:
|
||||||
logging.info("Reapplying settings to newly attached devices")
|
logging.info("Reapplying settings to newly attached devices")
|
||||||
self.apply_settings(settings_full)
|
self.apply_settings(settings_full)
|
||||||
|
|
||||||
def attach_plugins(self, plugin_maps: list):
|
|
||||||
"""
|
|
||||||
Automatically search for plugin maps in config, and attach.
|
|
||||||
"""
|
|
||||||
if plugin_maps:
|
|
||||||
for plugin_map in plugin_maps:
|
|
||||||
self.plugins.attach(plugin_map)
|
|
||||||
else:
|
|
||||||
logging.warning("No plugins specified. Skipping.")
|
|
||||||
|
|
||||||
def reload_plugins(self):
|
|
||||||
"""
|
|
||||||
Empty the plugin mount and re-attach from config.
|
|
||||||
"""
|
|
||||||
logging.info("Tearing down existing PluginMount...")
|
|
||||||
self.plugins = PluginLoader(self)
|
|
||||||
logging.info("Repopulating PluginMount...")
|
|
||||||
self.attach_plugins(self.plugin_maps)
|
|
||||||
|
|
||||||
def has_real_stage(self):
|
def has_real_stage(self):
|
||||||
if hasattr(self, "stage") and not isinstance(self.stage, MockStage):
|
if hasattr(self, "stage") and not isinstance(self.stage, MockStage):
|
||||||
return True
|
return True
|
||||||
|
|
@ -151,27 +122,6 @@ class Microscope:
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Create unified state
|
|
||||||
@property
|
|
||||||
def state(self):
|
|
||||||
"""Dictionary of the basic microscope state.
|
|
||||||
|
|
||||||
Return:
|
|
||||||
dict: Dictionary containing position data,
|
|
||||||
and :py:attr:`openflexure_microscope.camera.base.BaseCamera.status`
|
|
||||||
"""
|
|
||||||
# DEPRECATED
|
|
||||||
logging.warning(
|
|
||||||
"Microscope.state is deprecated. Use Microscope.status instead. State will be removed in a future version."
|
|
||||||
)
|
|
||||||
state = {
|
|
||||||
"camera": self.camera.status,
|
|
||||||
"stage": self.stage.status,
|
|
||||||
"plugin": self.plugins.state,
|
|
||||||
"version": pkg_resources.get_distribution("openflexure_microscope").version,
|
|
||||||
}
|
|
||||||
return state
|
|
||||||
|
|
||||||
# Create unified status
|
# Create unified status
|
||||||
@property
|
@property
|
||||||
def status(self):
|
def status(self):
|
||||||
|
|
@ -208,8 +158,6 @@ class Microscope:
|
||||||
self.name = config["name"]
|
self.name = config["name"]
|
||||||
if "fov" in config:
|
if "fov" in config:
|
||||||
self.fov = config["fov"]
|
self.fov = config["fov"]
|
||||||
if "plugins" in config:
|
|
||||||
self.plugin_maps = config["plugins"]
|
|
||||||
|
|
||||||
def read_settings(self, json_safe=False):
|
def read_settings(self, json_safe=False):
|
||||||
"""
|
"""
|
||||||
|
|
@ -222,12 +170,7 @@ class Microscope:
|
||||||
don't get removed from the settings file.
|
don't get removed from the settings file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
settings_current = {
|
settings_current = {"id": self.id, "name": self.name, "fov": self.fov}
|
||||||
"id": self.id,
|
|
||||||
"name": self.name,
|
|
||||||
"fov": self.fov,
|
|
||||||
"plugins": self.plugin_maps,
|
|
||||||
}
|
|
||||||
|
|
||||||
# If attached to a camera
|
# If attached to a camera
|
||||||
if self.camera:
|
if self.camera:
|
||||||
|
|
@ -256,22 +199,6 @@ class Microscope:
|
||||||
self.stage.save_settings()
|
self.stage.save_settings()
|
||||||
user_settings.save(current_config, backup=True)
|
user_settings.save(current_config, backup=True)
|
||||||
|
|
||||||
@property
|
|
||||||
def config(self) -> dict:
|
|
||||||
logging.warning(
|
|
||||||
"Reading microscope through config property is deprecated.\
|
|
||||||
Please use read_settings method instead."
|
|
||||||
)
|
|
||||||
return self.read_settings()
|
|
||||||
|
|
||||||
@config.setter
|
|
||||||
def config(self, config: dict) -> None:
|
|
||||||
logging.warning(
|
|
||||||
"Setting microscope through config property is deprecated.\
|
|
||||||
Please use apply_settings method instead."
|
|
||||||
)
|
|
||||||
self.apply_settings(config)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def metadata(self):
|
def metadata(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -279,7 +206,7 @@ class Microscope:
|
||||||
"""
|
"""
|
||||||
system_metadata = {
|
system_metadata = {
|
||||||
"microscope_settings": self.read_settings(),
|
"microscope_settings": self.read_settings(),
|
||||||
"microscope_state": self.state,
|
"microscope_state": self.status,
|
||||||
"microscope_id": self.id,
|
"microscope_id": self.id,
|
||||||
"microscope_name": self.name,
|
"microscope_name": self.name,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,5 @@
|
||||||
4100,
|
4100,
|
||||||
3146
|
3146
|
||||||
],
|
],
|
||||||
"jpeg_quality": 75,
|
"jpeg_quality": 75
|
||||||
"camera_settings": "~/.openflexure/camera_settings.json",
|
|
||||||
"stage_settings": "~/.openflexure/stage_settings.json",
|
|
||||||
"plugins": [
|
|
||||||
"openflexure_microscope.plugins.default.autofocus:AutofocusPlugin",
|
|
||||||
"openflexure_microscope.plugins.default.scan:ScanPlugin",
|
|
||||||
"openflexure_microscope.plugins.default.camera_calibration:AutocalibrationPlugin",
|
|
||||||
"openflexure_microscope.plugins.default.zip_builder:ZipBuilderPlugin"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
__all__ = [
|
|
||||||
"module_from_file",
|
|
||||||
"load_plugin_class",
|
|
||||||
"load_plugin_module",
|
|
||||||
"class_from_map",
|
|
||||||
"PluginLoader",
|
|
||||||
"MicroscopePlugin",
|
|
||||||
]
|
|
||||||
|
|
||||||
from .loader import (
|
|
||||||
module_from_file,
|
|
||||||
load_plugin_class,
|
|
||||||
load_plugin_module,
|
|
||||||
class_from_map,
|
|
||||||
PluginLoader,
|
|
||||||
MicroscopePlugin,
|
|
||||||
)
|
|
||||||
|
|
@ -134,7 +134,7 @@ class ScanPlugin(MicroscopePlugin):
|
||||||
basename = generate_basename()
|
basename = generate_basename()
|
||||||
|
|
||||||
# Generate a stack ID
|
# Generate a stack ID
|
||||||
scan_id = uuid.uuid4().hex
|
scan_id = uuid.uuid4()
|
||||||
|
|
||||||
# Store initial position
|
# Store initial position
|
||||||
initial_position = self.microscope.stage.position
|
initial_position = self.microscope.stage.position
|
||||||
|
|
@ -288,7 +288,7 @@ class ScanPlugin(MicroscopePlugin):
|
||||||
|
|
||||||
# Generate a stack ID
|
# Generate a stack ID
|
||||||
if not scan_id:
|
if not scan_id:
|
||||||
scan_id = uuid.uuid4().hex
|
scan_id = uuid.uuid4()
|
||||||
|
|
||||||
# Add scan metadata
|
# Add scan metadata
|
||||||
if not "time" in metadata:
|
if not "time" in metadata:
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ class ZipBuilderPlugin(MicroscopePlugin):
|
||||||
# Update task progress
|
# Update task progress
|
||||||
update_task_progress(int((index / n_files) * 100))
|
update_task_progress(int((index / n_files) * 100))
|
||||||
|
|
||||||
session_id = uuid.uuid4().hex
|
session_id = uuid.uuid4()
|
||||||
# self.session_zips[session_id] = fp
|
# self.session_zips[session_id] = fp
|
||||||
self.session_zips[session_id] = {
|
self.session_zips[session_id] = {
|
||||||
"id": session_id,
|
"id": session_id,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
__all__ = ["Plugin", "HelloWorldAPI", "IdentifyAPI"]
|
|
||||||
|
|
||||||
from .plugin import Plugin
|
|
||||||
from .api import HelloWorldAPI, IdentifyAPI
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
from openflexure_microscope.api.utilities import JsonResponse
|
|
||||||
from openflexure_microscope.api.views import MicroscopeViewPlugin
|
|
||||||
from openflexure_microscope.exceptions import TaskDeniedException
|
|
||||||
|
|
||||||
from flask import request, Response, escape, jsonify, abort
|
|
||||||
|
|
||||||
|
|
||||||
class IdentifyAPI(MicroscopeViewPlugin):
|
|
||||||
"""
|
|
||||||
A simple example API plugin, attached through the main microscope plugin.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
Method to call when an HTTP GET request is made.
|
|
||||||
"""
|
|
||||||
data = (
|
|
||||||
self.plugin.identify()
|
|
||||||
) # Call a method from our plugin, using the MicroscopeViewPlugin.plugin shortcut
|
|
||||||
return Response(escape(data))
|
|
||||||
|
|
||||||
|
|
||||||
class HelloWorldAPI(MicroscopeViewPlugin):
|
|
||||||
"""
|
|
||||||
A method to create, set, and return a new microscope parameter.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get(self):
|
|
||||||
"""
|
|
||||||
Method to call when an HTTP GET request is made.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# If the microscope does not already contain our plugin_string attribute
|
|
||||||
if not hasattr(self.microscope, "plugin_string"):
|
|
||||||
# Make a string, using the MicroscopeViewPlugin.plugin shortcut
|
|
||||||
self.microscope.plugin_string = self.plugin.hello_world()
|
|
||||||
|
|
||||||
return Response(self.microscope.plugin_string)
|
|
||||||
|
|
||||||
def post(self):
|
|
||||||
"""
|
|
||||||
Method to call when an HTTP POST request is made.
|
|
||||||
Assumes request will include a JSON payload.
|
|
||||||
"""
|
|
||||||
# Get payload JSON
|
|
||||||
payload = JsonResponse(request)
|
|
||||||
|
|
||||||
# Extract a value from the JSON key 'plugin_string', and convert to a string. If no value is given, default to empty.
|
|
||||||
new_plugin_string = payload.param("plugin_string", default="", convert=str)
|
|
||||||
|
|
||||||
if new_plugin_string: # If not None or empty
|
|
||||||
# Set microscope attribute to the specified string
|
|
||||||
self.microscope.plugin_string = new_plugin_string
|
|
||||||
|
|
||||||
return Response(self.microscope.plugin_string)
|
|
||||||
|
|
||||||
|
|
||||||
class LongRunningAPI(MicroscopeViewPlugin):
|
|
||||||
"""
|
|
||||||
An example API plugin that uses a long-running plugin method.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def post(self):
|
|
||||||
"""
|
|
||||||
Method to call when an HTTP POST request is made.
|
|
||||||
"""
|
|
||||||
# Get payload JSON
|
|
||||||
payload = JsonResponse(request)
|
|
||||||
|
|
||||||
# Extract a values from the JSON payload.
|
|
||||||
time_to_run = payload.param("time", default=10, convert=int)
|
|
||||||
|
|
||||||
# Attach the long-running method as a microscope task
|
|
||||||
try:
|
|
||||||
task = self.microscope.task.start(self.plugin.long_running, time_to_run)
|
|
||||||
return jsonify(task.state), 201
|
|
||||||
|
|
||||||
except TaskDeniedException:
|
|
||||||
return abort(409)
|
|
||||||
|
|
||||||
|
|
||||||
class SomeExceptionAPI(MicroscopeViewPlugin):
|
|
||||||
"""
|
|
||||||
An example API plugin that uses a long-running but broken plugin method.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def post(self):
|
|
||||||
"""
|
|
||||||
Method to call when an HTTP POST request is made.
|
|
||||||
"""
|
|
||||||
# Get payload JSON
|
|
||||||
payload = JsonResponse(request)
|
|
||||||
|
|
||||||
# Attach the long-running method as a microscope task
|
|
||||||
try:
|
|
||||||
task = self.microscope.task.start(self.plugin.some_exception)
|
|
||||||
return jsonify(task.state), 201
|
|
||||||
|
|
||||||
except TaskDeniedException:
|
|
||||||
return abort(409)
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import random
|
|
||||||
import time
|
|
||||||
from openflexure_microscope.plugins import MicroscopePlugin
|
|
||||||
|
|
||||||
from .api import IdentifyAPI, HelloWorldAPI, LongRunningAPI, SomeExceptionAPI
|
|
||||||
|
|
||||||
|
|
||||||
class Plugin(MicroscopePlugin):
|
|
||||||
"""
|
|
||||||
A set of default plugins
|
|
||||||
"""
|
|
||||||
|
|
||||||
api_views = {
|
|
||||||
"/identify": IdentifyAPI,
|
|
||||||
"/hello": HelloWorldAPI,
|
|
||||||
"/long_running": LongRunningAPI,
|
|
||||||
"/some_exception": SomeExceptionAPI,
|
|
||||||
}
|
|
||||||
|
|
||||||
def identify(self):
|
|
||||||
"""
|
|
||||||
Demonstrate access to Microscope.camera, and Microscope.stage
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = "My parent camera is {}, and my parent stage is {}.".format(
|
|
||||||
self.microscope.camera, self.microscope.stage
|
|
||||||
)
|
|
||||||
print(response)
|
|
||||||
return response
|
|
||||||
|
|
||||||
def hello_world(self):
|
|
||||||
"""
|
|
||||||
Demonstrate passive method
|
|
||||||
"""
|
|
||||||
|
|
||||||
return "Hello world!"
|
|
||||||
|
|
||||||
def some_exception(self):
|
|
||||||
"""
|
|
||||||
Demonstrate some broken plugin method that would be long running
|
|
||||||
"""
|
|
||||||
with self.microscope.lock:
|
|
||||||
time.sleep(3)
|
|
||||||
result = 15.0 / 0
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def long_running(self, t_run):
|
|
||||||
"""
|
|
||||||
Demonstrate a long-running method that requires microscope hardware
|
|
||||||
"""
|
|
||||||
print("Starting a long-running task...")
|
|
||||||
n_array = []
|
|
||||||
|
|
||||||
print("Acquiring composite microscope lock...")
|
|
||||||
with self.microscope.camera.lock, self.microscope.stage.lock:
|
|
||||||
for _ in range(t_run):
|
|
||||||
n_array.append(random.random())
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
print("Long-running task finished! Releasing locks.")
|
|
||||||
|
|
||||||
return n_array
|
|
||||||
|
|
@ -1,349 +0,0 @@
|
||||||
import importlib
|
|
||||||
import copy
|
|
||||||
import os
|
|
||||||
import collections
|
|
||||||
import inspect
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from openflexure_microscope.utilities import camel_to_snake, camel_to_spine
|
|
||||||
from openflexure_microscope.api.views import MicroscopeViewPlugin
|
|
||||||
|
|
||||||
|
|
||||||
class ConColors:
|
|
||||||
HEADER = "\033[95m"
|
|
||||||
OKBLUE = "\033[94m"
|
|
||||||
OKGREEN = "\033[92m"
|
|
||||||
WARNING = "\033[93m"
|
|
||||||
FAIL = "\033[91m"
|
|
||||||
ENDC = "\033[0m"
|
|
||||||
BOLD = "\033[1m"
|
|
||||||
UNDERLINE = "\033[4m"
|
|
||||||
|
|
||||||
|
|
||||||
def module_from_file(plugin_path):
|
|
||||||
# Expand environment variables in path string
|
|
||||||
plugin_path = os.path.expandvars(plugin_path)
|
|
||||||
# Expand user directory in path string
|
|
||||||
plugin_path = os.path.expanduser(plugin_path)
|
|
||||||
|
|
||||||
# Check if the path is to a file
|
|
||||||
if not os.path.isfile(plugin_path):
|
|
||||||
logging.warning(
|
|
||||||
ConColors.FAIL
|
|
||||||
+ "No valid plugin found at {}.".format(plugin_path)
|
|
||||||
+ ConColors.ENDC
|
|
||||||
)
|
|
||||||
return None, None, None
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Get name of plugin from the file
|
|
||||||
plugin_name = os.path.splitext(os.path.basename(plugin_path))[0]
|
|
||||||
|
|
||||||
plugin_spec = importlib.util.spec_from_file_location(plugin_name, plugin_path)
|
|
||||||
plugin_module = importlib.util.module_from_spec(plugin_spec)
|
|
||||||
|
|
||||||
return plugin_spec, plugin_module, plugin_name
|
|
||||||
|
|
||||||
|
|
||||||
def check_module(module_path):
|
|
||||||
"""
|
|
||||||
Used to check if a module exists, without ever importing it.
|
|
||||||
|
|
||||||
This checks each nested level separately to avoid raising exceptions.
|
|
||||||
For example, checking "mymodule.submodule.subsubmodule" will first
|
|
||||||
check if 'mymodule' exists, then if it does, it will check
|
|
||||||
'mymodule.submodule', and so on.
|
|
||||||
"""
|
|
||||||
module_split = module_path.split(".")
|
|
||||||
spec_path = ""
|
|
||||||
|
|
||||||
for module_level in module_split:
|
|
||||||
spec_path += ".{}".format(module_level)
|
|
||||||
spec_path = spec_path.strip(".")
|
|
||||||
|
|
||||||
logging.debug("Checking {}".format(spec_path))
|
|
||||||
|
|
||||||
# Try to find a module loader for the plugin path
|
|
||||||
plugin_spec = importlib.util.find_spec(spec_path)
|
|
||||||
|
|
||||||
# If plugin spec doesn't exist, return False early
|
|
||||||
if plugin_spec is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# If all checks pass, return True
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def name_from_module(plugin_path):
|
|
||||||
path_array = plugin_path.split(".")
|
|
||||||
|
|
||||||
# Truncate namespace of default plugins
|
|
||||||
if path_array[0:2] == ["openflexure_microscope", "plugins"]:
|
|
||||||
path_array = path_array[2:]
|
|
||||||
|
|
||||||
return "/".join(path_array)
|
|
||||||
|
|
||||||
|
|
||||||
def load_plugin_module(plugin_path):
|
|
||||||
# If the loader was found (i.e. plugin probably exists)
|
|
||||||
if check_module(plugin_path):
|
|
||||||
try:
|
|
||||||
plugin_module = importlib.import_module(plugin_path)
|
|
||||||
plugin_name = name_from_module(plugin_path)
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning("Error loading plugin.")
|
|
||||||
logging.error(e)
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
# If no loader was found, try finding a file from path
|
|
||||||
else:
|
|
||||||
plugin_spec, plugin_module, plugin_name = module_from_file(plugin_path)
|
|
||||||
# If a valid plugin was found
|
|
||||||
if plugin_spec and plugin_module:
|
|
||||||
# Execute the module, so we have access to it
|
|
||||||
plugin_spec.loader.exec_module(plugin_module)
|
|
||||||
|
|
||||||
return plugin_module, plugin_name
|
|
||||||
|
|
||||||
|
|
||||||
def load_plugin_class(plugin_path, plugin_class_name):
|
|
||||||
plugin_module, plugin_name = load_plugin_module(plugin_path)
|
|
||||||
if plugin_module:
|
|
||||||
# Now try to extract the class
|
|
||||||
try:
|
|
||||||
plugin_class = getattr(plugin_module, plugin_class_name)
|
|
||||||
except AttributeError:
|
|
||||||
logging.warning(
|
|
||||||
ConColors.FAIL
|
|
||||||
+ "Class {} does not exist in plugin {}. Skipping.".format(
|
|
||||||
plugin_class_name, plugin_path
|
|
||||||
)
|
|
||||||
+ ConColors.ENDC
|
|
||||||
)
|
|
||||||
return None, None
|
|
||||||
else:
|
|
||||||
return plugin_class, plugin_name
|
|
||||||
else:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def class_from_map(plugin_map):
|
|
||||||
plugin_arr = plugin_map.split(":")
|
|
||||||
|
|
||||||
if not len(plugin_arr) == 2:
|
|
||||||
logging.warning(
|
|
||||||
ConColors.WARNING
|
|
||||||
+ "Malformed plugin map {}. Skipping.".format(plugin_map)
|
|
||||||
+ ConColors.ENDC
|
|
||||||
)
|
|
||||||
return None, None
|
|
||||||
else:
|
|
||||||
return load_plugin_class(*plugin_arr)
|
|
||||||
|
|
||||||
|
|
||||||
class PluginLoader(object):
|
|
||||||
"""
|
|
||||||
A mount-point for all loaded plugins. Attaches to a Microscope object.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent (:py:class:`openflexure_microscope.microscope.Microscope`): The parent Microscope object to attach to.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, parent):
|
|
||||||
self.parent = parent
|
|
||||||
self._plugins = [] # List of plugin objects
|
|
||||||
self._legacy_plugins = {} # DEPRECATED: Dictionary of plugins with old names
|
|
||||||
self.forms = [] # List of plugin forms
|
|
||||||
logging.info("Creating plugin mount")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def state(self):
|
|
||||||
# DEPRECATED
|
|
||||||
logging.warning(
|
|
||||||
"PluginMount.state is deprecated. Use PluginMount.active instead. State will be removed in a future version."
|
|
||||||
)
|
|
||||||
return list(self._legacy_plugins.keys())
|
|
||||||
|
|
||||||
@property
|
|
||||||
def active(self):
|
|
||||||
return self._plugins
|
|
||||||
|
|
||||||
def attach(self, plugin_map):
|
|
||||||
"""
|
|
||||||
Attach a MicroscopePlugin instance to the plugin mount.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_map (str): A plugin map describing the file or module to load a MicroscopePlugin child from. Maps should be in the format 'module.to.load:ClassName' or '/path/to/file:ClassName'.
|
|
||||||
"""
|
|
||||||
plugin_class, plugin_name = class_from_map(plugin_map)
|
|
||||||
|
|
||||||
if plugin_class is not None:
|
|
||||||
|
|
||||||
logging.debug(f"Loading plugin class {plugin_class}, {plugin_name}")
|
|
||||||
|
|
||||||
plugin_name_python_safe = plugin_name.replace("/", "_")
|
|
||||||
|
|
||||||
if plugin_class and plugin_name:
|
|
||||||
plugin_object = plugin_class()
|
|
||||||
|
|
||||||
if hasattr(
|
|
||||||
self, plugin_name
|
|
||||||
): # If a plugin with the same name is already attached.
|
|
||||||
logging.warning(
|
|
||||||
ConColors.WARNING
|
|
||||||
+ "A plugin named {} has already been loaded. Skipping {}.".format(
|
|
||||||
plugin_name, plugin_map
|
|
||||||
)
|
|
||||||
+ ConColors.ENDC
|
|
||||||
)
|
|
||||||
|
|
||||||
elif isinstance(
|
|
||||||
plugin_object, BasePlugin
|
|
||||||
): # If plugin_object is an instance of MicroscopePlugin
|
|
||||||
# Attach plugin_object to the plugin mount
|
|
||||||
setattr(self, plugin_name_python_safe, plugin_object)
|
|
||||||
# Store the plugin object, and it's properties
|
|
||||||
self._plugins.append(plugin_object)
|
|
||||||
# DEPRECATED: Store the plugin with it's old name
|
|
||||||
self._legacy_plugins[plugin_name] = plugin_object
|
|
||||||
|
|
||||||
# Grant plugin access to the hardware
|
|
||||||
if isinstance(plugin_object, MicroscopePlugin):
|
|
||||||
plugin_object.microscope = self.parent
|
|
||||||
|
|
||||||
logging.info(
|
|
||||||
ConColors.OKGREEN
|
|
||||||
+ "Plugin {} loaded as {}.".format(
|
|
||||||
plugin_map, plugin_object._name
|
|
||||||
)
|
|
||||||
+ ConColors.ENDC
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
logging.warning(f"Error loading plugin {plugin_map}. Moving on.")
|
|
||||||
|
|
||||||
|
|
||||||
class BasePlugin:
|
|
||||||
"""
|
|
||||||
Parent class for all plugins.
|
|
||||||
|
|
||||||
Handles binding route views and forms.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._views = (
|
|
||||||
{}
|
|
||||||
) # Key: Full, Python-safe ID. Val: Original rule, and view class
|
|
||||||
self._rules = {} # Key: Original rule. Val: View class
|
|
||||||
self._gui = None
|
|
||||||
|
|
||||||
# If old api_views dictionary is found
|
|
||||||
if hasattr(self, "api_views"):
|
|
||||||
# Convert to new format
|
|
||||||
self._convert_old_api_views()
|
|
||||||
# If old api_form dictionary is found
|
|
||||||
if hasattr(self, "api_form"):
|
|
||||||
# Convert to new format
|
|
||||||
self._convert_old_api_form()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def views(self):
|
|
||||||
return self._views
|
|
||||||
|
|
||||||
def add_view(self, rule, view_class, **kwargs):
|
|
||||||
# Remove all leading slashes from view route
|
|
||||||
cleaned_rule = rule
|
|
||||||
while cleaned_rule[0] == "/":
|
|
||||||
cleaned_rule = cleaned_rule[1:]
|
|
||||||
|
|
||||||
# Expand the rule to include plugin name
|
|
||||||
full_rule = "/{}/{}".format(self._name_uri_safe, cleaned_rule)
|
|
||||||
|
|
||||||
view_id = cleaned_rule.replace("/", "_").replace("<", "").replace(">", "")
|
|
||||||
|
|
||||||
# Create a Python-safe route ID
|
|
||||||
logging.debug(view_id)
|
|
||||||
|
|
||||||
# Store route information in a dictionary
|
|
||||||
d = {"rule": full_rule, "view": view_class, "kwargs": kwargs}
|
|
||||||
|
|
||||||
# Add view to private views dictionary
|
|
||||||
self._views[view_id] = d
|
|
||||||
# Store the rule expansion information
|
|
||||||
self._rules[rule] = self._views[view_id]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def gui(self):
|
|
||||||
print(self._gui)
|
|
||||||
# Handle missing/no GUI
|
|
||||||
if not self._gui:
|
|
||||||
return None
|
|
||||||
# Handle GUI as a dictionary-returning callable function
|
|
||||||
elif isinstance(self._gui, collections.Callable):
|
|
||||||
api_gui = self._gui()
|
|
||||||
# Handle GUI as a static dictionary
|
|
||||||
elif isinstance(self._gui, dict):
|
|
||||||
api_gui = copy.deepcopy(self._gui)
|
|
||||||
# Handle borked GUI
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
"GUI must be a dictionary, or a dictionary-returning function with no arguments."
|
|
||||||
)
|
|
||||||
|
|
||||||
api_gui["id"] = self._name
|
|
||||||
|
|
||||||
if "forms" in api_gui and isinstance(api_gui["forms"], list):
|
|
||||||
for form in api_gui["forms"]:
|
|
||||||
if "route" in form and form["route"] in self._rules.keys():
|
|
||||||
form["route"] = self._rules[form["route"]]["rule"]
|
|
||||||
else:
|
|
||||||
logging.warn(
|
|
||||||
"No valid expandable route found for {}".format(form["route"])
|
|
||||||
)
|
|
||||||
return api_gui
|
|
||||||
|
|
||||||
def set_gui(self, form_dictionary: dict):
|
|
||||||
self._gui = form_dictionary
|
|
||||||
|
|
||||||
def _convert_old_api_views(self):
|
|
||||||
for view_route, view_class in self.api_views.items():
|
|
||||||
self.add_view(view_route, view_class)
|
|
||||||
|
|
||||||
def _convert_old_api_form(self):
|
|
||||||
self.set_gui(self.api_form)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _name(self):
|
|
||||||
return self.__class__.__name__
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _name_python_safe(self):
|
|
||||||
return camel_to_snake(self._name)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _name_uri_safe(self):
|
|
||||||
return camel_to_spine(self._name)
|
|
||||||
|
|
||||||
def _full_name(self):
|
|
||||||
module = self.__class__.__module__
|
|
||||||
if module is None or module == str.__class__.__module__:
|
|
||||||
return self.__class__.__name__ # Avoid reporting __builtin__
|
|
||||||
else:
|
|
||||||
return module + "." + self.__class__.__name__
|
|
||||||
|
|
||||||
|
|
||||||
class MicroscopePlugin(BasePlugin):
|
|
||||||
"""
|
|
||||||
Parent class for all microscope plugins.
|
|
||||||
|
|
||||||
Initially only defines an empty object for microscope. All plugins
|
|
||||||
must be an instance of this class to successfully attach to PluginMount.
|
|
||||||
"""
|
|
||||||
|
|
||||||
api_views = {} # Initially empty dictionary of API views associated with the plugin
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
BasePlugin.__init__(self)
|
|
||||||
self.microscope = (
|
|
||||||
None
|
|
||||||
) #: :py:class:`openflexure_microscope.microscope.Microscope`: Microscope object
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
from openflexure_microscope.common.lock import StrictLock
|
from openflexure_microscope.common.labthings_core.lock import StrictLock
|
||||||
|
|
||||||
|
|
||||||
class BaseStage(metaclass=ABCMeta):
|
class BaseStage(metaclass=ABCMeta):
|
||||||
|
|
@ -49,6 +49,14 @@ class BaseStage(metaclass=ABCMeta):
|
||||||
"""The current position, as a list"""
|
"""The current position, as a list"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def position_map(self):
|
||||||
|
return {
|
||||||
|
"x": self.position[0],
|
||||||
|
"y": self.position[1],
|
||||||
|
"z": self.position[2],
|
||||||
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def backlash(self):
|
def backlash(self):
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,10 @@ class MockStage(BaseStage):
|
||||||
def status(self):
|
def status(self):
|
||||||
"""The general status dictionary of the board."""
|
"""The general status dictionary of the board."""
|
||||||
status = {
|
status = {
|
||||||
"position": {
|
"position": self.position_map,
|
||||||
"x": self.position[0],
|
|
||||||
"y": self.position[1],
|
|
||||||
"z": self.position[2],
|
|
||||||
},
|
|
||||||
"board": None,
|
"board": None,
|
||||||
"version": "0",
|
"firmware": None,
|
||||||
|
"version": None
|
||||||
}
|
}
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,7 @@ class SangaStage(BaseStage):
|
||||||
def status(self):
|
def status(self):
|
||||||
"""The general status dictionary of the board."""
|
"""The general status dictionary of the board."""
|
||||||
status = {
|
status = {
|
||||||
"position": {
|
"position": self.position_map,
|
||||||
"x": self.position[0],
|
|
||||||
"y": self.position[1],
|
|
||||||
"z": self.position[2],
|
|
||||||
},
|
|
||||||
"board": self.board.board,
|
"board": self.board.board,
|
||||||
"firmware": self.board.firmware,
|
"firmware": self.board.firmware,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -213,9 +213,9 @@ class ExtensibleSerialInstrument(object):
|
||||||
return self.read_multiline(termination_line)
|
return self.read_multiline(termination_line)
|
||||||
else:
|
else:
|
||||||
logging.debug("Reading response...")
|
logging.debug("Reading response...")
|
||||||
line = (
|
line = self.readline(
|
||||||
self.readline(timeout).strip()
|
timeout
|
||||||
) # question: should we strip the final newline?
|
).strip() # question: should we strip the final newline?
|
||||||
logging.debug(f"Read finished. Got {line}")
|
logging.debug(f"Read finished. Got {line}")
|
||||||
return line
|
return line
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import logging
|
|
||||||
from openflexure_microscope.common.tasks import (
|
|
||||||
tasks,
|
|
||||||
states,
|
|
||||||
taskify,
|
|
||||||
cleanup_tasks,
|
|
||||||
remove_task,
|
|
||||||
)
|
|
||||||
|
|
||||||
# DEPRECATED
|
|
||||||
class TaskOrchestrator:
|
|
||||||
"""
|
|
||||||
DEPRECATED: Class responsible for spawning threaded tasks, and storing their returns.
|
|
||||||
A microscope should contain exactly one instance of `TaskOrchestrator`.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
tasks (list): List of `Task` objects
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tasks(self):
|
|
||||||
logging.warning(
|
|
||||||
"TaskOrchestrator class is deprecated. Please use common.tasks.tasks().values() instead."
|
|
||||||
)
|
|
||||||
return tasks().values()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def state(self):
|
|
||||||
"""
|
|
||||||
Returns a list of dictionary representations of all tasks in the session.
|
|
||||||
"""
|
|
||||||
logging.warning(
|
|
||||||
"TaskOrchestrator class is deprecated. Please use common.tasks.tasks() instead."
|
|
||||||
)
|
|
||||||
return states()
|
|
||||||
|
|
||||||
def task_from_id(self, task_id: str):
|
|
||||||
"""
|
|
||||||
Returns a particular task object with the specified `task_id`.
|
|
||||||
"""
|
|
||||||
return tasks()[task_id]
|
|
||||||
|
|
||||||
def start(self, function, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Attach and start a new long-running task, if allowed by `start_condition()`.
|
|
||||||
Returns an instance of :py:class:`openflexure_microscope.task.Task`, which can be used to check the state
|
|
||||||
or eventual return of the task.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
function (function): The target function to run in the background
|
|
||||||
args, kwargs: Arguments that will be passed to `function` at runtime.
|
|
||||||
"""
|
|
||||||
logging.warning(
|
|
||||||
"TaskOrchestrator class is deprecated. Please use common.tasks.taskify() instead."
|
|
||||||
)
|
|
||||||
return taskify(function)(*args, **kwargs)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
"""
|
|
||||||
Remove all inactive tasks from the task array.
|
|
||||||
This will remove tasks that either finished or never started.
|
|
||||||
"""
|
|
||||||
logging.warning(
|
|
||||||
"TaskOrchestrator class is deprecated. Please use common.tasks.cleanup_tasks() instead."
|
|
||||||
)
|
|
||||||
cleanup_tasks()
|
|
||||||
|
|
||||||
def delete(self, task_id: str):
|
|
||||||
"""
|
|
||||||
Delete a given task by ID, only if task is not currently running.
|
|
||||||
"""
|
|
||||||
logging.warning(
|
|
||||||
"TaskOrchestrator class is deprecated. Please use common.tasks.remove_task() instead."
|
|
||||||
)
|
|
||||||
remove_task(task_id)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue