Implemented root API v2 routes

This commit is contained in:
jtc42 2019-11-14 16:33:00 +00:00
parent 5aa783c269
commit 6581612312
27 changed files with 1205 additions and 148 deletions

View file

@ -21,6 +21,7 @@ from openflexure_microscope import Microscope
from openflexure_microscope.camera.capture import build_captures_from_exif from openflexure_microscope.camera.capture import build_captures_from_exif
from openflexure_microscope.config import settings_file_path, JSONEncoder from openflexure_microscope.config import settings_file_path, JSONEncoder
from openflexure_microscope.api.v1 import blueprints from openflexure_microscope.api.v1 import blueprints
from openflexure_microscope.api import v2
# Import device modules # Import device modules
# NB this will eventually be handled by the RC file, so you can choose what device # NB this will eventually be handled by the RC file, so you can choose what device
@ -141,6 +142,31 @@ app.register_blueprint(plugin_blueprint, url_prefix=uri("/plugin", "v1"))
task_blueprint = blueprints.task.construct_blueprint(api_microscope) task_blueprint = blueprints.task.construct_blueprint(api_microscope)
app.register_blueprint(task_blueprint, url_prefix=uri("/task", "v1")) app.register_blueprint(task_blueprint, url_prefix=uri("/task", "v1"))
### V2
# Tasks routes
v2_stream_blueprint = v2.blueprints.stream.construct_blueprint(api_microscope)
app.register_blueprint(v2_stream_blueprint, url_prefix=uri("/", "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"))
@app.route("/routes") @app.route("/routes")
def routes(): def routes():
@ -184,7 +210,7 @@ def cleanup():
# Save config # Save config
logging.debug("Saving config for teardown...") logging.debug("Saving config for teardown...")
api_microscope.save_config(backup=True) api_microscope.save_settings()
logging.debug("Settling...") logging.debug("Settling...")
time.sleep(0.5) time.sleep(0.5)

View file

@ -152,7 +152,7 @@ class ConfigAPI(MicroscopeView):
:>header Content-Type: application/json :>header Content-Type: application/json
:status 200: state available :status 200: state available
""" """
return jsonify(self.microscope.read_config(json_safe=True)) return jsonify(self.microscope.read_settings(json_safe=True))
def post(self): def post(self):
""" """
@ -223,10 +223,10 @@ class ConfigAPI(MicroscopeView):
logging.debug("Updating settings from POST request:") logging.debug("Updating settings from POST request:")
logging.debug(payload.json) logging.debug(payload.json)
self.microscope.apply_config(payload.json) self.microscope.apply_settings(payload.json)
self.microscope.save_config() self.microscope.save_settings()
return jsonify(self.microscope.read_config(json_safe=True)) return jsonify(self.microscope.read_settings(json_safe=True))
def construct_blueprint(microscope_obj): def construct_blueprint(microscope_obj):

View file

@ -130,16 +130,19 @@ class ListAPI(MicroscopeView):
output.file, use_video_port=use_video_port, resize=resize, bayer=bayer output.file, use_video_port=use_video_port, resize=resize, bayer=bayer
) )
metadata.update( # Inject system metadata
{ system_metadata = {
"microscope_settings": self.microscope.read_config(), "microscope_settings": self.microscope.read_settings(),
"microscope_state": self.microscope.state, "microscope_state": self.microscope.state,
"microscope_id": self.microscope.id, "microscope_id": self.microscope.id,
"microscope_name": self.microscope.name, "microscope_name": self.microscope.name,
} }
) output.system_metadata.update(system_metadata)
# Insert custom metadata
output.put_metadata(metadata) output.put_metadata(metadata)
# Insert custom tags
output.put_tags(tags) output.put_tags(tags)
return jsonify(output.state) return jsonify(output.state)

View file

@ -16,7 +16,7 @@ class ZoomAPI(MicroscopeView):
:<header Content-Type: application/json :<header Content-Type: application/json
:status 200: preview started/stopped :status 200: preview started/stopped
""" """
zoom_value = self.microscope.camera.state["zoom_value"] zoom_value = self.microscope.camera.status["zoom_value"]
return jsonify({"zoom_value": zoom_value}) return jsonify({"zoom_value": zoom_value})
@ -47,7 +47,7 @@ class ZoomAPI(MicroscopeView):
self.microscope.camera.set_zoom(zoom_value) self.microscope.camera.set_zoom(zoom_value)
return jsonify(self.microscope.camera.state) return jsonify(self.microscope.camera.status)
class OverlayAPI(MicroscopeView): class OverlayAPI(MicroscopeView):

View file

@ -3,6 +3,7 @@ from openflexure_microscope.api.views import MicroscopeViewPlugin
from flask import Blueprint, jsonify from flask import Blueprint, jsonify
from openflexure_microscope.api.views import MicroscopeView from openflexure_microscope.api.views import MicroscopeView
import copy
import logging import logging
import warnings import warnings
@ -21,7 +22,7 @@ class PluginFormAPI(MicroscopeView):
A complete list of enabled plugins can be found in the microscope state. A complete list of enabled plugins can be found in the microscope state.
""" """
out = self.microscope.plugin.forms out = self.microscope.plugins.forms
return jsonify(out) return jsonify(out)
@ -38,7 +39,10 @@ def construct_blueprint(microscope_obj):
all_routes = [] all_routes = []
# For each plugin attached to the microscope object # For each plugin attached to the microscope object
for plugin_name, plugin_obj in microscope_obj.plugin.plugins: for plugin_representation in microscope_obj.plugins.active:
plugin_obj = plugin_representation["plugin"]
plugin_name = plugin_representation["name"]
# If plugin contains valid endpoints # If plugin contains valid endpoints
if hasattr(plugin_obj, "api_views") and isinstance(plugin_obj.api_views, dict): if hasattr(plugin_obj, "api_views") and isinstance(plugin_obj.api_views, dict):
@ -94,7 +98,7 @@ def construct_blueprint(microscope_obj):
if hasattr(plugin_obj, "api_form") and isinstance( if hasattr(plugin_obj, "api_form") and isinstance(
plugin_obj.api_form, dict plugin_obj.api_form, dict
): ):
api_form_info = plugin_obj.api_form api_form_info = copy.deepcopy(plugin_obj.api_form)
api_form_info["id"] = plugin_name api_form_info["id"] = plugin_name
if "forms" in api_form_info and isinstance( if "forms" in api_form_info and isinstance(
api_form_info["forms"], list api_form_info["forms"], list
@ -110,8 +114,8 @@ def construct_blueprint(microscope_obj):
) )
# Store the complete form in Microscope().plugin.form # Store the complete form in Microscope().plugin.form
microscope_obj.plugin.forms.append(api_form_info) microscope_obj.plugins.forms.append(api_form_info)
print(microscope_obj.plugin.forms) print(microscope_obj.plugins.forms)
else: else:
warnings.warn( warnings.warn(

View file

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

View file

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

View file

@ -0,0 +1,422 @@
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, 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": {
"properties": "{}".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
"""
include_unavailable = get_bool(request.args.get("include_unavailable"))
representation = captures_representation(self.microscope.camera.images, include_unavailable=include_unavailable)
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
"""
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
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("captures_blueprint", __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("capture_list", microscope=microscope_obj),
)
return blueprint

View file

@ -0,0 +1,151 @@
from openflexure_microscope.plugins import PluginLoader, MicroscopePlugin
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
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:
d = {
"name": plugin["name"],
"plugin": str(plugin["plugin"]),
"routes": plugin["routes"],
"form": plugin["form"]
}
plugins.append(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("plugins_blueprint", __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),
)
all_routes = []
# For each plugin attached to the microscope object
for plugin_representation in microscope_obj.plugins.active:
plugin_obj = plugin_representation["plugin"]
plugin_name = plugin_representation["name"]
# 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,
),
)
# Add route to the plugin representation dictionary
plugin_representation["routes"].append(full_view_route)
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
):
# TODO: We deep copy this to avoid clashing between API versions. Can be removed when v1 is removed.
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
plugin_representation["form"] = api_form_info
print(microscope_obj.plugins.forms)
else:
warnings.warn(
"No valid 'api_views' dictionary found in {}".format(plugin_obj)
)
return blueprint

View file

@ -0,0 +1,153 @@
from openflexure_microscope.api.utilities import gen, JsonResponse
from openflexure_microscope.api.views import MicroscopeView
from flask import Blueprint, jsonify, request
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: Config; Set microscope config
**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(json_safe=True))
def construct_blueprint(microscope_obj):
blueprint = Blueprint("v2_settings_blueprint", __name__)
blueprint.add_url_rule(
"/", view_func=SettingsAPI.as_view("settings", microscope=microscope_obj)
)
return blueprint

View file

@ -0,0 +1,64 @@
from openflexure_microscope.api.views import MicroscopeView
from flask import Blueprint, jsonify
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)
def construct_blueprint(microscope_obj):
blueprint = Blueprint("v2_status_blueprint", __name__)
blueprint.add_url_rule(
"/", view_func=StatusAPI.as_view("status", microscope=microscope_obj)
)
return blueprint

View file

@ -0,0 +1,57 @@
from openflexure_microscope.api.utilities import gen, JsonResponse
from openflexure_microscope.api.views import MicroscopeView
from flask import Response, Blueprint, jsonify, request
class StreamAPI(MicroscopeView):
def get(self):
"""
Real-time MJPEG stream from the microscope camera
.. :quickref: Stream; 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):
def get(self):
"""
Single snapshot from the camera stream
.. :quickref: Stream; 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")
def construct_blueprint(microscope_obj):
blueprint = Blueprint("v2_stream_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),
)
return blueprint

View file

