Merge branch 'new-plugin-system' into 'master'

LabThings API base

See merge request openflexure/openflexure-microscope-server!36
This commit is contained in:
Joel Collins 2020-01-14 16:12:35 +00:00
commit fc6bea3359
103 changed files with 4207 additions and 3853 deletions

View file

@ -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

View file

@ -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

View file

@ -1,3 +0,0 @@
__all__ = ["utilities", "views"]
from . import utilities, views

View file

@ -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)

View file

@ -0,0 +1,3 @@
from .autofocus import autofocus_extension_v2
from .scan import scan_extension_v2
from .zip_builder import zip_extension_v2

View 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")

View 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")

View 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")

View 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)
)

View 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!")

View file

@ -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 *"

View 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")

View file

@ -1 +0,0 @@
from . import camera, stage, base, plugins, task

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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})

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1 +0,0 @@
from . import blueprints

View file

@ -1 +0,0 @@
from . import root, captures, settings, status, tasks, streams, plugins, actions

View file

@ -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

View file

@ -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)

View file

@ -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"])

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,4 @@
from .actions import enabled_root_actions
from .captures import *
from .state import *
from .streams import *

View 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

View 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)

View 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"])

View file

@ -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"])

View 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)

View 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)

View 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")

View file

@ -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)

View file

@ -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

View file

@ -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")

View file

@ -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:

View file

@ -0,0 +1,2 @@
from . import flask_labthings
from openflexure_microscope.common.labthings_core import tasks

View file

@ -0,0 +1 @@
EXTENSION_NAME = "flask-lab"

View 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

View file

@ -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):

View 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

View file

@ -0,0 +1,2 @@
# Marshmallow fields
from marshmallow.fields import *

View 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

View 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)

View file

@ -0,0 +1,3 @@
TASK_ENDPOINT = "labthing_task"
TASK_LIST_ENDPOINT = "labthing_task_list"
EXTENSION_LIST_ENDPOINT = "labthing_extension_list"

View 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

View 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

View 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}

View 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

View 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

View 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

View 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

View file

@ -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")
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

View 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>

View 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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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>&nbsp;</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>

View file

@ -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()

View 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

View 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,

View file

@ -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():

View file

@ -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

View 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("_", "-")

View file

@ -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)

View file

@ -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,

View file

@ -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,
}

View file

@ -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
}

View file

@ -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,
)

View file

@ -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:

View file

@ -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,

View file

@ -1,4 +0,0 @@
__all__ = ["Plugin", "HelloWorldAPI", "IdentifyAPI"]
from .plugin import Plugin
from .api import HelloWorldAPI, IdentifyAPI

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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,
}

View file

@ -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

View file

@ -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