Merge branch 'static-docs' into 'master'
Improved API docs See merge request openflexure/openflexure-microscope-server!133
This commit is contained in:
commit
3e02fe4790
24 changed files with 2151 additions and 430 deletions
|
|
@ -102,6 +102,28 @@ pytest:
|
|||
- tags
|
||||
- web
|
||||
|
||||
openapi:
|
||||
stage: testing
|
||||
image: python:3.7
|
||||
retry: 1
|
||||
|
||||
<<: *python-install
|
||||
|
||||
script:
|
||||
- mkdir -p docs/build/
|
||||
- ofm-generate-openapi -o docs/build/swagger.yaml --validate
|
||||
artifacts:
|
||||
name: "openapi"
|
||||
expire_in: 1 week
|
||||
paths:
|
||||
- "./docs/build/swagger.yaml"
|
||||
|
||||
only:
|
||||
- branches
|
||||
- merge_requests
|
||||
- tags
|
||||
- web
|
||||
|
||||
# JavaScript linting with ESLint (via Vue CLI)
|
||||
eslint:
|
||||
stage: analysis
|
||||
|
|
@ -146,6 +168,7 @@ package:
|
|||
stage: package
|
||||
dependencies:
|
||||
- build
|
||||
- openapi
|
||||
|
||||
image: ubuntu:latest
|
||||
|
||||
|
|
@ -154,9 +177,11 @@ package:
|
|||
- mkdir -p dist
|
||||
- tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz .
|
||||
- tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz ./openflexure_microscope/api/static/dist/
|
||||
- cp docs/build/swagger.yaml dist/openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml
|
||||
- cd dist/
|
||||
- sha256sum openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz.sha256
|
||||
- sha256sum openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz.sha256
|
||||
- sha256sum openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml > openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml.sha256
|
||||
|
||||
artifacts:
|
||||
name: "dist"
|
||||
|
|
|
|||
741
Pipfile.lock
generated
741
Pipfile.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -13,7 +13,12 @@ help:
|
|||
|
||||
.PHONY: help Makefile
|
||||
|
||||
openapi: $(BUILDDIR)/swagger.yaml
|
||||
|
||||
$(BUILDDIR)/swagger.yaml: Makefile
|
||||
ofm-generate-openapi -o $@
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
html dirhtml singlehtml json htmlhelp epub latex latexpdf text changes xml linkcheck doctest coverage clean: openapi Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
|
@ -6,4 +6,10 @@ Live documentation
|
|||
|
||||
Full, interactive Swagger documentation for your microscopes web API is available from the microscope itself. From any browser, go to ``http://{your microscope IP address}/api/v2/docs/swagger-ui``.
|
||||
|
||||
.. note:: We should have an online copy of the API SwaggerUI documentation up soon.
|
||||
.. note:: We should have an online copy of the API SwaggerUI documentation up soon.
|
||||
|
||||
The API is now also detailed below, or there is a more interactive version of the docs statically compiled using ReDoc_
|
||||
|
||||
.. openapi:: ../build/swagger.yaml
|
||||
|
||||
.. _ReDoc: api_redoc.html
|
||||
|
|
@ -15,12 +15,15 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
# Load module from relative imports
|
||||
# Load module from relative imports by modifying the path
|
||||
|
||||
module_path = os.path.abspath("../..")
|
||||
sys.path.insert(0, module_path)
|
||||
|
||||
# Handle mock imports for non-platform-agnostic modules
|
||||
# This allows modules to load that depend on hardware that's not present, e.g.
|
||||
# the `picamera` related modules. They won't *work* but if they load, we can
|
||||
# extract the docstrings, which is what we care about here.
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
|
@ -41,6 +44,7 @@ project = "OpenFlexure Microscope Software"
|
|||
copyright = "2018, Bath Open Instrumentation Group" # pylint: disable=redefined-builtin
|
||||
author = "Bath Open Instrumentation Group"
|
||||
|
||||
# TODO: extract version from ../setup.py
|
||||
# The short X.Y version
|
||||
version = ""
|
||||
# The full version, including alpha/beta/rc tags
|
||||
|
|
@ -64,9 +68,8 @@ extensions = [
|
|||
"sphinx.ext.viewcode",
|
||||
"sphinx.ext.githubpages",
|
||||
"sphinx.ext.ifconfig",
|
||||
"sphinxcontrib.httpdomain",
|
||||
"sphinxcontrib.autohttp.flask",
|
||||
"sphinxcontrib.autohttp.flaskqref",
|
||||
"sphinxcontrib.openapi",
|
||||
"sphinxcontrib.redoc",
|
||||
]
|
||||
|
||||
# Override ordering
|
||||
|
|
@ -222,6 +225,7 @@ epub_exclude_files = ["search.html"]
|
|||
# -- Options for intersphinx extension ---------------------------------------
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
# TODO: update with pysangaboard?
|
||||
intersphinx_mapping = {
|
||||
"openflexure_stage": ("https://openflexure-stage.readthedocs.io/en/latest/", None),
|
||||
"picamerax": ("https://picamerax.readthedocs.io/en/latest//", None),
|
||||
|
|
@ -233,3 +237,13 @@ intersphinx_mapping = {
|
|||
|
||||
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||
todo_include_todos = True
|
||||
|
||||
# -- Options for redoc extension ---------------------------------------------
|
||||
redoc = [
|
||||
{
|
||||
"name": "OpenFlexure Microscope HTTP API",
|
||||
"page": "api_redoc",
|
||||
"spec": "../build/swagger.yaml",
|
||||
"embed": True,
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ Like properties, we use a special view class to identify a view as an action: ``
|
|||
|
||||
# Our success response (200) returns an image (image/jpeg mimetype)
|
||||
responses = {
|
||||
200: {"content_type": "image/jpeg"}
|
||||
200: {
|
||||
"content": { "image/jpeg": {} },
|
||||
}
|
||||
}
|
||||
|
||||
def post(self, args):
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ class QuickCaptureAPI(ActionView):
|
|||
args = {"use_video_port": fields.Boolean(missing=True)}
|
||||
|
||||
# Our success response (200) returns an image (image/jpeg mimetype)
|
||||
responses = {200: {"content_type": "image/jpeg"}}
|
||||
responses = {200: {"content": {"image/jpeg": {}}}}
|
||||
|
||||
def post(self, args):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"}}
|
||||
|
|
|
|||
36
openflexure_microscope/api/openapi.py
Normal file
36
openflexure_microscope/api/openapi.py
Normal 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)
|
||||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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_):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ from openflexure_microscope.stage.sanga import SangaDeltaStage, SangaStage
|
|||
|
||||
try:
|
||||
from openflexure_microscope.camera.pi import PiCameraStreamer
|
||||
except Exception as e: # pylint: disable=W0703
|
||||
logging.error(e)
|
||||
except Exception as exc: # pylint: disable=W0703
|
||||
logging.error(exc)
|
||||
logging.warning("Unable to import PiCameraStreamer")
|
||||
from labthings import CompositeLock
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ location = ".venv"
|
|||
[tool.poe.tasks]
|
||||
black = "black ."
|
||||
black_check = "black --check ."
|
||||
isort = "isort ."
|
||||
isort = "isort openflexure_microscope"
|
||||
pylint = "pylint openflexure_microscope"
|
||||
mypy = "mypy --cobertura-xml-report openflexure_microscope openflexure_microscope"
|
||||
test = "pytest ."
|
||||
|
|
|
|||
12
setup.py
12
setup.py
|
|
@ -44,6 +44,7 @@ setup(
|
|||
# set up the project for development, you use those specific packages, rather than
|
||||
# the looser specifications given here.
|
||||
install_requires=[
|
||||
"apispec[validation]", # We need the extra to validate the spec
|
||||
"Flask ~= 1.0",
|
||||
"Pillow ~= 7.2.0",
|
||||
"numpy ~= 1.20",
|
||||
|
|
@ -58,7 +59,7 @@ setup(
|
|||
"pyyaml ~= 5.4.0",
|
||||
"pytest-cov ~= 2.10.1",
|
||||
"piexif ~= 1.1.3",
|
||||
"labthings ~= 1.2.2",
|
||||
"labthings ~= 1.3.0",
|
||||
"typing-extensions ~= 3.7.4", # Needed for some type-hints in Python < 3.8 (e.g. Literal)
|
||||
"RPi.GPIO ~= 0.7.0; platform_machine == 'armv7l'",
|
||||
],
|
||||
|
|
@ -67,11 +68,15 @@ setup(
|
|||
# them to specific versions to enable consistent builds and testing.
|
||||
extras_require={
|
||||
"dev": [
|
||||
"sphinxcontrib-httpdomain ~= 1.7",
|
||||
"sphinx < 4.0", # Currently httpdomain isn't ready for 4.0
|
||||
"sphinxcontrib-openapi ~= 0.7",
|
||||
"sphinx_rtd_theme ~=0.5.2",
|
||||
"rope ~= 0.14.0",
|
||||
"pylint ~= 2.3",
|
||||
"pylint ~= 2.8.0", # 2.9.2 crashes and I've not yet figured out why.
|
||||
"pytest ~= 6.1.2",
|
||||
"mypy ~= 0.790",
|
||||
"types-python-dateutil",
|
||||
"types-setuptools",
|
||||
"poethepoet ~= 0.10.0",
|
||||
"freezegun ~= 1.0.0",
|
||||
"lxml ~= 4.6",
|
||||
|
|
@ -87,6 +92,7 @@ setup(
|
|||
"console_scripts": [
|
||||
"ofm-serve=openflexure_microscope.api.app:ofm_serve",
|
||||
"ofm-rescue=openflexure_microscope.rescue.auto:main",
|
||||
"ofm-generate-openapi=openflexure_microscope.api.app:generate_openapi",
|
||||
]
|
||||
},
|
||||
project_urls={
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
from apispec.utils import validate_spec
|
||||
import json
|
||||
import jsonschema
|
||||
import os
|
||||
|
||||
from openflexure_microscope.api.app import api_microscope, app, labthing
|
||||
from openflexure_microscope.camera.base import BaseCamera
|
||||
from openflexure_microscope.microscope import Microscope
|
||||
from openflexure_microscope.stage.base import BaseStage
|
||||
from labthings.json import encode_json
|
||||
|
||||
|
||||
def test_app_creation():
|
||||
|
|
@ -12,3 +18,26 @@ def test_microscope_creation():
|
|||
assert isinstance(api_microscope, Microscope)
|
||||
assert isinstance(api_microscope.camera, BaseCamera)
|
||||
assert isinstance(api_microscope.stage, BaseStage)
|
||||
|
||||
|
||||
def test_openapi_valid():
|
||||
assert validate_spec(labthing.spec)
|
||||
|
||||
|
||||
def test_thing_description_valid():
|
||||
# TODO: it would be nice to put this into LabThings
|
||||
# First load the schema from file and check it validates
|
||||
schema_fname = os.path.join(os.path.dirname(__file__), "w3c_td_schema.json")
|
||||
schema = json.load(open(schema_fname, "r"))
|
||||
jsonschema.Draft7Validator.check_schema(schema)
|
||||
|
||||
# Build a TD dictionary
|
||||
with labthing.app.test_request_context():
|
||||
td_dict = labthing.thing_description.to_dict()
|
||||
|
||||
# Allow our LabThingsJSONEncoder to encode the RD
|
||||
td_json = encode_json(td_dict)
|
||||
# Decode the JSON back into a primitive dictionary
|
||||
td_json_dict = json.loads(td_json)
|
||||
# Validate
|
||||
jsonschema.validate(instance=td_json_dict, schema=schema)
|
||||
|
|
|
|||
1035
tests/w3c_td_schema.json
Normal file
1035
tests/w3c_td_schema.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue