Merge branch 'static-docs' into 'master'

Improved API docs

See merge request openflexure/openflexure-microscope-server!133
This commit is contained in:
Richard Bowman 2021-08-17 11:33:40 +00:00
commit 3e02fe4790
24 changed files with 2151 additions and 430 deletions

View file

@ -1,4 +1,5 @@
#!/usr/bin/env python
import argparse
import atexit
import logging
import logging.handlers
@ -23,7 +24,7 @@ import os
from datetime import datetime
import pkg_resources
from flask import abort, send_file, jsonify
from flask import abort, jsonify, send_file
from flask_cors import CORS, cross_origin
from labthings import create_app
from labthings.extensions import find_extensions
@ -39,6 +40,8 @@ from openflexure_microscope.paths import (
logs_file_path,
)
from .openapi import add_spec_extras
# Custom RotatingFileHandler subclass
class CustomRotatingFileHandler(logging.handlers.RotatingFileHandler):
@ -207,6 +210,9 @@ def api_v1_catch_all(path): # pylint: disable=W0613
abort(410, "API v1 is no longer in use. Please upgrade your client.")
add_spec_extras(labthing.spec)
# Automatically clean up microscope at exit
def cleanup():
logging.debug("App teardown started...")
@ -242,6 +248,39 @@ def ofm_serve():
server.run(host="0.0.0.0", port=5000, debug=debug_app, zeroconf=True)
def generate_openapi():
parser = argparse.ArgumentParser("Generate an OpenAPI specification document")
parser.add_argument(
"-o",
dest="output",
default="openapi.yaml",
help=(
"Specify the output filename. If it ends in .json, we output JSON."
"Use .yml or .yaml for YAML (which is the default"
),
)
parser.add_argument(
"--validate",
action="store_true",
help="Validate the API spec, returning an error code if it does not pass.",
)
args = parser.parse_args()
if args.validate:
import apispec.utils
if apispec.utils.validate_spec(labthing.spec):
print("OpenAPI specification validated OK.")
fname = args.output
if fname.endswith(".json"):
import json
with open(fname, "w") as fd:
json.dump(labthing.spec.to_dict(), fd)
else:
with open(fname, "w") as fd:
fd.write(labthing.spec.to_yaml())
# Start the app if the module is run directly
if __name__ == "__main__":
ofm_serve()

View file

@ -1,11 +1,13 @@
import inspect
import logging
import time
from contextlib import contextmanager
from typing import Callable, Dict, List, Optional, Tuple
from typing import Callable, Dict, List, Optional, Tuple, cast
import numpy as np
from labthings import current_action, fields, find_component
from labthings.extensions import BaseExtension
from labthings.utilities import get_docstring, get_summary
from labthings.views import ActionView, View
from scipy import ndimage
@ -19,6 +21,10 @@ from openflexure_microscope.utilities import set_properties
class JPEGSharpnessMonitor:
"""Monitor JPEG frame size in a background thread
This class starts a background thread """
def __init__(self, microscope: Microscope):
self.microscope: Microscope = microscope
self.camera: BaseCamera = microscope.camera
@ -144,6 +150,124 @@ def sharpness_edge(image: np.ndarray) -> float:
)
def find_microscope() -> Microscope:
"""Find the microscope component or raise an exception.
This function will fail with HTTPError extensions if it can't
find the appropriate hardware.
We return a `Microscope` object
"""
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(503, "No microscope connected. Unable to autofocus.")
return microscope
def find_microscope_with_real_stage() -> Microscope:
"""Find the microscope and ensure it has a real stage.
This function wraps `find_microscope()` and additionally asserts
that there is a real stage, raising a `503` code if not.
"""
microscope = find_microscope()
if not microscope.has_real_stage():
abort(503, "No stage connected. Unable to autofocus.")
return microscope
def extension_action(args=None):
"""A decorator to auto-create an Action endpoint for a method
Use this decorator on any method of an extension (`BaseExtension` subclass)
and it will automatically be added to the API. At present it is deliberately
basic, and the plan is to expand the options in the future.
Currently, you may specify `args` (which is a Marshmallow-format schema
or dictionary, determining the datatype of the arguments, which will be
taken from the JSON payload of the POST request initiating the action).
The parsed arguments dictionary is expanded as the function arguments,
i.e. we call `decorated_method(self, **kwargs)`. This means that any
unused arguments will cause an error, which is probably good practice...
NB this decorator does **not** replace the function with a `View` or
register it with the parent `Extension`. It adds the created `View` as
a property of the function, `flask_view`. The parent `Extension` is
responsible for collating and adding the views in its `__init__` method.
"""
supplied_args = args
def decorator(func):
class_docstring = f"""Manage actions for {func.__name__}.
This `View` class will return a list of `Action` objects representing
each time {func.__name__} has been run in response to a `GET` request,
and will start a new `Action` in response to a `POST` request.
"""
class ActionViewWrapper(ActionView):
__doc__ = class_docstring
args = supplied_args
def post(self, arguments):
# Run the action
return func(self.extension, **arguments)
def get(self, *args, **kwargs): # pylint: disable=useless-super-delegation
# Explicitly wrap the `get` method to allow us to add a docstring
return super().get(*args, **kwargs)
# Create a nice docstring. NB because the source function and this docstring
# aren't guaranteed to have the same leading whitespace, we just make sure
# both docstrings are stripped of leading indent, using `inspect.cleandoc()`
ActionViewWrapper.post.description = (
get_docstring(func, remove_newlines=False)
+ "\n\n"
+ inspect.cleandoc(
"""
This `POST` request starts an Action, i.e. the hardware will do something
that may continue after the HTTP request has been responded to. The
response will always be an Action object, that details the current
status of the action and provides an interface to poll for completion.
If the action completes within a specified timeout, we will return
an HTTP status code of `200` and the return value will include any
output from the action. If it does not complete, we will return a
`201` response code, and the action's endpoint may be polled to follow
its progress.
"""
)
)
ActionViewWrapper.post.summary = get_summary(func)
ActionViewWrapper.post.__doc__ = ActionViewWrapper.post.description
ActionViewWrapper.__name__ = func.__name__
ActionViewWrapper.get.summary = (
f"List running and completed `{func.__name__}` actions."
)
ActionViewWrapper.get.description = (
ActionViewWrapper.get.summary
+ "\n\n"
+ inspect.cleandoc(
f"""
This `GET` request will return a list of `Action` objects corresponding
to the `{func.__name__}` action. It will include all the times it has
been run since the server was last restarted, including running, completed,
and failed attempts.
"""
)
)
func.flask_view = ActionViewWrapper
return func
return decorator
### Autofocus extension
@ -157,21 +281,34 @@ class AutofocusExtension(BaseExtension):
self.add_view(
MeasureSharpnessAPI, "/measure_sharpness", endpoint="measure_sharpness"
)
self.add_view(AutofocusAPI, "/autofocus", endpoint="autofocus")
self.add_view(FastAutofocusAPI, "/fast_autofocus", endpoint="fast_autofocus")
self.add_view(
UpDownUpAutofocusAPI, "/updownup_autofocus", endpoint="updownup_autofocus"
)
self.add_decorated_method_views()
self.add_view(
MoveAndMeasureAPI, "/move_and_measure", endpoint="move_and_measure"
)
def add_decorated_method_views(self):
"""Add views from any methods that have been decorated
Using the decorators `@extension_action()` et al will add a
property to the decorated method, `method_view`. If this is
present, this function will add the views to the extension.
"""
for k in dir(self):
obj = getattr(self, k)
if hasattr(obj, "flask_view"):
name = obj.__name__
self.add_view(obj.flask_view, f"/{name}", endpoint=name)
def measure_sharpness(
self, microscope: Microscope, metric_fn: Callable = sharpness_sum_lap2
self,
microscope: Optional[Microscope] = None,
metric_fn: Callable = sharpness_sum_lap2,
) -> float:
"""Measure the sharpness of the camera's current view."""
"""Measure the sharpness from the MJPEG stream
Take a JPEG snapshot from the camera (extracted from the live preview stream)
and return its size. This is the sharpness metric used by the fast autofocus
method.
"""
if not microscope:
microscope = find_microscope()
if hasattr(microscope.camera, "array") and callable(
getattr(microscope.camera, "array")
):
@ -179,22 +316,38 @@ class AutofocusExtension(BaseExtension):
else:
raise RuntimeError(f"Object {microscope.camera} has no method `array`")
@extension_action(
args={
"dz": fields.List(
fields.Int(),
description="An ascending list of relative z positions",
example=[int(x) for x in np.linspace(-300, 300, 7)],
)
}
)
def autofocus(
self,
microscope: Microscope,
dz: List[int],
microscope: Optional[Microscope] = None,
dz: Optional[List[int]] = None,
settle: float = 0.5,
metric_fn: Callable = sharpness_sum_lap2,
) -> Tuple[List[int], List[float]]:
"""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)
"""
if not microscope:
microscope = find_microscope_with_real_stage()
camera: BaseCamera = microscope.camera
stage: BaseStage = microscope.stage
if not dz:
dz = list(np.linspace(-300, 300, 7))
dz = cast(List[int], dz) # dz can't now be None, so fix its type.
with set_properties(stage, backlash=256), stage.lock, camera.lock:
sharpnesses: List[float] = []
@ -216,25 +369,115 @@ class AutofocusExtension(BaseExtension):
return positions, sharpnesses
def move_and_find_focus(self, microscope: Microscope, dz: int) -> int:
def move_and_find_focus(
self, microscope: Optional[Microscope] = None, dz: int = 0
) -> int:
"""Make a relative Z move and return the peak sharpness position"""
if not microscope:
microscope = find_microscope_with_real_stage()
with monitor_sharpness(microscope) as m:
m.focus_rel(dz)
return m.sharpest_z_on_move(0)
@extension_action(
args={
"dz": fields.Int(required=True, description="The relative Z move to make")
}
)
def move_and_measure(
self, microscope: Microscope, dz: int
self, microscope: Optional[Microscope] = None, dz: int = 0
) -> Dict[str, np.ndarray]:
"""Make a relative Z move and return the sharpness data"""
"""Make a relative move in Z and measure dynamic sharpness
This accesses the underlying method used by the fast autofocus routines, to
move the stage while monitoring the sharpness, as reported by the size of
each JPEG frame in the preview stream. It returns a dictionary with
stage position vs time and image size (i.e. sharpness) vs time.
"""
if not microscope:
microscope = find_microscope_with_real_stage()
with monitor_sharpness(microscope) as m:
m.focus_rel(dz)
return m.data_dict()
@extension_action(
args={
"dz": fields.Int(
missing=2000,
example=2000,
description="Total Z range to search over (in stage steps)",
)
}
)
def fast_autofocus(
self, microscope: Microscope, dz: int = 2000
self, microscope: Optional[Microscope] = None, dz: int = 2000
) -> Dict[str, np.ndarray]:
"""Perform a down-up-down-up autofocus"""
with microscope.camera.lock, microscope.stage.lock:
"""Perform a fast down-up-down-up autofocus
This "fast" autofocus method moves the stage continuously in Z, while
following the sharpness using the MJPEG stream. This version is the
simplest "fast" autofocus method, and performs the following sequence
of moves:
1. Move to `-dz/2`, i.e. the bottom of the range
2. Move up by `dz`, i.e. to the top of the range, while recording the
sharpness of the image as a function of time. Record the estimated
position of the stage when the sharpness was maximised, `fz`.
3. Move back to the bottom (by `-dz`)
4. Move up to the position where it was sharpest.
## Backlash correction
This routine should cancel out backlash: the stage is moving upwards as
we record the sharpnes vs z data, and it is also moving upwards when
we make the final move to the sharpest point. Mechanical backlash should
therefore be the same in both cases.
This does not account for lag between the sharpness measurements and the
stage's motion; that has been tested for and seems not to be a big issue
most of the time, but may need to be accounted for in the future, if
hardware or software changes increase the latency.
## Sharpness metric
This method uses the MJPEG preview stream to estimate the sharpness of
the image. MJPEG streams consist of a series of independent JPEG images,
so each frame can be looked at in isolation (though see later for an
important caveat). JPEG images are compressed lossily, by taking the
discrete cosine transform (DCT) of each 8x8 block in the image. A very
rough precis of how this works is that after the DCT, cosine components
that are deemed unimportant (i.e. smaller than a threshold) are discarded.
The effect is that images with lots of high-frequency information have a
larger file size.
We look only at the size of each JPEG frame in the stream, so we get a
remarkably robust estimate of image sharpness without even opening the
images! That's what lets us analyse 30 images/second even on the very
limited processing power available to the Raspberry Pi 3.
## Warning: frame independence
We assume that JPEG frames are independent. This is only true if the
MJPEG stream is encoded at *constant quality* without any additional
bit rate control. By default, many streams will reduce the quality
factor if they exceed a target bit rate, which badly affects this
method. We turn off bit rate limiting for the Raspberry Pi camera,
which fixes the problem, at the expense of sometimes failing if
particularly sharp images appear in the stream, as there is a fairly
small maximum size for each JPEG frame beyond which empty images are
returned.
## Estimation of sharpness vs z
What we record during an autofocus is two time series, from two parallel
threads. One thread monitors the camera, and records the size of each
JPEG frame as a function of time. NB this is time from `time.time()`
in Python, so will not be microsecond-accurate. The other thread is
responsible for moving the stage, and records its current Z position
before and after each move. Interpolating between these `(t, z)` points
gives us a `z` value for each JPEG size, and so we can estimate the
JPEG size as a function of `z` and hence determine the `z` value at
which sharpness is maximised.
"""
if not microscope:
microscope = find_microscope_with_real_stage()
with microscope.lock(timeout=1), microscope.camera.lock, microscope.stage.lock:
with monitor_sharpness(microscope) as m:
# Move to (-dz / 2)
m.focus_rel(-dz / 2)
@ -253,15 +496,44 @@ class AutofocusExtension(BaseExtension):
# Return all focus data
return m.data_dict()
@extension_action(
args={
"dz": fields.Int(
missing=2000,
example=2000,
description="Total Z range to search over (in stage steps)",
),
"target_z": fields.Int(
missing=0,
example=-100,
description="Target finishing position, relative to the focus.",
),
"initial_move_up": fields.Bool(
missing=True,
description="Set to Flase to disable the initial move upwards",
),
"backlash": fields.Int(
missing=25,
minimum=0,
description="Distance to undershoot, before correction move.",
),
}
)
def fast_up_down_up_autofocus(
self,
microscope: Microscope,
microscope: Optional[Microscope] = None,
dz: int = 2000,
target_z: int = 0,
initial_move_up: bool = True,
mini_backlash: int = 25,
backlash: Optional[int] = None,
mini_backlash: Optional[int] = None,
) -> Dict[str, np.ndarray]:
"""Autofocus by measuring on the way down, and moving back up with feedback.
"""Perform a fast up-down-up autofocus, with feedback
Autofocus by measuring on the way down, and moving back up with feedback.
This is a "fast" autofocus method, i.e. it moves the stage continuously
while monitoring the sharpness using the MJPEG stream. See `fast_autofocus`
for more details.
This autofocus method is very efficient, as it only passes the peak once.
The sequence of moves it performs is:
@ -277,26 +549,36 @@ class AutofocusExtension(BaseExtension):
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.
backlash, by using the sharpness-vs-z curve as a rough encoder for Z. The
main source of error is that the curves on the way up and the way down are
not always identical, largely due to small lateral shifts as the Z axis is
moved.
Parameters:
dz: number of steps over which to scan (optional, default 2000)
* `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.
* `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.
* `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 25) 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.
* **backlash**: (optional, default 25) 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.
* **mini_backlash**: (optional, default 25) is an alias for `backlash` and will be
removed in due course.
"""
with microscope.camera.lock, microscope.stage.lock:
if not microscope:
microscope = find_microscope_with_real_stage()
if not mini_backlash: # I renamed the argument,
mini_backlash = backlash or 25
with microscope.lock(timeout=1), microscope.camera.lock, microscope.stage.lock:
with monitor_sharpness(microscope) as m:
# Ensure the MJPEG stream has started
microscope.camera.start_stream()
@ -347,116 +629,7 @@ class AutofocusExtension(BaseExtension):
class MeasureSharpnessAPI(View):
__doc__ = AutofocusExtension.measure_sharpness.__doc__
def post(self):
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(503, "No microscope connected. Unable to measure sharpness.")
return {"sharpness": self.extension.measure_sharpness(microscope)}
class MoveAndMeasureAPI(ActionView):
"""
Move the stage and measure dynamic sharpness
"""
args = {"dz": fields.Int(required=True)}
def post(self, args):
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(503, "No microscope connected. Unable to autofocus.")
if microscope.has_real_stage():
# Acquire microscope lock with 1s timeout
with microscope.camera.lock, microscope.stage.lock:
return self.extension.move_and_measure(microscope, dz=args.get("dz"))
else:
abort(503, "No stage connected. Unable to autofocus.")
class AutofocusAPI(ActionView):
"""
Run a standard autofocus
"""
args = {"dz": fields.List(fields.Int())}
def post(self, args):
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 = np.array(args.get("dz", np.linspace(-300, 300, 7)))
if microscope.has_real_stage():
logging.debug("Running autofocus...")
# return a handle on the autofocus task
return self.extension.autofocus(microscope, dz)
else:
abort(503, "No stage connected. Unable to autofocus.")
class FastAutofocusAPI(ActionView):
"""
Run a fast autofocus
"""
args = {"dz": fields.Int(missing=2000, example=2000)}
def post(self, args):
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(503, "No microscope connected. Unable to autofocus.")
if microscope.has_real_stage():
logging.debug("Running autofocus...")
# Acquire microscope lock with 1s timeout
with microscope.lock(timeout=1):
# Run fast_autofocus
return self.extension.fast_autofocus(microscope, dz=args.get("dz"))
else:
abort(503, "No stage connected. Unable to autofocus.")
class UpDownUpAutofocusAPI(ActionView):
"""
Run a fast up-down-up autofocus
"""
args = {
"dz": fields.Int(missing=2000, example=2000),
"backlash": fields.Int(missing=25, minimum=0),
}
def post(self, args):
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(503, "No microscope connected. Unable to autofocus.")
# Figure out the parameters to use
dz = args.get("dz")
backlash = args.get("backlash")
if microscope.has_real_stage():
logging.debug("Running autofocus...")
# Acquire microscope lock with 1s timeout
with microscope.lock(timeout=1):
# Run fast_up_down_up_autofocus
return self.extension.fast_up_down_up_autofocus(
microscope, dz=dz, mini_backlash=backlash
)
else:
abort(503, "No stage connected. Unable to autofocus.")
return {"sharpness": self.extension.measure_sharpness()}

View file

@ -2,10 +2,10 @@ import datetime
import logging
import time
import uuid
import marshmallow
from functools import reduce
from typing import Dict, List, Optional, Tuple
import marshmallow
from labthings import (
current_action,
fields,

View file

@ -10,7 +10,7 @@ from labthings import fields, find_component, update_action_progress
from labthings.extensions import BaseExtension
from labthings.schema import Schema, pre_dump
from labthings.utilities import description_from_view
from labthings.views import ActionView, PropertyView, View
from labthings.views import ActionView, PropertyView, View, described_operation
from openflexure_microscope.captures import CaptureObject
from openflexure_microscope.microscope import Microscope
@ -149,15 +149,24 @@ class ZipBuilderAPIView(ActionView):
args = fields.List(fields.String(), required=True)
def post(self, args):
"""Build a zip file of some captures
Given a list of capture IDs as its argument, this action will
create a zip file that can be downloaded once the action has
completed.
"""
microscope = find_component("org.openflexure.microscope")
# Return a handle on the autofocus task
# Build a zip file from the supplied IDs
return self.extension.manager.marshaled_build_zip_from_capture_ids(
microscope, args
)
class ZipListAPIView(PropertyView):
"""List all the zip files currently available for download.
"""
schema = ZipObjectSchema(many=True)
def get(self):
@ -165,10 +174,21 @@ class ZipListAPIView(PropertyView):
class ZipGetterAPIView(View):
"""
Download or delete a particular capture collection ZIP file
"""Download or delete a particular capture collection ZIP file
"""
parameters = [
{
"name": "session_id",
"in": "path",
"description": "The unique ID of the zip builder session",
"required": True,
"schema": {"type": "string"},
}
]
responses = {404: {"description": "The session ID could not be found"}}
@described_operation
def get(self, session_id):
"""
Download a particular capture collection ZIP file
@ -176,7 +196,7 @@ class ZipGetterAPIView(View):
if not session_id in self.extension.manager.session_zips:
return abort(404) # 404 Not Found
logging.info("Session ID: %s", session_id)
logging.info("Retrieving zip (session ID: %s)", session_id)
return send_file(
self.extension.manager.zip_fp_from_id(session_id).name,
@ -185,6 +205,14 @@ class ZipGetterAPIView(View):
attachment_filename=f"{session_id}.zip",
)
get.responses = {
200: {
"content": {"application/zip": {}},
"description": "A zip archive containing the selected captures",
}
}
@described_operation
def delete(self, session_id):
"""
Close and delete a particular capture collection ZIP file
@ -198,3 +226,5 @@ class ZipGetterAPIView(View):
del self.extension.manager.session_zips[session_id]
return {"return": session_id}
delete.responses = {200: {"description": "The zip file was deleted"}}

View file

@ -0,0 +1,36 @@
API_TAGS = [
{
"name": "actions",
"description": (
"Actions that can be run on the microscope. These endpoints represent "
"actions that many not complete immediately, so they run on the server "
"as `Action` objects and their status can be queried using the links "
"embedded in the JSON action description."
),
"externalDocs": {
"url": "https://iot.mozilla.org/wot/#action-resource",
"description": "Mozilla's description of Web of Things 'Action' resources.",
},
},
{
"name": "properties",
"description": (
"Properties can be read and/or written to, and affect the "
"state of the microscope."
),
"externalDocs": {
"url": "https://iot.mozilla.org/wot/#property-resource",
"description": "Mozilla's description of Web of Things 'Property' resources.",
},
},
{"name": "captures", "description": ""},
{"name": "extensions", "description": ""},
{"name": "events", "description": ""},
]
def add_spec_extras(spec):
"""Add extra documentation and features to the OpenAPI spec"""
# Add a list of tags, so we can control ordering and add descriptions
for t in API_TAGS:
spec.tag(t)

View file

@ -66,12 +66,15 @@ class CaptureAPI(ActionView):
class RAMCaptureAPI(ActionView):
"""
Take a non-persistant image capture.
"""
"""Take a non-persistent image capture."""
args = BasicCaptureArgs()
responses = {200: {"content_type": "image/jpeg"}}
responses = {
200: {
"content": {"image/jpeg": {}},
"description": "A JPEG image, representing the capture",
}
}
def post(self, args):
"""

View file

@ -9,16 +9,19 @@ class MoveStageAPI(ActionView):
"absolute": fields.Boolean(
missing=False, example=False, description="Move to an absolute position"
),
"x": fields.Int(missing=None, example=100),
"y": fields.Int(missing=None, example=100),
"z": fields.Int(missing=None, example=20),
"x": fields.Int(missing=None, example=100, allow_none=False),
"y": fields.Int(missing=None, example=100, allow_none=False),
"z": fields.Int(missing=None, example=20, allow_none=False),
}
def post(self, args):
"""
Move the microscope stage in x, y, z
Any axes that are not specifed will not move.
This action moves the stage. Any axes that are not specifed will not move.
If `absolute=True` is specified, the stage will move to the absolute
coordinates given. If not (the default), a relative move is made, i.e.
`x=0, y=0, z=0` corresponds to no motion.
"""
microscope = find_component("org.openflexure.microscope")
@ -50,7 +53,8 @@ class ZeroStageAPI(ActionView):
def post(self):
"""
Zero the stage coordinates.
Does not move the stage, but rather makes the current position read as [0, 0, 0]
This action does not move the stage, but rather makes the current position read as [0, 0, 0]
"""
microscope = find_component("org.openflexure.microscope")

View file

@ -14,14 +14,14 @@ class LSTImageProperty(PropertyView):
responses = {
200: {
"content_type": "image/png",
"content": {"image/jpeg": {}},
"description": "Lens-shading table in RGB format",
}
}
def get(self):
"""
Get the stage geometry.
Get the lens shading table as an image.
"""
microscope = find_component("org.openflexure.microscope")

View file

@ -146,8 +146,19 @@ class CaptureList(PropertyView):
return image_list
CAPTURE_ID_PARAMETER = {
"name": "id_",
"in": "path",
"description": "The unique ID of the capture",
"required": True,
"schema": {"type": "string"},
"example": "eeae7ae9-0c0d-45a4-9ef2-7b84bb67a1d1",
}
class CaptureView(View):
tags = ["captures"]
parameters = [CAPTURE_ID_PARAMETER]
@marshal_with(FullCaptureSchema())
def get(self, id_):
@ -162,6 +173,8 @@ class CaptureView(View):
return capture_obj
get.responses = {404: {"description": "Capture object was not found"}}
def delete(self, id_):
"""
Delete a single image capture
@ -182,7 +195,20 @@ class CaptureView(View):
class CaptureDownload(View):
tags = ["captures"]
responses = {200: {"content_type": "image/jpeg"}}
responses = {
200: {"content": {"image/jpeg": {}}, "description": "Image data in JPEG format"}
}
parameters = [
CAPTURE_ID_PARAMETER,
{
"name": "filename",
"in": "path",
"description": "The filename of the downloaded image.",
"required": False,
"schema": {"type": "string"},
"example": "myimage.jpeg",
},
]
def get(self, id_, filename: Optional[str]):
"""
@ -223,6 +249,7 @@ class CaptureDownload(View):
class CaptureTags(View):
tags = ["captures"]
parameters = [CAPTURE_ID_PARAMETER]
def get(self, id_):
"""
@ -270,6 +297,7 @@ class CaptureTags(View):
class CaptureAnnotations(View):
tags = ["captures"]
parameters = [CAPTURE_ID_PARAMETER]
def get(self, id_):
"""

View file

@ -32,9 +32,24 @@ class SettingsProperty(PropertyView):
return self.get()
ROUTE_PARAMETER = {
"name": "route",
"in": "path",
"description": (
"The location of a key or sub-dictionary. "
+ "This is formatted like a path, i.e. forward "
+ "slashes delimit components of the path."
),
"required": True,
"schema": {"type": "string"},
"example": "camera/exposure_time",
}
class NestedSettingsProperty(View):
tags = ["properties"]
responses = {404: {"description": "Settings key cannot be found"}}
parameters = [ROUTE_PARAMETER]
def get(self, route: str):
"""
@ -79,6 +94,7 @@ class StateProperty(PropertyView):
class NestedStateProperty(View):
tags = ["properties"]
responses = {404: {"description": "Status key cannot be found"}}
parameters = [ROUTE_PARAMETER]
def get(self, route):
"""
@ -107,6 +123,7 @@ class ConfigurationProperty(PropertyView):
class NestedConfigurationProperty(View):
tags = ["properties"]
responses = {404: {"description": "Status key cannot be found"}}
parameters = [ROUTE_PARAMETER]
def get(self, route):
"""

View file

@ -1,5 +1,6 @@
from labthings import fields, find_component
from labthings.views import PropertyView
from marshmallow import validate
class StageTypeProperty(PropertyView):
@ -8,8 +9,9 @@ class StageTypeProperty(PropertyView):
schema = fields.String(
missing=None,
example="SangaStage",
OneOf=["SangaStage", "SangaDeltaStage"],
validate=validate.OneOf(["SangaStage", "SangaDeltaStage"]),
description="The translation stage geometry",
allow_none=False,
)
def get(self):

View file

@ -17,7 +17,23 @@ class MjpegStream(PropertyView):
Real-time MJPEG stream from the microscope camera
"""
responses = {200: {"content_type": "multipart/x-mixed-replace"}}
responses = {
200: {
"content": {"multipart/x-mixed-replace": {}},
"description": (
"An MJPEG stream of camera images.\n\n"
"This endpoint will serve JPEG images sequentially, \n"
"with each frame separated by `--frame` and a \n"
"`Content-Type: image/jpeg` header.\n"
"Using this endpoint as the `src` of an HTML `<img>` \n"
"tag will result in the video stream displaying without \n"
"further effort.\n\n"
"If you save the stream to disk (e.g. with `curl`), be \n"
"aware that the text in between frames may confuse some \n"
"video players."
),
}
}
def get(self):
"""
@ -44,7 +60,7 @@ class SnapshotStream(PropertyView):
Single JPEG snapshot from the camera stream
"""
responses = {200: {"content_type": "image/jpeg", "description": "Snapshot taken"}}
responses = {200: {"content": {"image/jpeg": {}}, "description": "Snapshot taken"}}
def get(self):
"""