@ -0,0 +1,160 @@
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": {
"properties": "{}".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.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("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

@ -100,7 +100,7 @@ class BaseCamera(metaclass=ABCMeta):
last_access (time): Time of last client access to the camera last_access (time): Time of last client access to the camera
stream_timeout (int): Number of inactive seconds before timing out the stream stream_timeout (int): Number of inactive seconds before timing out the stream
stream_timeout_enabled (bool): Enable or disable timing out the stream stream_timeout_enabled (bool): Enable or disable timing out the stream
state (dict): Dictionary for capture state status (dict): Dictionary for capture state
paths (dict): Dictionary of capture paths paths (dict): Dictionary of capture paths
images (list): List of image capture objects images (list): List of image capture objects
videos (list): List of video capture objects videos (list): List of video capture objects
@ -120,7 +120,7 @@ class BaseCamera(metaclass=ABCMeta):
self.stream_timeout = 20 self.stream_timeout = 20
self.stream_timeout_enabled = False self.stream_timeout_enabled = False
self.state = {"board": None} self.status = {"board": None}
# TODO: Load/save these to config # TODO: Load/save these to config
self.paths = {"default": BASE_CAPTURE_PATH, "temp": TEMP_CAPTURE_PATH} self.paths = {"default": BASE_CAPTURE_PATH, "temp": TEMP_CAPTURE_PATH}
@ -130,7 +130,7 @@ class BaseCamera(metaclass=ABCMeta):
self.videos = [] self.videos = []
@abstractmethod @abstractmethod
def apply_config(self, config: dict): def apply_settings(self, config: dict):
"""Update settings from a config dictionary""" """Update settings from a config dictionary"""
with self.lock: with self.lock:
# Apply valid config params to camera object # Apply valid config params to camera object
@ -139,11 +139,11 @@ class BaseCamera(metaclass=ABCMeta):
setattr(self, key, value) # Set to the target value setattr(self, key, value) # Set to the target value
@abstractmethod @abstractmethod
def read_config(self) -> dict: def read_settings(self) -> dict:
"""Return the current settings as a dictionary""" """Return the current settings as a dictionary"""
return {"paths": self.paths} return {"paths": self.paths}
def save_config(self): def save_settings(self):
"""(Optional) Save any settings to disk that need to be stored""" """(Optional) Save any settings to disk that need to be stored"""
return return
@ -187,7 +187,7 @@ class BaseCamera(metaclass=ABCMeta):
self.last_access = time.time() self.last_access = time.time()
self.stop = False self.stop = False
if not self.state["stream_active"]: if not self.status["stream_active"]:
# start background frame thread # start background frame thread
self.thread = threading.Thread(target=self._thread) self.thread = threading.Thread(target=self._thread)
self.thread.daemon = True self.thread.daemon = True
@ -207,12 +207,12 @@ class BaseCamera(metaclass=ABCMeta):
logging.debug("Stopping worker thread") logging.debug("Stopping worker thread")
timeout_time = time.time() + timeout timeout_time = time.time() + timeout
if self.state["stream_active"]: if self.status["stream_active"]:
self.stop = True self.stop = True
self.thread.join() # Wait for stream thread to exit self.thread.join() # Wait for stream thread to exit
logging.debug("Waiting for stream thread to exit.") logging.debug("Waiting for stream thread to exit.")
while self.state["stream_active"]: while self.status["stream_active"]:
if time.time() > timeout_time: if time.time() > timeout_time:
logging.debug("Timeout waiting for worker thread close.") logging.debug("Timeout waiting for worker thread close.")
raise TimeoutError("Timeout waiting for worker thread close.") raise TimeoutError("Timeout waiting for worker thread close.")
@ -352,7 +352,7 @@ class BaseCamera(metaclass=ABCMeta):
self.frames_iterator = self.frames() self.frames_iterator = self.frames()
logging.debug("Entering worker thread.") logging.debug("Entering worker thread.")
self.state["stream_active"] = True self.status["stream_active"] = True
for frame in self.frames_iterator: for frame in self.frames_iterator:
self.frame = frame self.frame = frame
@ -365,7 +365,7 @@ class BaseCamera(metaclass=ABCMeta):
and ( # If using timeout and ( # If using timeout
time.time() - self.last_access > self.stream_timeout time.time() - self.last_access > self.stream_timeout
) )
and not self.state[ # And timeout time and not self.status[ # And timeout time
"preview_active" "preview_active"
] # And GPU preview is not active ] # And GPU preview is not active
): ):
@ -383,4 +383,4 @@ class BaseCamera(metaclass=ABCMeta):
logging.debug("BaseCamera worker thread exiting...") logging.debug("BaseCamera worker thread exiting...")
# Set stream_activate state # Set stream_activate state
self.state["stream_active"] = False self.status["stream_active"] = False

View file

@ -97,7 +97,7 @@ def capture_from_exif(path, exif_dict):
capture.timestring = exif_dict["time"] capture.timestring = exif_dict["time"]
capture.format = exif_dict["format"] capture.format = exif_dict["format"]
capture._metadata = exif_dict["custom"] capture.custom_metadata = exif_dict["custom"]
capture.tags = exif_dict["tags"] capture.tags = exif_dict["tags"]
return capture return capture
@ -110,7 +110,7 @@ class CaptureObject(object):
Attributes: Attributes:
timestring (str): Timestring of capture creation time timestring (str): Timestring of capture creation time
_metadata (dict): Dictionary of custom metadata to be included in metadata file custom_metadata (dict): Dictionary of custom metadata to be included in metadata file
tags (list): List of tags. Essentially just as extra custom metadata field, but useful for quick organisation tags (list): List of tags. Essentially just as extra custom metadata field, but useful for quick organisation
filefolder (str): Folder in which the capture file will be stored filefolder (str): Folder in which the capture file will be stored
filename (str): Full name of the capture file filename (str): Full name of the capture file
@ -132,7 +132,9 @@ class CaptureObject(object):
self.split_file_path(self.file) self.split_file_path(self.file)
# Dictionary for storing custom metadata # Dictionary for storing custom metadata
self._metadata = {} self.custom_metadata = {}
# Dictionary for adding top-level metadata (cannmot be accessed through web API)
self.system_metadata = {}
# List for storing tags # List for storing tags
self.tags = [] self.tags = []
@ -203,7 +205,7 @@ class CaptureObject(object):
Args: Args:
data (dict): Dictionary of metadata to be added data (dict): Dictionary of metadata to be added
""" """
self._metadata.update(data) self.custom_metadata.update(data)
self.save_metadata() self.save_metadata()
def save_metadata(self) -> None: def save_metadata(self) -> None:
@ -225,6 +227,7 @@ class CaptureObject(object):
# Insert exif into file # Insert exif into file
piexif.insert(exif_bytes, self.file) piexif.insert(exif_bytes, self.file)
@property @property
def metadata(self) -> dict: def metadata(self) -> dict:
""" """
@ -233,13 +236,14 @@ class CaptureObject(object):
""" """
d = { d = {
"id": self.id, "id": self.id,
"filename": self.filename,
"time": self.timestring, "time": self.timestring,
"format": self.format, "format": self.format,
"tags": self.tags, "tags": self.tags,
"custom": self._metadata, "custom": self.custom_metadata,
} }
d.update(self.system_metadata)
# Add custom metadata to dictionary # Add custom metadata to dictionary
return d return d
@ -250,7 +254,7 @@ class CaptureObject(object):
""" """
# Create basic state dictionary # Create basic state dictionary
d = {"path": self.file, "metadata": self.metadata} d = {"path": self.file, "filename": self.filename, "metadata": self.metadata}
# Combined availability of data # Combined availability of data
if self.exists: if self.exists:

