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.utilities import JsonResponse
|
||||
|
||||
from openflexure_microscope.common.tasks import taskify
|
||||
from openflexure_microscope.common.labthings_core.tasks import taskify
|
||||
|
||||
import os
|
||||
import time
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
__all__ = ["Microscope", "config", "task", "utilities"]
|
||||
__all__ = ["Microscope", "config", "utilities"]
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from .microscope import Microscope
|
||||
from . import config
|
||||
from . import utilities
|
||||
from . import task
|
||||
from . import common
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
__all__ = ["utilities", "views"]
|
||||
|
||||
from . import utilities, views
|
||||
|
|
@ -3,189 +3,117 @@
|
|||
import time
|
||||
import atexit
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import pkg_resources
|
||||
|
||||
from flask import Flask, jsonify, send_file
|
||||
|
||||
from serial import SerialException
|
||||
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
|
||||
from openflexure_microscope.api.utilities import list_routes, init_default_extensions
|
||||
|
||||
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.config import settings_file_path, JSONEncoder
|
||||
from openflexure_microscope.api.v1 import blueprints
|
||||
from openflexure_microscope.api import v2
|
||||
from openflexure_microscope.common.flask_labthings.quick import create_app
|
||||
from openflexure_microscope.common.flask_labthings.extensions import find_extensions
|
||||
|
||||
# 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
|
||||
from openflexure_microscope.api.microscope import default_microscope as api_microscope
|
||||
|
||||
from openflexure_microscope.api.v2 import views
|
||||
|
||||
# Handle logging
|
||||
is_gunicorn = "gunicorn" in os.environ.get("SERVER_SOFTWARE", "")
|
||||
|
||||
DEFAULT_LOGFILE = settings_file_path("openflexure_microscope.log")
|
||||
|
||||
logger = logging.getLogger()
|
||||
if (__name__ == "__main__") or (not is_gunicorn):
|
||||
# If imported, but not by gunicorn
|
||||
print("Letting sys handle logs")
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
# Direct standard Python logging to file and console
|
||||
root = logging.getLogger()
|
||||
error_formatter = logging.Formatter(
|
||||
"[%(asctime)s] [%(threadName)s] [%(levelname)s] %(message)s"
|
||||
)
|
||||
|
||||
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()]
|
||||
|
||||
for handler in error_handlers:
|
||||
handler.setFormatter(error_formatter)
|
||||
root.addHandler(handler)
|
||||
logger.addHandler(handler)
|
||||
|
||||
root.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
|
||||
logger.setLevel(logging.getLogger("gunicorn.error").level)
|
||||
|
||||
|
||||
# Create flask app
|
||||
app = Flask(__name__)
|
||||
app.url_map.strict_slashes = False
|
||||
app, labthing = create_app(
|
||||
__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
|
||||
app.json_encoder = JSONEncoder
|
||||
|
||||
# Enable CORS everywhere
|
||||
CORS(app, resources=r"*")
|
||||
# Attach lab devices
|
||||
labthing.add_component(api_microscope, "org.openflexure.microscope")
|
||||
|
||||
# Make errors more API friendly
|
||||
handler = JSONExceptionHandler(app)
|
||||
# Attach extensions
|
||||
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
|
||||
base_blueprint = blueprints.base.construct_blueprint(api_microscope)
|
||||
app.register_blueprint(base_blueprint, url_prefix=uri("", "v1"))
|
||||
labthing.add_view(views.CaptureView, f"/captures/<id>")
|
||||
labthing.add_view(views.CaptureDownload, f"/captures/<id>/download/<filename>")
|
||||
labthing.add_view(views.CaptureTags, f"/captures/<id>/tags")
|
||||
labthing.add_view(views.CaptureMetadata, f"/captures/<id>/metadata")
|
||||
|
||||
# Stage routes
|
||||
stage_blueprint = blueprints.stage.construct_blueprint(api_microscope)
|
||||
app.register_blueprint(stage_blueprint, url_prefix=uri("/stage", "v1"))
|
||||
# Attach settings and status resources
|
||||
labthing.add_view(views.SettingsProperty, f"/settings")
|
||||
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
|
||||
camera_blueprint = blueprints.camera.construct_blueprint(api_microscope)
|
||||
app.register_blueprint(camera_blueprint, url_prefix=uri("/camera", "v1"))
|
||||
# Attach streams resources
|
||||
labthing.add_view(views.MjpegStream, f"/streams/mjpeg")
|
||||
labthing.add_view(views.SnapshotStream, f"/streams/snapshot")
|
||||
|
||||
# Plugin routes
|
||||
plugin_blueprint = blueprints.plugins.construct_blueprint(api_microscope)
|
||||
app.register_blueprint(plugin_blueprint, url_prefix=uri("/plugin", "v1"))
|
||||
|
||||
# Task routes
|
||||
task_blueprint = blueprints.task.construct_blueprint(api_microscope)
|
||||
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"))
|
||||
# Attach microscope action resources
|
||||
labthing.add_view(views.actions.ActionsView, "/actions")
|
||||
for name, action in views.enabled_root_actions().items():
|
||||
view_class = action["view_class"]
|
||||
rule = action["rule"]
|
||||
labthing.add_view(view_class, f"/actions{rule}")
|
||||
|
||||
|
||||
@app.route("/routes")
|
||||
@cross_origin()
|
||||
def 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))
|
||||
|
||||
|
|
@ -194,12 +122,6 @@ def routes():
|
|||
def err_log():
|
||||
"""
|
||||
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")
|
||||
return send_file(
|
||||
|
|
@ -211,7 +133,6 @@ def err_log():
|
|||
|
||||
# Automatically clean up microscope at exit
|
||||
def cleanup():
|
||||
global api_microscope
|
||||
logging.debug("App teardown started...")
|
||||
logging.debug("Settling...")
|
||||
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 os
|
||||
import errno
|
||||
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=""):
|
||||
|
|
@ -95,3 +103,32 @@ def list_routes(app):
|
|||
output[url] = line
|
||||
|
||||
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 os
|
||||
from sys import platform
|
||||
|
||||
from openflexure_microscope.common.flask_labthings.decorators import (
|
||||
ThingAction,
|
||||
doc_response,
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class ShutdownAPI(MicroscopeView):
|
||||
@ThingAction
|
||||
class ShutdownAPI(View):
|
||||
"""
|
||||
Attempt to shutdown the device
|
||||
"""
|
||||
|
||||
@doc_response(201)
|
||||
def post(self):
|
||||
"""
|
||||
Attempt to shutdown the device
|
||||
|
||||
.. :quickref: Actions; Shutdown
|
||||
|
||||
"""
|
||||
subprocess.Popen(["sudo", "shutdown", "-h", "now"])
|
||||
|
||||
return "{}", 201
|
||||
|
||||
|
||||
class RebootAPI(MicroscopeView):
|
||||
@ThingAction
|
||||
class RebootAPI(View):
|
||||
"""
|
||||
Attempt to reboot the device
|
||||
"""
|
||||
|
||||
@doc_response(201)
|
||||
def post(self):
|
||||
"""
|
||||
Attempt to shutdown the device
|
||||
|
||||
.. :quickref: Actions; Shutdown
|
||||
|
||||
Attempt to reboot the device
|
||||
"""
|
||||
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 .capture import CaptureObject
|
||||
from openflexure_microscope.utilities import entry_by_id
|
||||
from openflexure_microscope.common.lock import StrictLock
|
||||
from openflexure_microscope.utilities import entry_by_uuid
|
||||
from openflexure_microscope.common.labthings_core.lock import StrictLock
|
||||
|
||||
|
||||
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):
|
||||
"""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):
|
||||
"""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
|
||||
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ class CaptureObject(object):
|
|||
"""Create a new StreamObject, to manage capture data."""
|
||||
|
||||
# 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))
|
||||
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.
|
||||
"""
|
||||
logging.warning(
|
||||
"set_zoom is deprecated. Please use the 'zoom' property in picamera_settings."
|
||||
)
|
||||
with self.lock:
|
||||
self.status["zoom_value"] = float(zoom_value)
|
||||
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
|
||||
|
||||
response = {"status_code": status_code, "message": escape(message)}
|
||||
response = {"code": status_code, "message": escape(message)}
|
||||
return jsonify(response), status_code
|
||||
|
||||
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__ = [
|
||||
"taskify",
|
||||
"tasks",
|
||||
"dict",
|
||||
"states",
|
||||
"current_task",
|
||||
"update_task_progress",
|
||||
|
|
@ -12,6 +13,7 @@ __all__ = [
|
|||
|
||||
from .pool import (
|
||||
tasks,
|
||||
dict,
|
||||
states,
|
||||
current_task,
|
||||
update_task_progress,
|
||||
|
|
@ -4,6 +4,8 @@ from functools import wraps
|
|||
|
||||
from .thread import TaskThread
|
||||
|
||||
from flask import copy_current_request_context
|
||||
|
||||
|
||||
class TaskMaster:
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
|
@ -11,11 +13,19 @@ class TaskMaster:
|
|||
|
||||
@property
|
||||
def tasks(self):
|
||||
"""
|
||||
Returns:
|
||||
list: List of TaskThread objects.
|
||||
"""
|
||||
return self._tasks
|
||||
|
||||
@property
|
||||
def dict(self):
|
||||
"""
|
||||
Returns:
|
||||
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
|
||||
def states(self):
|
||||
|
|
@ -23,10 +33,13 @@ class TaskMaster:
|
|||
Returns:
|
||||
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):
|
||||
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)
|
||||
return task
|
||||
|
||||
|
|
@ -45,13 +58,23 @@ class TaskMaster:
|
|||
|
||||
|
||||
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
|
||||
Returns:
|
||||
dict: Dictionary of tasks in default taskmaster
|
||||
"""
|
||||
global _default_task_master
|
||||
return _default_task_master.tasks
|
||||
return _default_task_master.dict
|
||||
|
||||
|
||||
def states():
|
||||
|
|
@ -26,7 +26,7 @@ class TaskThread(threading.Thread):
|
|||
)
|
||||
|
||||
# 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
|
||||
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 logging
|
||||
import shutil
|
||||
from uuid import UUID
|
||||
import numpy as np
|
||||
from fractions import Fraction
|
||||
|
||||
|
|
@ -23,8 +24,10 @@ class JSONEncoder(json.JSONEncoder):
|
|||
"""
|
||||
|
||||
def default(self, o, markers=None):
|
||||
if isinstance(o, UUID):
|
||||
return str(o)
|
||||
# PiCamera fractions
|
||||
if isinstance(o, Fraction):
|
||||
elif isinstance(o, Fraction):
|
||||
return float(o)
|
||||
# Numpy integers
|
||||
elif isinstance(o, np.integer):
|
||||
|
|
@ -54,17 +57,11 @@ class OpenflexureSettingsFile:
|
|||
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
|
||||
|
||||
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
|
||||
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_file(self.config_path, populate=DEFAULT_CONFIG)
|
||||
|
|
@ -76,11 +73,6 @@ class OpenflexureSettingsFile:
|
|||
# Unexpanded config dictionary (used at load/save time)
|
||||
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")
|
||||
return loaded_config
|
||||
|
||||
|
|
@ -88,12 +80,8 @@ class OpenflexureSettingsFile:
|
|||
"""
|
||||
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:
|
||||
# Contract self._config into self.raw_config
|
||||
save_settings = self.contract_config(config)
|
||||
else:
|
||||
save_settings = config
|
||||
|
||||
save_settings = config
|
||||
|
||||
if backup:
|
||||
if os.path.isfile(self.config_path):
|
||||
|
|
@ -109,64 +97,6 @@ class OpenflexureSettingsFile:
|
|||
|
||||
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
|
||||
|
||||
|
|
@ -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_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
|
||||
with open(DEFAULT_CONFIG_FILE_PATH, "r") as default_rc:
|
||||
DEFAULT_CONFIG = default_rc.read()
|
||||
|
||||
# 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
|
||||
"""
|
||||
|
||||
# Plugin classes
|
||||
from openflexure_microscope.plugins import MicroscopePlugin
|
||||
from openflexure_microscope.api.views import MicroscopeViewPlugin
|
||||
from openflexure_microscope.api.utilities import JsonResponse
|
||||
|
||||
# Task management
|
||||
from openflexure_microscope.common.tasks import (
|
||||
from openflexure_microscope.common.labthings_core.tasks import (
|
||||
current_task,
|
||||
update_task_progress,
|
||||
update_task_data,
|
||||
|
|
|
|||
|
|
@ -12,11 +12,10 @@ from openflexure_microscope.camera.base import BaseCamera
|
|||
from openflexure_microscope.camera.mock import MockStreamer
|
||||
|
||||
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.common.labthings_core.lock import CompositeLock
|
||||
|
||||
|
||||
class Microscope:
|
||||
"""
|
||||
|
|
@ -31,31 +30,22 @@ class Microscope:
|
|||
stage (:py:class:`openflexure_microscope.stage.base.BaseStage`): Stage object
|
||||
task: (:py:class:`openflexure_microscope.task.TaskOrchestrator`): Threaded ask orchestrator for managing
|
||||
background tasks using microscope hardware
|
||||
plugins (:py:class:`openflexure_microscope.plugins.PluginLoader`): Mounting point for all microscope plugins
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Initial attributes
|
||||
self.id = uuid.uuid4().hex
|
||||
self.id = uuid.uuid4()
|
||||
self.name = self.id
|
||||
self.fov = [0, 0]
|
||||
self.plugin_maps = []
|
||||
self.camera = None
|
||||
self.stage = None
|
||||
|
||||
# Initialise with an empty composite lock
|
||||
self.lock = CompositeLock([])
|
||||
|
||||
# Create a task orchestrator
|
||||
self.task = TaskOrchestrator()
|
||||
|
||||
# Apply settings loaded from file
|
||||
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):
|
||||
"""Create microscope on context enter."""
|
||||
return self
|
||||
|
|
@ -120,25 +110,6 @@ class Microscope:
|
|||
logging.info("Reapplying settings to newly attached devices")
|
||||
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):
|
||||
if hasattr(self, "stage") and not isinstance(self.stage, MockStage):
|
||||
return True
|
||||
|
|
@ -151,27 +122,6 @@ class Microscope:
|
|||
else:
|
||||
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
|
||||
@property
|
||||
def status(self):
|
||||
|
|
@ -208,8 +158,6 @@ class Microscope:
|
|||
self.name = config["name"]
|
||||
if "fov" in config:
|
||||
self.fov = config["fov"]
|
||||
if "plugins" in config:
|
||||
self.plugin_maps = config["plugins"]
|
||||
|
||||
def read_settings(self, json_safe=False):
|
||||
"""
|
||||
|
|
@ -222,12 +170,7 @@ class Microscope:
|
|||
don't get removed from the settings file.
|
||||
"""
|
||||
|
||||
settings_current = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"fov": self.fov,
|
||||
"plugins": self.plugin_maps,
|
||||
}
|
||||
settings_current = {"id": self.id, "name": self.name, "fov": self.fov}
|
||||
|
||||
# If attached to a camera
|
||||
if self.camera:
|
||||
|
|
@ -256,22 +199,6 @@ class Microscope:
|
|||
self.stage.save_settings()
|
||||
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
|
||||
def metadata(self):
|
||||
"""
|
||||
|
|
@ -279,7 +206,7 @@ class Microscope:
|
|||
"""
|
||||
system_metadata = {
|
||||
"microscope_settings": self.read_settings(),
|
||||
"microscope_state": self.state,
|
||||
"microscope_state": self.status,
|
||||
"microscope_id": self.id,
|
||||
"microscope_name": self.name,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,5 @@
|
|||
4100,
|
||||
3146
|
||||
],
|
||||
"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"
|
||||
]
|
||||
"jpeg_quality": 75
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
# Generate a stack ID
|
||||
scan_id = uuid.uuid4().hex
|
||||
scan_id = uuid.uuid4()
|
||||
|
||||
# Store initial position
|
||||
initial_position = self.microscope.stage.position
|
||||
|
|
@ -288,7 +288,7 @@ class ScanPlugin(MicroscopePlugin):
|
|||
|
||||
# Generate a stack ID
|
||||
if not scan_id:
|
||||
scan_id = uuid.uuid4().hex
|
||||
scan_id = uuid.uuid4()
|
||||
|
||||
# Add scan metadata
|
||||
if not "time" in metadata:
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ class ZipBuilderPlugin(MicroscopePlugin):
|
|||
# Update task progress
|
||||
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] = {
|
||||
"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
|
||||
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):
|
||||
|
|
@ -49,6 +49,14 @@ class BaseStage(metaclass=ABCMeta):
|
|||
"""The current position, as a list"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def position_map(self):
|
||||
return {
|
||||
"x": self.position[0],
|
||||
"y": self.position[1],
|
||||
"z": self.position[2],
|
||||
}
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def backlash(self):
|
||||
|
|
|
|||
|
|
@ -20,13 +20,10 @@ class MockStage(BaseStage):
|
|||
def status(self):
|
||||
"""The general status dictionary of the board."""
|
||||
status = {
|
||||
"position": {
|
||||
"x": self.position[0],
|
||||
"y": self.position[1],
|
||||
"z": self.position[2],
|
||||
},
|
||||
"position": self.position_map,
|
||||
"board": None,
|
||||
"version": "0",
|
||||
"firmware": None,
|
||||
"version": None
|
||||
}
|
||||
return status
|
||||
|
||||
|
|
|
|||
|
|
@ -34,11 +34,7 @@ class SangaStage(BaseStage):
|
|||
def status(self):
|
||||
"""The general status dictionary of the board."""
|
||||
status = {
|
||||
"position": {
|
||||
"x": self.position[0],
|
||||
"y": self.position[1],
|
||||
"z": self.position[2],
|
||||
},
|
||||
"position": self.position_map,
|
||||
"board": self.board.board,
|
||||
"firmware": self.board.firmware,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,9 +213,9 @@ class ExtensibleSerialInstrument(object):
|
|||
return self.read_multiline(termination_line)
|
||||
else:
|
||||
logging.debug("Reading response...")
|
||||
line = (
|
||||
self.readline(timeout).strip()
|
||||
) # question: should we strip the final newline?
|
||||
line = self.readline(
|
||||
timeout
|
||||
).strip() # question: should we strip the final newline?
|
||||
logging.debug(f"Read finished. Got {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