View file

@ -26,7 +26,7 @@ class MockStreamer(BaseCamera):
BaseCamera.__init__(self) BaseCamera.__init__(self)
# Store state of PiCameraStreamer # Store state of PiCameraStreamer
self.state.update( self.status.update(
{"stream_active": False, "record_active": False, "board": None} {"stream_active": False, "record_active": False, "board": None}
) )
@ -74,13 +74,13 @@ class MockStreamer(BaseCamera):
BaseCamera.close(self) BaseCamera.close(self)
# HANDLE SETTINGS # HANDLE SETTINGS
def read_config(self) -> dict: def read_settings(self) -> dict:
""" """
Return config dictionary of the PiCameraStreamer. Return config dictionary of the PiCameraStreamer.
""" """
# Get config items from the base class # Get config items from the base class
conf_dict = BaseCamera.read_config(self) conf_dict = BaseCamera.read_settings(self)
# Include device-specific config items # Include device-specific config items
conf_dict.update( conf_dict.update(
@ -94,7 +94,7 @@ class MockStreamer(BaseCamera):
return conf_dict return conf_dict
def apply_config(self, config: dict): def apply_settings(self, config: dict):
""" """
Write a config dictionary to the PiCameraStreamer config. Write a config dictionary to the PiCameraStreamer config.
@ -110,7 +110,7 @@ class MockStreamer(BaseCamera):
with self.lock: with self.lock:
# Apply valid config params to camera object # Apply valid config params to camera object
if not self.state["record_active"]: # If not recording a video if not self.status["record_active"]: # If not recording a video
for key, value in config.items(): # For each provided setting for key, value in config.items(): # For each provided setting
if hasattr(self, key): if hasattr(self, key):

View file

@ -75,8 +75,8 @@ class PiCameraStreamer(BaseCamera):
picamera.PiCamera() picamera.PiCamera()
) #: :py:class:`picamera.PiCamera`: Picamera object ) #: :py:class:`picamera.PiCamera`: Picamera object
# Store state of PiCameraStreamer # Store status of PiCameraStreamer
self.state.update( self.status.update(
{ {
"stream_active": False, "stream_active": False,
"record_active": False, "record_active": False,
@ -97,7 +97,7 @@ class PiCameraStreamer(BaseCamera):
self.picamera_lst_path = settings_file_path("picamera_lst.npy") #: str: Path of .npy lens shading table file self.picamera_lst_path = settings_file_path("picamera_lst.npy") #: str: Path of .npy lens shading table file
# Update board identifier # Update board identifier
self.state.update({}) self.status.update({})
# Create an empty stream # Create an empty stream
self.stream = io.BytesIO() self.stream = io.BytesIO()
@ -118,13 +118,13 @@ class PiCameraStreamer(BaseCamera):
self.camera.close() self.camera.close()
# HANDLE SETTINGS # HANDLE SETTINGS
def read_config(self) -> dict: def read_settings(self) -> dict:
""" """
Return config dictionary of the PiCameraStreamer. Return config dictionary of the PiCameraStreamer.
""" """
# Get config items from the base class # Get config items from the base class
conf_dict = BaseCamera.read_config(self) conf_dict = BaseCamera.read_settings(self)
# Include device-specific config items # Include device-specific config items
conf_dict.update( conf_dict.update(
@ -149,12 +149,12 @@ class PiCameraStreamer(BaseCamera):
return conf_dict return conf_dict
def save_config(self): def save_settings(self):
"""Save lens-shading table to disk""" """Save lens-shading table to disk"""
logging.info("Saving picamera_lst to {}".format(self.picamera_lst_path)) logging.info("Saving picamera_lst to {}".format(self.picamera_lst_path))
self.save_lens_shading_table() self.save_lens_shading_table()
def apply_config(self, config: dict): def apply_settings(self, config: dict):
""" """
Write a config dictionary to the PiCameraStreamer config. Write a config dictionary to the PiCameraStreamer config.
@ -172,10 +172,10 @@ class PiCameraStreamer(BaseCamera):
with self.lock: with self.lock:
# Apply valid config params to Picamera object # Apply valid config params to Picamera object
if not self.state["record_active"]: # If not recording a video if not self.status["record_active"]: # If not recording a video
# Pause stream while changing settings # Pause stream while changing settings
if self.state["stream_active"]: # If stream is active if self.status["stream_active"]: # If stream is active
logging.info("Pausing stream to update config.") logging.info("Pausing stream to update config.")
self.stop_stream_recording() # Pause stream self.stop_stream_recording() # Pause stream
paused_stream = True # Remember to unpause stream when done paused_stream = True # Remember to unpause stream when done
@ -299,13 +299,13 @@ class PiCameraStreamer(BaseCamera):
Change the camera zoom, handling re-centering and scaling. Change the camera zoom, handling re-centering and scaling.
""" """
with self.lock: with self.lock:
self.state["zoom_value"] = float(zoom_value) self.status["zoom_value"] = float(zoom_value)
if self.state["zoom_value"] < 1: if self.status["zoom_value"] < 1:
self.state["zoom_value"] = 1 self.status["zoom_value"] = 1
# Richard's code for zooming ! # Richard's code for zooming !
fov = self.camera.zoom fov = self.camera.zoom
centre = np.array([fov[0] + fov[2] / 2.0, fov[1] + fov[3] / 2.0]) centre = np.array([fov[0] + fov[2] / 2.0, fov[1] + fov[3] / 2.0])
size = 1.0 / self.state["zoom_value"] size = 1.0 / self.status["zoom_value"]
# If the new zoom value would be invalid, move the centre to # If the new zoom value would be invalid, move the centre to
# keep it within the camera's sensor (this is only relevant # keep it within the camera's sensor (this is only relevant
# when zooming out, if the FoV is not centred on (0.5, 0.5) # when zooming out, if the FoV is not centred on (0.5, 0.5)
@ -332,7 +332,7 @@ class PiCameraStreamer(BaseCamera):
self.camera.preview.window = window self.camera.preview.window = window
if fullscreen: if fullscreen:
self.camera.preview.fullscreen = fullscreen self.camera.preview.fullscreen = fullscreen
self.state["preview_active"] = True self.status["preview_active"] = True
except picamera.exc.PiCameraMMALError as e: except picamera.exc.PiCameraMMALError as e:
logging.error( logging.error(
"Suppressed a MMALError in start_preview. Exception: {}".format(e) "Suppressed a MMALError in start_preview. Exception: {}".format(e)
@ -347,7 +347,7 @@ class PiCameraStreamer(BaseCamera):
def stop_preview(self): def stop_preview(self):
"""Stop the on board GPU camera preview.""" """Stop the on board GPU camera preview."""
self.camera.stop_preview() self.camera.stop_preview()
self.state["preview_active"] = False self.status["preview_active"] = False
def start_recording(self, output, fmt: str = "h264", quality: int = 15): def start_recording(self, output, fmt: str = "h264", quality: int = 15):
"""Start recording. """Start recording.
@ -365,7 +365,7 @@ class PiCameraStreamer(BaseCamera):
""" """
with self.lock: with self.lock:
# Start recording method only if a current recording is not running # Start recording method only if a current recording is not running
if not self.state["record_active"]: if not self.status["record_active"]:
# Start the camera video recording on port 2 # Start the camera video recording on port 2
logging.info("Recording to {}".format(output)) logging.info("Recording to {}".format(output))
@ -378,8 +378,8 @@ class PiCameraStreamer(BaseCamera):
quality=quality, quality=quality,
) )
# Update state dictionary # Update status dictionary
self.state["record_active"] = True self.status["record_active"] = True
return output return output
@ -398,8 +398,8 @@ class PiCameraStreamer(BaseCamera):
self.camera.stop_recording(splitter_port=2) self.camera.stop_recording(splitter_port=2)
logging.info("Recording stopped") logging.info("Recording stopped")
# Update state dictionary # Update status dictionary
self.state["record_active"] = False self.status["record_active"] = False
def stop_stream_recording( def stop_stream_recording(
self, splitter_port: int = 1, resolution: Tuple[int, int] = None self, splitter_port: int = 1, resolution: Tuple[int, int] = None
@ -467,7 +467,7 @@ class PiCameraStreamer(BaseCamera):
self.camera.resolution = resolution self.camera.resolution = resolution
# If the stream should be active # If the stream should be active
if self.state["stream_active"]: if self.status["stream_active"]:
try: try:
# Start recording on stream port # Start recording on stream port
self.camera.start_recording( self.camera.start_recording(
@ -633,7 +633,7 @@ class PiCameraStreamer(BaseCamera):
# Start stream recording (and set resolution) # Start stream recording (and set resolution)
self.start_stream_recording() self.start_stream_recording()
# Update state # Update status
logging.debug("STREAM ACTIVE") logging.debug("STREAM ACTIVE")
# While the iterator is not closed # While the iterator is not closed

View file

@ -85,16 +85,16 @@ class OpenflexureSettingsFile:
# If the loaded config was in contracted format # If the loaded config was in contracted format
if self.expand: if self.expand:
# Contract self._config into self.raw_config # Contract self._config into self.raw_config
save_config = self.contract_config(config) save_settings = self.contract_config(config)
else: else:
save_config = config save_settings = config
if backup: if backup:
if os.path.isfile(self.config_path): if os.path.isfile(self.config_path):
shutil.copyfile(self.config_path, self.config_path + ".bk") shutil.copyfile(self.config_path, self.config_path + ".bk")
logging.debug("Saving settings dictionary to disk") logging.debug("Saving settings dictionary to disk")
save_json_file(self.config_path, save_config) save_json_file(self.config_path, save_settings)
def merge(self, config: dict, backup: bool = True): def merge(self, config: dict, backup: bool = True):
logging.debug("Merging settings with file on disk") logging.debug("Merging settings with file on disk")

View file

@ -11,7 +11,7 @@ from openflexure_microscope.stage.mock import MockStage
from openflexure_microscope.camera.base import BaseCamera from openflexure_microscope.camera.base import BaseCamera
from openflexure_microscope.camera.mock import MockStreamer from openflexure_microscope.camera.mock import MockStreamer
from openflexure_microscope.plugins import PluginMount from openflexure_microscope.plugins import PluginLoader
from openflexure_microscope.task import TaskOrchestrator from openflexure_microscope.task import TaskOrchestrator
from openflexure_microscope.common.lock import CompositeLock from openflexure_microscope.common.lock import CompositeLock
from openflexure_microscope.config import user_settings from openflexure_microscope.config import user_settings
@ -30,7 +30,7 @@ class Microscope:
stage (:py:class:`openflexure_microscope.stage.base.BaseStage`): Stage object stage (:py:class:`openflexure_microscope.stage.base.BaseStage`): Stage object
task: (:py:class:`openflexure_microscope.task.TaskOrchestrator`): Threaded ask orchestrator for managing task: (:py:class:`openflexure_microscope.task.TaskOrchestrator`): Threaded ask orchestrator for managing
background tasks using microscope hardware background tasks using microscope hardware
plugin (:py:class:`openflexure_microscope.plugins.PluginMount`): Mounting point for all microscope plugins plugins (:py:class:`openflexure_microscope.plugins.PluginLoader`): Mounting point for all microscope plugins
""" """
def __init__(self): def __init__(self):
@ -49,10 +49,10 @@ class Microscope:
self.task = TaskOrchestrator() self.task = TaskOrchestrator()
# Apply settings loaded from file # Apply settings loaded from file
self.apply_config(user_settings.load()) self.apply_settings(user_settings.load())
# Create plugin mount-point and attach plugins from maps # Create plugin mount-point and attach plugins from maps
self.plugin = PluginMount(self) self.plugins = PluginLoader(self)
self.attach_plugins(self.plugin_maps) self.attach_plugins(self.plugin_maps)
def __enter__(self): def __enter__(self):
@ -84,7 +84,7 @@ class Microscope:
stage (:py:class:`openflexure_microscope.stage.base.BaseStage`): stage object stage (:py:class:`openflexure_microscope.stage.base.BaseStage`): stage object
""" """
settings_full = self.read_config() settings_full = self.read_settings()
logging.debug("Attaching camera...") logging.debug("Attaching camera...")
self.camera = ( self.camera = (
@ -117,7 +117,7 @@ class Microscope:
self.lock.locks.append(self.stage.lock) self.lock.locks.append(self.stage.lock)
logging.info("Reapplying settings to newly attached devices") logging.info("Reapplying settings to newly attached devices")
self.apply_config(settings_full) self.apply_settings(settings_full)
def attach_plugins(self, plugin_maps: list): def attach_plugins(self, plugin_maps: list):
""" """
@ -125,7 +125,7 @@ class Microscope:
""" """
if plugin_maps: if plugin_maps:
for plugin_map in plugin_maps: for plugin_map in plugin_maps:
self.plugin.attach(plugin_map) self.plugins.attach(plugin_map)
else: else:
logging.warning("No plugins specified. Skipping.") logging.warning("No plugins specified. Skipping.")
@ -134,7 +134,7 @@ class Microscope:
Empty the plugin mount and re-attach from config. Empty the plugin mount and re-attach from config.
""" """
logging.info("Tearing down existing PluginMount...") logging.info("Tearing down existing PluginMount...")
self.plugin = PluginMount(self) self.plugins = PluginLoader(self)
logging.info("Repopulating PluginMount...") logging.info("Repopulating PluginMount...")
self.attach_plugins(self.plugin_maps) self.attach_plugins(self.plugin_maps)
@ -157,17 +157,34 @@ class Microscope:
Return: Return:
dict: Dictionary containing position data, dict: Dictionary containing position data,
and :py:attr:`openflexure_microscope.camera.base.BaseCamera.state` 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 = { state = {
"camera": self.camera.state, "camera": self.camera.status,
"stage": self.stage.state, "stage": self.stage.status,
"plugin": self.plugin.state, "plugin": self.plugins.state,
"version": pkg_resources.get_distribution("openflexure_microscope").version, "version": pkg_resources.get_distribution("openflexure_microscope").version,
} }
return state return state
def apply_config(self, config: dict): # Create unified status
@property
def status(self):
"""Dictionary of the basic microscope status.
Return:
dict: Dictionary containing complete microscope status
"""
state = {
"camera": self.camera.status,
"stage": self.stage.status,
"version": pkg_resources.get_distribution("openflexure_microscope").version,
}
return state
def apply_settings(self, config: dict):
""" """
Applies a config dictionary. Missing parameters will be left untouched. Applies a config dictionary. Missing parameters will be left untouched.
""" """
@ -175,11 +192,11 @@ class Microscope:
# If attached to a camera # If attached to a camera
if ("camera_settings" in config) and self.camera: if ("camera_settings" in config) and self.camera:
self.camera.apply_config(config["camera_settings"]) self.camera.apply_settings(config["camera_settings"])
# If attached to a stage # If attached to a stage
if ("stage_settings" in config) and self.stage: if ("stage_settings" in config) and self.stage:
self.stage.apply_config(config["stage_settings"]) self.stage.apply_settings(config["stage_settings"])
# Todo: tidy up with some loopy goodness # Todo: tidy up with some loopy goodness
if "id" in config: if "id" in config:
@ -191,7 +208,7 @@ class Microscope:
if "plugins" in config: if "plugins" in config:
self.plugin_maps = config["plugins"] self.plugin_maps = config["plugins"]
def read_config(self, json_safe=False): def read_settings(self, json_safe=False):
""" """
Get an updated settings dictionary. Get an updated settings dictionary.
@ -211,47 +228,43 @@ class Microscope:
# If attached to a camera # If attached to a camera
if self.camera: if self.camera:
settings_current_camera = self.camera.read_config() settings_current_camera = self.camera.read_settings()
settings_current["camera_settings"] = settings_current_camera settings_current["camera_settings"] = settings_current_camera
# If attached to a stage # If attached to a stage
if self.stage: if self.stage:
settings_current_stage = self.stage.read_config() settings_current_stage = self.stage.read_settings()
settings_current["stage_settings"] = settings_current_stage settings_current["stage_settings"] = settings_current_stage
settings_full = user_settings.merge(settings_current) settings_full = user_settings.merge(settings_current)
return settings_full return settings_full
def save_config(self): def save_settings(self):
""" """
Merges the current settings back to disk Merges the current settings back to disk
""" """
# Read curent config # Read curent config
current_config = self.read_config() current_config = self.read_settings()
# Merge in server version responsible for saving the config file
current_config["server_version"] = pkg_resources.get_distribution(
"openflexure_microscope"
).version
# Save config to file # Save config to file
if self.camera: if self.camera:
self.camera.save_config() self.camera.save_settings()
if self.stage: if self.stage:
self.stage.save_config() self.stage.save_settings()
user_settings.save(current_config, backup=True) user_settings.save(current_config, backup=True)
@property @property
def config(self) -> dict: def config(self) -> dict:
logging.warning( logging.warning(
"Reading microscope through config property is deprecated.\ "Reading microscope through config property is deprecated.\
Please use read_config method instead." Please use read_settings method instead."
) )
return self.read_config() return self.read_settings()
@config.setter @config.setter
def config(self, config: dict) -> None: def config(self, config: dict) -> None:
logging.warning( logging.warning(
"Setting microscope through config property is deprecated.\ "Setting microscope through config property is deprecated.\
Please use apply_config method instead." Please use apply_settings method instead."
) )
self.apply_config(config) self.apply_settings(config)

View file

@ -3,7 +3,7 @@ __all__ = [
"load_plugin_class", "load_plugin_class",
"load_plugin_module", "load_plugin_module",
"class_from_map", "class_from_map",
"PluginMount", "PluginLoader",
"MicroscopePlugin", "MicroscopePlugin",
] ]
@ -12,6 +12,6 @@ from .loader import (
load_plugin_class, load_plugin_class,
load_plugin_module, load_plugin_module,
class_from_map, class_from_map,
PluginMount, PluginLoader,
MicroscopePlugin, MicroscopePlugin,
) )

View file

@ -37,10 +37,10 @@ class Plugin(MicroscopePlugin):
""" """
scamera = self.microscope.camera scamera = self.microscope.camera
with scamera.lock: with scamera.lock:
assert not scamera.state[ assert not scamera.status[
"record_active" "record_active"
], "Can't recalibrate while recording!" ], "Can't recalibrate while recording!"
streaming = scamera.state["stream_active"] streaming = scamera.status["stream_active"]
if streaming: if streaming:
logging.info("Stopping stream before recalibration") logging.info("Stopping stream before recalibration")
scamera.stop_stream_recording(resolution=(640, 480)) scamera.stop_stream_recording(resolution=(640, 480))
@ -51,7 +51,7 @@ class Plugin(MicroscopePlugin):
recalibrate_camera(scamera.camera) recalibrate_camera(scamera.camera)
finally: finally:
scamera.camera.resolution = old_resolution scamera.camera.resolution = old_resolution
self.microscope.save_config() self.microscope.save_settings()
if streaming: if streaming:
logging.info("Restarting stream after recalibration") logging.info("Restarting stream after recalibration")
scamera.start_stream_recording() scamera.start_stream_recording()

View file

@ -97,18 +97,19 @@ class ScanPlugin(MicroscopePlugin):
if "scan" not in tags: if "scan" not in tags:
tags.append("scan") tags.append("scan")
metadata.update( # Inject system metadata
{ system_metadata = {
"scan_id": scan_id, "microscope_settings": self.microscope.read_settings(),
"basename": basename, "microscope_state": self.microscope.state,
"microscope_settings": self.microscope.read_config(), "microscope_id": self.microscope.id,
"microscope_state": self.microscope.state, "microscope_name": self.microscope.name,
"microscope_id": self.microscope.id, }
"microscope_name": self.microscope.name, output.system_metadata.update(system_metadata)
}
)
# Insert custom metadata
output.put_metadata(metadata) output.put_metadata(metadata)
# Insert custom tags
output.put_tags(tags) output.put_tags(tags)
def tile( def tile(

View file

@ -137,7 +137,7 @@ def class_from_map(plugin_map):
return load_plugin_class(*plugin_arr) return load_plugin_class(*plugin_arr)
class PluginMount(object): class PluginLoader(object):
""" """
A mount-point for all loaded plugins. Attaches to a Microscope object. A mount-point for all loaded plugins. Attaches to a Microscope object.
@ -147,30 +147,19 @@ class PluginMount(object):
def __init__(self, parent): def __init__(self, parent):
self.parent = parent self.parent = parent
self.plugins = [] # List of plugin objects self._plugins = [] # List of plugin objects
self.forms = [] # List of plugin forms self.forms = [] # List of plugin forms
logging.info("Creating plugin mount") logging.info("Creating plugin mount")
@property @property
def state(self): def state(self):
return [m[0] for m in self.members] # DEPRECATED
logging.warning("PluginMount.state is deprecated. Use PluginMount.active instead. State will be removed in a future version.")
return [m["python_name"] for m in self._plugins]
@property @property
def members(self): def active(self):
ignores = ["state", "members", "attach"] return self._plugins
plugin_array = []
for obj_name in dir(self):
if not obj_name in ignores and not obj_name[:2] == "__":
obj = getattr(self, obj_name)
if isinstance(obj, MicroscopePlugin):
plugin_members = [
member
for member in inspect.getmembers(obj)
if not member[0][:2] == "__"
]
plugin_info = (obj_name, plugin_members)
plugin_array.append(plugin_info)
return plugin_array
def attach(self, plugin_map): def attach(self, plugin_map):
""" """
@ -204,7 +193,15 @@ class PluginMount(object):
): # If plugin_object is an instance of MicroscopePlugin ): # If plugin_object is an instance of MicroscopePlugin
# Attach plugin_object to the plugin mount # Attach plugin_object to the plugin mount
setattr(self, pythonsafe_plugin_name, plugin_object) setattr(self, pythonsafe_plugin_name, plugin_object)
self.plugins.append((plugin_name, plugin_object))
# Store the plugin object, and it's properties
self._plugins.append({
"name": plugin_name,
"python_name": pythonsafe_plugin_name,
"plugin": plugin_object,
"routes": [],
"form": None
})
# Grant plugin access to the hardware # Grant plugin access to the hardware
plugin_object.microscope = self.parent plugin_object.microscope = self.parent

View file

@ -14,22 +14,22 @@ class BaseStage(metaclass=ABCMeta):
self.lock = StrictLock(timeout=5) self.lock = StrictLock(timeout=5)
@abstractmethod @abstractmethod
def apply_config(self, config: dict): def apply_settings(self, config: dict):
"""Update settings from a config dictionary""" """Update settings from a config dictionary"""
pass pass
@abstractmethod @abstractmethod
def read_config(self): def read_settings(self):
"""Return the current settings as a dictionary""" """Return the current settings as a dictionary"""
pass pass
def save_config(self): def save_settings(self):
"""(Optional) Save any settings to disk that need to be stored""" """(Optional) Save any settings to disk that need to be stored"""
return return
@property @property
@abstractmethod @abstractmethod
def state(self): def status(self):
"""The general state dictionary of the board. """The general state dictionary of the board.
Should at least contain 'position', and 'board' keys. Should at least contain 'position', and 'board' keys.
Note: A None/Null value for 'board' will disable stage Note: A None/Null value for 'board' will disable stage

View file

@ -16,9 +16,9 @@ class MockStage(BaseStage):
self.axis_names = ["x", "y", "z"] # Assume all sangaboards are 3 axis self.axis_names = ["x", "y", "z"] # Assume all sangaboards are 3 axis
@property @property
def state(self): def status(self):
"""The general state dictionary of the board.""" """The general status dictionary of the board."""
state = { status = {
"position": { "position": {
"x": self.position[0], "x": self.position[0],
"y": self.position[1], "y": self.position[1],
@ -27,9 +27,9 @@ class MockStage(BaseStage):
"board": None, "board": None,
"version": "0", "version": "0",
} }
return state return status
def apply_config(self, config: dict): def apply_settings(self, config: dict):
"""Update settings from a config dictionary""" """Update settings from a config dictionary"""
# Set backlash. Expects a dictionary with axis labels # Set backlash. Expects a dictionary with axis labels
@ -38,7 +38,7 @@ class MockStage(BaseStage):
backlash = axes_to_array(config["backlash"], ["x", "y", "z"], [0, 0, 0]) backlash = axes_to_array(config["backlash"], ["x", "y", "z"], [0, 0, 0])
self.backlash = backlash self.backlash = backlash
def read_config(self) -> dict: def read_settings(self) -> dict:
"""Return the current settings as a dictionary""" """Return the current settings as a dictionary"""
blsh = self.backlash.tolist() blsh = self.backlash.tolist()
config = {"backlash": {"x": blsh[0], "y": blsh[1], "z": blsh[2]}} config = {"backlash": {"x": blsh[0], "y": blsh[1], "z": blsh[2]}}

View file

@ -31,9 +31,9 @@ class SangaStage(BaseStage):
self.axis_names = ["x", "y", "z"] # Assume all sangaboards are 3 axis self.axis_names = ["x", "y", "z"] # Assume all sangaboards are 3 axis
@property @property
def state(self): def status(self):
"""The general state dictionary of the board.""" """The general status dictionary of the board."""
state = { status = {
"position": { "position": {
"x": self.position[0], "x": self.position[0],
"y": self.position[1], "y": self.position[1],
@ -42,7 +42,7 @@ class SangaStage(BaseStage):
"board": self.board.board, "board": self.board.board,
"firmware": self.board.firmware, "firmware": self.board.firmware,
} }
return state return status
@property @property
def n_axes(self): def n_axes(self):
@ -85,7 +85,7 @@ class SangaStage(BaseStage):
else: else:
self._backlash = np.array([int(blsh)] * self.n_axes, dtype=np.int) self._backlash = np.array([int(blsh)] * self.n_axes, dtype=np.int)
def apply_config(self, config: dict): def apply_settings(self, config: dict):
"""Update settings from a config dictionary""" """Update settings from a config dictionary"""
# Set backlash. Expects a dictionary with axis labels # Set backlash. Expects a dictionary with axis labels
@ -94,7 +94,7 @@ class SangaStage(BaseStage):
backlash = axes_to_array(config["backlash"], ["x", "y", "z"], [0, 0, 0]) backlash = axes_to_array(config["backlash"], ["x", "y", "z"], [0, 0, 0])
self.backlash = backlash self.backlash = backlash
def read_config(self) -> dict: def read_settings(self) -> dict:
"""Return the current settings as a dictionary""" """Return the current settings as a dictionary"""
blsh = self.backlash.tolist() blsh = self.backlash.tolist()
config = {"backlash": {"x": blsh[0], "y": blsh[1], "z": blsh[2]}} config = {"backlash": {"x": blsh[0], "y": blsh[1], "z": blsh[2]}}

View file

@ -14,18 +14,18 @@ logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
class TestPluginMethods(unittest.TestCase): class TestPluginMethods(unittest.TestCase):
def test_plugin_load(self): def test_plugin_load(self):
plugin_arr = microscope.plugin.plugins plugin_arr = microscope.plugins.plugins
plugin_names = [plugin[0] for plugin in plugin_arr] plugin_names = [plugin[0] for plugin in plugin_arr]
self.assertTrue("testing" in plugin_names) self.assertTrue("testing" in plugin_names)
def test_camera_access(self): def test_camera_access(self):
identify = microscope.plugin.testing.identify() identify = microscope.plugins.testing.identify()
self.assertTrue(identify[0] is microscope.camera) self.assertTrue(identify[0] is microscope.camera)
def test_stage_access(self): def test_stage_access(self):
identify = microscope.plugin.testing.identify() identify = microscope.plugins.testing.identify()
self.assertTrue(identify[1] is microscope.stage) self.assertTrue(identify[1] is microscope.stage)
@ -35,7 +35,7 @@ if __name__ == "__main__":
microscope.attach(PiCameraStreamer(), OpenFlexureStage()) microscope.attach(PiCameraStreamer(), OpenFlexureStage())
microscope.plugin.attach("openflexure_microscope.plugins.testing:Plugin") microscope.plugins.attach("openflexure_microscope.plugins.testing:Plugin")
suites = [unittest.TestLoader().loadTestsFromTestCase(TestPluginMethods)] suites = [unittest.TestLoader().loadTestsFromTestCase(TestPluginMethods)]