Merge branch 'v1-blueprints' into 'master'

View blueprints and plugin system

See merge request openflexure/openflexure-microscope-software!2
This commit is contained in:
Joel Collins 2018-12-12 15:06:25 +00:00
commit c0c3cf6963
29 changed files with 1025 additions and 1171 deletions

View file

@ -3,14 +3,14 @@ REST API
Summary
-------
.. qrefflask:: openflexure_microscope.api.v1:app
.. qrefflask:: openflexure_microscope.api.app:app
:undoc-endpoints: index
:undoc-static:
:endpoints:
Details
-----------
.. autoflask:: openflexure_microscope.api.v1:app
.. autoflask:: openflexure_microscope.api.app:app
:undoc-endpoints: index
:undoc-static:
:endpoints:

View file

@ -1,187 +1,130 @@
#!/usr/bin/env python
"""
TODO: Implement API route to cleanly shut down server
TODO: Implement plugin API routes somehow
"""
from pprint import pprint
import numpy as np
from importlib import import_module
import time
import datetime
import os
from flask import (
Flask, render_template, Response,
redirect, request, jsonify, send_file)
Flask, render_template, Response, url_for,
redirect, request, jsonify, send_file, abort,
make_response)
import numpy as np
from flask.views import MethodView
from werkzeug.exceptions import default_exceptions
from openflexure_microscope.api.utilities import parse_payload, get_from_payload, gen, get_bool, list_routes
from openflexure_microscope import Microscope, config
from openflexure_microscope.camera.pi import StreamingCamera
from openflexure_stage import OpenFlexureStage
import atexit
import logging, sys
logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
# Create a dummy microscope object, with no hardware attachments
openflexurerc = config.load_config() # Load default user config
api_microscope = Microscope(None, None, config=openflexurerc)
logging.debug("Created an empty microscope in global.")
# Generate API URI based on version from filename
def uri(suffix, api_version, base=None):
if not base:
base = "/api/{}".format(api_version)
uri = base + suffix
logging.debug("Created app route: {}".format(uri))
return uri
# Create flask app
app = Flask(__name__)
cam = StreamingCamera()
app.url_map.strict_slashes = False
def parse_payload(request):
"""Convert request to JSON. Will eventually handle error-checking."""
# TODO: Handle invalid JSON payloads
state = request.get_json()
return state
# Make errors more API friendly
def _handle_http_exception(e):
return make_response(
jsonify({
'status_code': e.code,
'error': e.name,
'details': e.description
}),
e.code)
for code in default_exceptions:
app.errorhandler(code)(_handle_http_exception)
def gen(camera):
"""Video streaming generator function."""
while True:
# the obtained frame is a jpeg
frame = camera.get_frame()
# After app starts, but before first request, attach hardware to global microscope
@app.before_first_request
def attach_microscope():
# Create the microscope object globally (common to all spawned server threads)
global api_microscope, openflexurerc
logging.debug("First request made. Populating microscope with hardware...")
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n')
logging.debug("Creating camera object...")
api_camera = StreamingCamera(config=openflexurerc)
logging.debug("Creating stage object...")
api_stage = OpenFlexureStage("/dev/ttyUSB0")
logging.debug("Attaching devices to microscope...")
api_microscope.attach(
api_camera,
api_stage
)
logging.debug("Microscope successfully attached!")
##### WEBAPP ROUTES ######
@app.route('/')
def index():
"""Video streaming home page."""
cam.start_worker() # Start the stream
return render_template('index.html')
@app.route('/stream')
def stream():
"""Video streaming route. Put this in the src attribute of an img tag."""
return Response(
gen(cam),
mimetype='multipart/x-mixed-replace; boundary=frame')
# TODO: Be able to change the image resolution with an option
@app.route('/capture/', methods=['GET', 'POST', 'PUT'])
def capture():
"""
POST/PUT: Capture a single image.
GET: Return capture status, or download latest capture.
API demo app
"""
if request.method == 'POST' or request.method == 'PUT':
state = parse_payload(request)
print(state)
return render_template(
'index_v1.html'
)
# Handle filename argument
if 'filename' in state:
filename = state['filename']
else:
filename = None
##### API ROUTES ######
from openflexure_microscope.api.v1 import blueprints
if 'write_to_file' in state:
write_to_file = bool(state['write_to_file'])
else:
write_to_file = False
logging.debug("Registering blueprints...")
# Base routes
base_blueprint = blueprints.base.construct_blueprint(api_microscope)
app.register_blueprint(base_blueprint, url_prefix=uri('', 'v1'))
if 'use_video_port' in state:
use_video_port = bool(state['use_video_port'])
else:
use_video_port = False
# Stage routes
stage_blueprint = blueprints.stage.construct_blueprint(api_microscope)
app.register_blueprint(stage_blueprint, url_prefix=uri('/stage', 'v1'))
if 'resize' in state:
resize_h = int(state['resize'])
resize_w = int(resize_h*(4/3))
resize = (resize_w, resize_h)
else:
resize = None
# Camera routes
camera_blueprint = blueprints.camera.construct_blueprint(api_microscope)
app.register_blueprint(camera_blueprint, url_prefix=uri('/camera', 'v1'))
response = cam.capture(
write_to_file=write_to_file,
use_video_port=use_video_port,
filename=filename,
resize=resize)
# Pluginroutes
plugin_blueprint = blueprints.plugins.construct_blueprint(api_microscope)
app.register_blueprint(plugin_blueprint, url_prefix=uri('/plugin', 'v1'))
return str(response) + "\n"
else: # If GET request
download = request.args.get('download')
state = cam.state
if ((
download == 'true' or
download == 'True' or
download == '1') and
cam.image is not None):
return send_file(cam.image.data, mimetype='image/jpeg')
else:
return jsonify(state)
# List all routes
list_routes(app)
@app.route('/record/', methods=['GET', 'POST', 'PUT'])
def record():
"""
POST/PUT: Start or stop a video recording.
GET: Return recording status, or download latest recording
"""
if request.method == 'POST' or request.method == 'PUT':
state = request.get_json()
print(state)
# Automatically clean up microscope at exit
def cleanup():
global api_microscope
api_microscope.close()
# Handle filename argument
if 'filename' in state:
filename = state['filename']
else:
filename = None
atexit.register(cleanup)
# Handle status argument
if 'status' in state:
status = bool(state['status'])
# synchronise the arduino_time
if status is True:
response = cam.start_recording(filename=filename)
return str(response)
elif status is False:
response = cam.stop_recording()
return str(response)
else:
download = request.args.get('download')
state = cam.state
if ((
download == 'true' or
download == 'True' or
download == '1') and
cam.state['recent_video'] is not None):
return send_file(
cam.state['recent_video'],
mimetype='video/H264',
as_attachment=True)
else:
return jsonify(state)
@app.route('/preview/', methods=['GET', 'POST', 'PUT'])
def preview():
"""
POST/PUT: Start or stop the direct-to-GPU camera preview on the Pi.
GET: Return preview status
"""
if request.method == 'POST' or request.method == 'PUT':
state = request.get_json()
print(state)
if 'status' in state:
status = bool(state['status'])
if status is False:
response = cam.stop_preview()
else:
response = cam.start_preview()
return str(response)
else:
# Get args passed via URL (not useful here. Just for reference.)
state = cam.state
return jsonify(state)
if __name__ == '__main__':
app.run(host='0.0.0.0', threaded=True, debug=True, use_reloader=False)
if __name__ == "__main__":
app.run(host='0.0.0.0', port="5000", threaded=True, debug=True, use_reloader=False)

View file

@ -1,9 +0,0 @@
<html>
<head>
<title>Video Streaming Demonstration</title>
</head>
<body>
<h1>Video Streaming Demonstration</h1>
<img src="{{ url_for('stream') }}">
</body>
</html>

View file

@ -1,9 +0,0 @@
<html>
<head>
<title>Video Streaming Demonstration</title>
</head>
<body>
<h1>Video Streaming Demonstration</h1>
<img src="{{ url_for('stream') }}">
</body>
</html>

View file

@ -1,3 +1,5 @@
import pprint
def parse_payload(request):
"""Convert request to JSON. Will eventually handle error-checking."""
# TODO: Handle invalid JSON payloads
@ -31,4 +33,9 @@ def get_bool(get_arg):
get_arg == '1'):
return True
else:
return False
return False
def list_routes(app):
"""Print available functions."""
pprint.pprint(list(map(lambda x: repr(x), app.url_map.iter_rules())))

View file

@ -1,172 +0,0 @@
#!/usr/bin/env python
from pprint import pprint
from importlib import import_module
import time
import datetime
from flask import (
Flask, render_template, Response,
redirect, request, jsonify, send_file)
import numpy as np
from openflexure_microscope.api.utilities import parse_payload, gen
from openflexure_microscope.camera.pi import StreamingCamera
app = Flask(__name__)
cam = StreamingCamera()
@app.route('/')
def index():
"""Video streaming home page."""
cam.start_worker() # Start the stream
return render_template('index_v0.html')
@app.route('/stream')
def stream():
"""Video streaming route. Put this in the src attribute of an img tag."""
return Response(
gen(cam),
mimetype='multipart/x-mixed-replace; boundary=frame')
# TODO: Be able to change the image resolution with an option
@app.route('/capture/', methods=['GET', 'POST', 'PUT'])
def capture():
"""
POST/PUT: Capture a single image.
GET: Return capture status, or download latest capture.
"""
if request.method == 'POST' or request.method == 'PUT':
state = parse_payload(request)
print(state)
# Handle filename argument
if 'filename' in state:
filename = state['filename']
else:
filename = None
if 'write_to_file' in state:
write_to_file = bool(state['write_to_file'])
else:
write_to_file = False
if 'use_video_port' in state:
use_video_port = bool(state['use_video_port'])
else:
use_video_port = False
if 'resize' in state:
resize_h = int(state['resize'])
resize_w = int(resize_h*(4/3))
resize = (resize_w, resize_h)
else:
resize = None
response = cam.capture(
write_to_file=write_to_file,
use_video_port=use_video_port,
filename=filename,
resize=resize)
return str(response) + "\n"
else: # If GET request
download = request.args.get('download')
state = cam.state
if ((
download == 'true' or
download == 'True' or
download == '1') and
cam.image is not None):
return send_file(cam.image.data, mimetype='image/jpeg')
else:
return jsonify(state)
@app.route('/record/', methods=['GET', 'POST', 'PUT'])
def record():
"""
POST/PUT: Start or stop a video recording.
GET: Return recording status, or download latest recording
"""
if request.method == 'POST' or request.method == 'PUT':
state = request.get_json()
print(state)
# Handle filename argument
if 'filename' in state:
filename = state['filename']
else:
filename = None
# Handle status argument
if 'status' in state:
status = bool(state['status'])
# synchronise the arduino_time
if status is True:
response = cam.start_recording(filename=filename)
return str(response)
elif status is False:
response = cam.stop_recording()
return str(response)
else:
download = request.args.get('download')
state = cam.state
if ((
download == 'true' or
download == 'True' or
download == '1') and
cam.state['recent_video'] is not None):
return send_file(
cam.state['recent_video'],
mimetype='video/H264',
as_attachment=True)
else:
return jsonify(state)
@app.route('/preview/', methods=['GET', 'POST', 'PUT'])
def preview():
"""
POST/PUT: Start or stop the direct-to-GPU camera preview on the Pi.
GET: Return preview status
"""
if request.method == 'POST' or request.method == 'PUT':
state = request.get_json()
print(state)
if 'status' in state:
status = bool(state['status'])
if status is False:
response = cam.stop_preview()
else:
response = cam.start_preview()
return str(response)
else:
# Get args passed via URL (not useful here. Just for reference.)
state = cam.state
return jsonify(state)
if __name__ == '__main__':
app.run(host='0.0.0.0', threaded=True, debug=True, use_reloader=False)

View file

@ -1,695 +0,0 @@
#!/usr/bin/env python
"""
TODO: Add proper docstrings
TODO: Implement API route to cleanly shut down server
TODO: Implement microscope function API routes (autofocus etc)
"""
import numpy as np
from importlib import import_module
import time
import datetime
import os
from flask import (
Flask, render_template, Response, url_for,
redirect, request, jsonify, send_file, abort,
make_response)
from flask.views import MethodView
from werkzeug.exceptions import default_exceptions
from openflexure_microscope.api.utilities import parse_payload, get_from_payload, gen, get_bool
from openflexure_microscope import Microscope, config
from openflexure_microscope.camera.pi import StreamingCamera
from openflexure_stage import OpenFlexureStage
import atexit
import logging, sys
logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
# Create a dummy microscope object, with no hardware attachments
api_microscope = Microscope(None, None)
logging.debug("Created an empty microscope in global.")
# Generate API URI based on version from filename
def uri(suffix, base=None):
if not base:
api_ver = os.path.splitext(os.path.basename(__file__))[0]
base = "/api/{}".format(api_ver)
uri = base + suffix
logging.debug("Created app route: {}".format(uri))
return uri
# Create flask app
app = Flask(__name__)
app.url_map.strict_slashes = False
# Make errors more API friendly
def _handle_http_exception(e):
return make_response(
jsonify({
'status_code': e.code,
'error': e.name,
'details': e.description
}),
e.code)
for code in default_exceptions:
app.errorhandler(code)(_handle_http_exception)
# After app starts, but before first request, attach hardware to global microscope
@app.before_first_request
def attach_microscope():
# Create the microscope object globally (common to all spawned server threads)
global api_microscope
logging.debug("First request made. Populating microscope with hardware...")
openflexurerc = config.load_config() # Load default user config
api_microscope.attach(
StreamingCamera(config=openflexurerc),
OpenFlexureStage("/dev/ttyUSB0")
)
logging.debug("Microscope successfully attached!")
##### WEBAPP ROUTES ######
@app.route('/')
def index():
"""
API demo app
"""
return render_template(
'index_v1.html'
)
##### API ROUTES ######
# Basic microscope view
class MicroscopeView(MethodView):
def __init__(self, microscope, **kwargs):
"""
Create a generic MethodView with a globally available
microscope object passed as an argument.
"""
self.microscope = microscope
MethodView.__init__(self, **kwargs)
class StreamAPI(MicroscopeView):
def get(self):
"""
Real-time MJPEG stream from the microscope camera
.. :quickref: State; Camera stream
:>header Accept: image/jpeg
:>header Content-Type: image/jpeg
:status 200: stream active
"""
# Restart stream worker thread
self.microscope.camera.start_worker()
return Response(
gen(self.microscope.camera),
mimetype='multipart/x-mixed-replace; boundary=frame')
app.add_url_rule(
uri('/stream/'),
view_func=StreamAPI.as_view('stream', microscope=api_microscope))
class StateAPI(MicroscopeView):
def get(self):
"""
JSON representation of the microscope object.
.. :quickref: State; Microscope state
**Example request**:
.. sourcecode:: http
GET /state/ HTTP/1.1
Accept: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"camera": {
"preview_active": false,
"record_active": false,
"stream_active": true
},
"plugin": {},
"stage": {
"backlash": {
"x": 128,
"y": 128,
"z": 128
},
"position": {
"x": -8080,
"y": 5665,
"z": -12600
}
}
}
:>header Accept: application/json
:>header Content-Type: application/json
:status 200: state available
"""
return jsonify(self.microscope.state)
app.add_url_rule(
uri('/state/'),
view_func=StateAPI.as_view('state', microscope=api_microscope))
class PositionAPI(MicroscopeView):
def get(self):
"""
Return current x, y and z positions of the stage.
.. :quickref: Position; Get current position
**Example request**:
.. sourcecode:: http
GET /stage/position/ HTTP/1.1
Accept: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"x": 0,
"y": 0,
"z": 0
}
:>json int x: x steps
:>json int y: y steps
:>json int z: z steps
"""
return jsonify(self.microscope.state['stage']['position'])
def post(self):
"""
Set x, y and z positions of the stage.
.. :quickref: Position; Update current position
:reqheader Accept: application/json
:<json boolean absolute: (true) move to absolute position, (false) move by relative amount
:<json boolean force: allow moving by more than programmed limit
:<json int x: x steps
:<json int y: y steps
:<json int z: z steps
"""
# Get payload
state = parse_payload(request)
logging.debug(state)
# Construct position array
position = [0, 0, 0]
# Handle absolute positioning
if 'absolute' in state and state['absolute'] is True:
# Get coordinates from payload
for axis, key in enumerate(['x', 'y', 'z']):
if key in state:
position[axis] = int(state[key]-self.microscope.stage.position[axis])
else:
# Get coordinates from payload
for axis, key in enumerate(['x', 'y', 'z']):
if key in state:
position[axis] = int(state[key])
logging.debug(position)
# Safeguard to prevent moving to an absolute position beyond a fixed limit
if 'force' not in state or state['force'] is False: # Allow for override
# TODO: Make travel_limit a property of the stage or microscope
# TODO: Make travel_limit a 3-axis list
travel_limit = 20000
for axis, pos in enumerate(position):
if abs(pos) > travel_limit:
# Respond with 400 Bad Request
response = {'error': 'Cannot move to absolute position beyond the safeguard limit.'}
return jsonify(response), 400
self.microscope.stage.move_rel(position)
return jsonify(self.microscope.state['stage']['position'])
app.add_url_rule(
uri('/stage/position/'),
view_func=PositionAPI.as_view('position', microscope=api_microscope))
class StageParamsAPI(MicroscopeView):
def get(self):
"""
Return current parameters of the stage.
.. :quickref: Stage params; Get current stage parameters
**Example request**:
.. sourcecode:: http
GET /stage/params HTTP/1.1
Accept: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"backlash": {
"x": 0,
"y": 0,
"z": 128
},
}
"""
return jsonify(self.microscope.state['stage'])
def post(self):
"""
Set parameters of the stage.
.. :quickref: Stage params; Set current stage parameters
:reqheader Accept: application/json
:<json json backlash: - **x** *(int)*: x-axis backlash in steps
- **y** *(int)*: y-axis backlash in steps
- **z** *(int)*: x-axis backlash in steps
"""
# Get payload
state = parse_payload(request)
logging.debug(state)
# BACKLASH
if 'backlash' in state:
# Construct backlash array
backlash = [0, 0, 0]
# Get backlash coordinates from payload
for axis, key in enumerate(['x', 'y', 'z']):
if key in state['backlash']:
backlash[axis] = int(state['backlash'][key])
# Apply backlash
self.microscope.stage.backlash = backlash
return jsonify(self.microscope.state['stage'])
app.add_url_rule(
uri('/stage/params/'),
view_func=StageParamsAPI.as_view('stage_params', microscope=api_microscope))
class CaptureListAPI(MicroscopeView):
def get(self):
"""
Get list of image captures.
.. :quickref: Capture collection; 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
- **metadata** *(string)*: api uri to the capture json representation
:>header Content-Type: application/json
:status 200: capture found
:status 404: no capture found with that id
"""
include_unavailable = get_bool(request.args.get('include_unavailable'))
if include_unavailable:
captures = [image.metadata for image in self.microscope.camera.images]
else:
captures = [image.metadata for image in self.microscope.camera.images if image.metadata['available']]
return jsonify(captures)
def delete(self):
"""
Delete all captures (not yet implemented)
.. :quickref: Capture collection; Delete all captures
"""
for image in self.microscope.camera.images:
image.delete()
captures = [image.metadata for image in self.microscope.camera.images]
return jsonify(captures)
def post(self):
"""
Create a new image capture.
.. :quickref: Capture collection; New capture
**Example request**:
.. sourcecode:: http
POST /camera/capture HTTP/1.1
Accept: application/json
{
"filename": "myfirstcapture",
"keep_on_disk": true,
"use_video_port": true,
"size": {
"x": 640,
"y": 480
}
}
:>header Accept: application/json
:<json string filename: filename of stored capture
:<json boolean keep_on_disk: keep the capture file on microscope after closing
:<json boolean use_video_port: capture still image from the video port
:<json json size: - **x** *(int)*: x-axis resize
- **y** *(int)*: y-axis resize
:>json boolean available: availability of capture data
:>json string filename: filename of capture
:>json string id: unique id of the capture object
:>json boolean 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
- **metadata** *(string)*: api uri to the capture json representation
:<header Content-Type: application/json
:status 200: capture created
"""
state = parse_payload(request)
logging.info(state)
filename = get_from_payload(state, 'filename', default=None)
keep_on_disk = bool(get_from_payload(state, 'keep_on_disk', default=True))
use_video_port = bool(get_from_payload(state, 'use_video_port', default=False))
resize = get_from_payload(state, 'size', default=None)
if resize:
if ('width' in resize) and ('height' in resize):
resize = (int(resize['width']), int(resize['height'])) # Convert dict to tuple
else:
abort(400)
capture_obj = self.microscope.camera.capture(
write_to_file=True, # Always write data to disk when using API
keep_on_disk=keep_on_disk,
use_video_port=use_video_port,
filename=filename,
resize=resize)
return jsonify(capture_obj.metadata)
app.add_url_rule(
uri('/camera/capture/'),
view_func=CaptureListAPI.as_view('capture_list', microscope=api_microscope))
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,
"filename": "2018-11-16_10-21-53.jpeg",
"id": "d0b2067abbb946f19351e075c5e7cd5b",
"keep_on_disk": false,
"locked": false,
"path": "capture/2018-11-16_10-21-53.jpeg",
"stream": false,
"uri": {
"download": "/api/v1/capture/d0b2067abbb946f19351e075c5e7cd5b/download",
"metadata": "/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
- **metadata** *(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 metadata
capture_metadata = capture_obj.metadata
# TODO: Tidy up adding URI to metadata
# Add API routes to returned metadata
uri_dict = {
'uri': {'metadata': uri('/capture/{}/'.format(capture_id))}
}
# If available, also add download link
if capture_metadata['available']:
uri_dict['uri']['download'] = uri('/capture/{}/download/{}'.format(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):
"""
Modify the metadata of a capture (not yet implemented)
.. :quickref: Capture; Update capture metadata
"""
return jsonify({"return": capture_id})
app.add_url_rule(
uri('/camera/capture/<capture_id>/'),
view_func=CaptureAPI.as_view('capture', microscope=api_microscope))
class CaptureDownloadRedirectAPI(MicroscopeView):
def get(self, capture_id):
"""
Redirect to download the capture under it's currently set filename.
I.e., `/(capture_id)/download` will
redirect to `/(capture_id)/download/(filename)`.
Note: This route may be deprecated in the future. Where at all
possible, please include a filename to download as.
.. :quickref: Capture; Redirect to download
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj or not capture_obj.metadata['available']:
return abort(404) # 404 Not Found
as_attachment = get_bool(request.args.get('as_attachment'))
thumbnail = get_bool(request.args.get('thumbnail'))
return redirect(url_for('capture_download', capture_id=capture_id, filename=capture_obj.filename, as_attachment=as_attachment, thumbnail=thumbnail), code=307)
# Route for shortcut where no filename is specified
app.add_url_rule(
uri('/camera/capture/<capture_id>/download'),
view_func=CaptureDownloadRedirectAPI.as_view('capture_download_redirect', microscope=api_microscope))
class CaptureDownloadAPI(MicroscopeView):
def get(self, capture_id, filename):
"""
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.
.. :quickref: Capture; Download capture file
**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
:query as_attachment: return the image as an attachment download e.g. ?as_attachment=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.metadata['available']:
return abort(404) # 404 Not Found
as_attachment = get_bool(request.args.get('as_attachment'))
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, as_attachment=as_attachment, 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',
as_attachment=as_attachment,
attachment_filename=filename)
app.add_url_rule(
uri('/camera/capture/<capture_id>/download/<filename>'),
view_func=CaptureDownloadAPI.as_view('capture_download', microscope=api_microscope))
# Preview
class GPUPreviewAPI(MicroscopeView):
def post(self, operation):
"""
Create a new image capture.
.. :quickref: GPU Preview; Start/stop preview
**Example requests**:
.. sourcecode:: http
POST /camera/preview/start HTTP/1.1
Accept: application/json
.. sourcecode:: http
POST /camera/preview/stop HTTP/1.1
Accept: application/json
:>header Accept: application/json
:<header Content-Type: application/json
:status 200: preview started/stopped
"""
if operation == "start":
self.microscope.camera.start_preview()
elif operation == "stop":
self.microscope.camera.stop_preview()
return jsonify(self.microscope.state)
app.add_url_rule(
uri('/camera/preview/<string:operation>'),
view_func=GPUPreviewAPI.as_view('gpu_preview', microscope=api_microscope))
# Automatically clean up microscope at exit
def cleanup():
global api_microscope
api_microscope.close()
atexit.register(cleanup)
if __name__ == "__main__":
app.run(host='0.0.0.0', port="5000", threaded=True, debug=True, use_reloader=False)

View file

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

View file

@ -0,0 +1,92 @@
from openflexure_microscope.api.utilities import parse_payload, get_from_payload, gen, get_bool
from openflexure_microscope.api.v1.views import MicroscopeView
from flask import Response, Blueprint, jsonify
class StreamAPI(MicroscopeView):
def get(self):
"""
Real-time MJPEG stream from the microscope camera
.. :quickref: State; Camera stream
:>header Accept: image/jpeg
:>header Content-Type: image/jpeg
:status 200: stream active
"""
# Restart stream worker thread
self.microscope.camera.start_worker()
return Response(
gen(self.microscope.camera),
mimetype='multipart/x-mixed-replace; boundary=frame')
class StateAPI(MicroscopeView):
def get(self):
"""
JSON representation of the microscope object.
.. :quickref: State; Microscope state
**Example request**:
.. sourcecode:: http
GET /state/ HTTP/1.1
Accept: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"camera": {
"preview_active": false,
"record_active": false,
"stream_active": true
},
"plugin": {},
"stage": {
"backlash": {
"x": 128,
"y": 128,
"z": 128
},
"position": {
"x": -8080,
"y": 5665,
"z": -12600
}
}
}
:>header Accept: application/json
:>header Content-Type: application/json
:status 200: state available
"""
return jsonify(self.microscope.state)
def construct_blueprint(microscope_obj):
blueprint = Blueprint('base_blueprint', __name__)
blueprint.add_url_rule(
'/stream',
view_func=StreamAPI.as_view('stream', microscope=microscope_obj)
)
blueprint.add_url_rule(
'/state',
view_func=StateAPI.as_view('state', microscope=microscope_obj)
)
return(blueprint)

View file

@ -0,0 +1,34 @@
from openflexure_microscope.api.v1.views import MicroscopeView
from flask import Blueprint
from . import capture, record, preview
def construct_blueprint(microscope_obj):
blueprint = Blueprint('camera_blueprint', __name__)
# Capture routes
blueprint.add_url_rule(
'/capture/<capture_id>/download/<filename>',
view_func=capture.DownloadAPI.as_view('capture_download', microscope=microscope_obj))
blueprint.add_url_rule(
'/capture/<capture_id>/download',
view_func=capture.DownloadRedirectAPI.as_view('capture_download_redirect', microscope=microscope_obj))
blueprint.add_url_rule(
'/capture/<capture_id>/',
view_func=capture.CaptureAPI.as_view('capture', microscope=microscope_obj))
blueprint.add_url_rule(
'/capture/',
view_func=capture.ListAPI.as_view('capture_list', microscope=microscope_obj))
# Preview routes
blueprint.add_url_rule(
'/preview/<string:operation>',
view_func=preview.GPUPreviewAPI.as_view('gpu_preview', microscope=microscope_obj))
return(blueprint)

View file

@ -0,0 +1,289 @@
from openflexure_microscope.api.utilities import parse_payload, get_from_payload, gen, get_bool
from openflexure_microscope.api.v1.views import MicroscopeView
from flask import Response, Blueprint, jsonify, request, abort, url_for, redirect, send_file
import logging
class ListAPI(MicroscopeView):
def get(self):
"""
Get list of image captures.
.. :quickref: Capture collection; 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
- **metadata** *(string)*: api uri to the capture json representation
:>header Content-Type: application/json
:status 200: capture found
:status 404: no capture found with that id
"""
include_unavailable = get_bool(request.args.get('include_unavailable'))
if include_unavailable:
captures = [image.metadata for image in self.microscope.camera.images]
else:
captures = [image.metadata for image in self.microscope.camera.images if image.metadata['available']]
return jsonify(captures)
def delete(self):
"""
Delete all captures (not yet implemented)
.. :quickref: Capture collection; Delete all captures
"""
for image in self.microscope.camera.images:
image.delete()
captures = [image.metadata for image in self.microscope.camera.images]
return jsonify(captures)
def post(self):
"""
Create a new image capture.
.. :quickref: Capture collection; New capture
**Example request**:
.. sourcecode:: http
POST /camera/capture HTTP/1.1
Accept: application/json
{
"filename": "myfirstcapture",
"keep_on_disk": true,
"use_video_port": true,
"size": {
"x": 640,
"y": 480
}
}
:>header Accept: application/json
:<json string filename: filename of stored capture
:<json boolean keep_on_disk: keep the capture file on microscope after closing
:<json boolean use_video_port: capture still image from the video port
:<json json size: - **x** *(int)*: x-axis resize
- **y** *(int)*: y-axis resize
:>json boolean available: availability of capture data
:>json string filename: filename of capture
:>json string id: unique id of the capture object
:>json boolean 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
- **metadata** *(string)*: api uri to the capture json representation
:<header Content-Type: application/json
:status 200: capture created
"""
state = parse_payload(request)
logging.info(state)
filename = get_from_payload(state, 'filename', default=None)
keep_on_disk = bool(get_from_payload(state, 'keep_on_disk', default=True))
use_video_port = bool(get_from_payload(state, 'use_video_port', default=False))
resize = get_from_payload(state, 'size', default=None)
if resize:
if ('width' in resize) and ('height' in resize):
resize = (int(resize['width']), int(resize['height'])) # Convert dict to tuple
else:
abort(400)
capture_obj = self.microscope.camera.capture(
write_to_file=True, # Always write data to disk when using API
keep_on_disk=keep_on_disk,
use_video_port=use_video_port,
filename=filename,
resize=resize)
return jsonify(capture_obj.metadata)
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,
"filename": "2018-11-16_10-21-53.jpeg",
"id": "d0b2067abbb946f19351e075c5e7cd5b",
"keep_on_disk": false,
"locked": false,
"path": "capture/2018-11-16_10-21-53.jpeg",
"stream": false,
"uri": {
"download": "/api/v1/capture/d0b2067abbb946f19351e075c5e7cd5b/download",
"metadata": "/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
- **metadata** *(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 metadata
capture_metadata = capture_obj.metadata
# TODO: Tidy up adding URI to metadata
# Add API routes to returned metadata
uri_dict = {
'uri': {'metadata': '{}/{}'.format(url_for(self), capture_id)}
}
# If available, also add download link
if capture_metadata['available']:
uri_dict['uri']['download'] = '{}/{}/download/{}'.format(url_for(self), 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):
"""
Modify the metadata of a capture (not yet implemented)
.. :quickref: Capture; Update capture metadata
"""
return jsonify({"return": capture_id})
class DownloadRedirectAPI(MicroscopeView):
def get(self, capture_id):
"""
Redirect to download the capture under it's currently set filename.
I.e., `/(capture_id)/download` will
redirect to `/(capture_id)/download/(filename)`.
Note: This route may be deprecated in the future. Where at all
possible, please include a filename to download as.
.. :quickref: Capture; Redirect to download
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj or not capture_obj.metadata['available']:
return abort(404) # 404 Not Found
as_attachment = get_bool(request.args.get('as_attachment'))
thumbnail = get_bool(request.args.get('thumbnail'))
return redirect(url_for('.capture_download', capture_id=capture_id, filename=capture_obj.filename, as_attachment=as_attachment, thumbnail=thumbnail), code=307)
class DownloadAPI(MicroscopeView):
def get(self, capture_id, filename):
"""
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.
.. :quickref: Capture; Download capture file
**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
:query as_attachment: return the image as an attachment download e.g. ?as_attachment=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.metadata['available']:
return abort(404) # 404 Not Found
as_attachment = get_bool(request.args.get('as_attachment'))
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, as_attachment=as_attachment, 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',
as_attachment=as_attachment,
attachment_filename=filename)

View file

@ -0,0 +1,38 @@
from openflexure_microscope.api.utilities import parse_payload, get_from_payload, gen, get_bool
from openflexure_microscope.api.v1.views import MicroscopeView
from flask import Response, Blueprint, jsonify, request, abort, url_for, redirect, send_file
import logging
class GPUPreviewAPI(MicroscopeView):
def post(self, operation):
"""
Create a new image capture.
.. :quickref: GPU Preview; Start/stop preview
**Example requests**:
.. sourcecode:: http
POST /camera/preview/start HTTP/1.1
Accept: application/json
.. sourcecode:: http
POST /camera/preview/stop HTTP/1.1
Accept: application/json
:>header Accept: application/json
:<header Content-Type: application/json
:status 200: preview started/stopped
"""
if operation == "start":
self.microscope.camera.start_preview()
elif operation == "stop":
self.microscope.camera.stop_preview()
return jsonify(self.microscope.state)

View file

@ -0,0 +1,44 @@
from openflexure_microscope.api.utilities import parse_payload, get_from_payload, gen, get_bool
from openflexure_microscope.api.v1.views import MicroscopeViewPlugin
from flask import Response, Blueprint, jsonify
import logging, warnings
def construct_blueprint(microscope_obj, plugin_paths=[], include_default=True):
blueprint = Blueprint('plugin_blueprint', __name__)
all_routes = []
# For each plugin attached to the microscope object
for plugin_name, plugin_obj in microscope_obj.plugin.plugins:
if hasattr(plugin_obj, 'api_views') and isinstance(plugin_obj.api_views, dict): # If plugin contains valid endpoints
for view_route, view_class in plugin_obj.api_views.items(): # For each defined endpoint
# Remove all leading slashes from view route
while view_route[0] == '/':
view_route = view_route[1:]
# Construct a full view route from the plugin name
full_view_route = "/{}/{}".format(plugin_name, view_route)
if full_view_route not in all_routes and issubclass(view_class, MicroscopeViewPlugin): # Check if endpoint name clashes
all_routes.append(full_view_route) # Add route to main route dictionary
# Add route to the plugins blueprint
blueprint.add_url_rule(
full_view_route,
view_func=view_class.as_view('plugin_{}'.format(view_route).replace('/', '_'), microscope=microscope_obj, plugin=plugin_obj)
)
else:
warnings.warn("An endpoint /{} has already been loaded. Skipping {}.".format(full_view_route, view_class))
else:
warnings.warn("No valid 'api_views' dictionary found in {}".format(plugin_obj))
return(blueprint)

View file

@ -0,0 +1,177 @@
from openflexure_microscope.api.utilities import parse_payload, get_from_payload, gen, get_bool
from openflexure_microscope.api.v1.views import MicroscopeView
from flask import Response, Blueprint, jsonify, request
import logging
class PositionAPI(MicroscopeView):
def get(self):
"""
Return current x, y and z positions of the stage.
.. :quickref: Position; Get current position
**Example request**:
.. sourcecode:: http
GET /stage/position/ HTTP/1.1
Accept: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"x": 0,
"y": 0,
"z": 0
}
:>json int x: x steps
:>json int y: y steps
:>json int z: z steps
"""
return jsonify(self.microscope.state['stage']['position'])
def post(self):
"""
Set x, y and z positions of the stage.
.. :quickref: Position; Update current position
:reqheader Accept: application/json
:<json boolean absolute: (true) move to absolute position, (false) move by relative amount
:<json boolean force: allow moving by more than programmed limit
:<json int x: x steps
:<json int y: y steps
:<json int z: z steps
"""
# Get payload
state = parse_payload(request)
logging.debug(state)
# Construct position array
position = [0, 0, 0]
# Handle absolute positioning
if 'absolute' in state and state['absolute'] is True:
# Get coordinates from payload
for axis, key in enumerate(['x', 'y', 'z']):
if key in state:
position[axis] = int(state[key]-self.microscope.stage.position[axis])
else:
# Get coordinates from payload
for axis, key in enumerate(['x', 'y', 'z']):
if key in state:
position[axis] = int(state[key])
logging.debug(position)
# Safeguard to prevent moving to an absolute position beyond a fixed limit
if 'force' not in state or state['force'] is False: # Allow for override
# TODO: Make travel_limit a property of the stage or microscope
# TODO: Make travel_limit a 3-axis list
travel_limit = 20000
for axis, pos in enumerate(position):
if abs(pos) > travel_limit:
# Respond with 400 Bad Request
response = {'error': 'Cannot move to absolute position beyond the safeguard limit.'}
return jsonify(response), 400
self.microscope.stage.move_rel(position)
return jsonify(self.microscope.state['stage']['position'])
class StageParamsAPI(MicroscopeView):
def get(self):
"""
Return current parameters of the stage.
.. :quickref: Stage params; Get current stage parameters
**Example request**:
.. sourcecode:: http
GET /stage/params HTTP/1.1
Accept: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"backlash": {
"x": 0,
"y": 0,
"z": 128
},
}
"""
return jsonify(self.microscope.state['stage'])
def post(self):
"""
Set parameters of the stage.
.. :quickref: Stage params; Set current stage parameters
:reqheader Accept: application/json
:<json json backlash: - **x** *(int)*: x-axis backlash in steps
- **y** *(int)*: y-axis backlash in steps
- **z** *(int)*: x-axis backlash in steps
"""
# Get payload
state = parse_payload(request)
logging.debug(state)
# BACKLASH
if 'backlash' in state:
# Construct backlash array
backlash = [0, 0, 0]
# Get backlash coordinates from payload
for axis, key in enumerate(['x', 'y', 'z']):
if key in state['backlash']:
backlash[axis] = int(state['backlash'][key])
# Apply backlash
self.microscope.stage.backlash = backlash
return jsonify(self.microscope.state['stage'])
def construct_blueprint(microscope_obj):
blueprint = Blueprint('stage_blueprint', __name__)
blueprint.add_url_rule(
'/position',
view_func=PositionAPI.as_view('position', microscope=microscope_obj)
)
blueprint.add_url_rule(
'/params',
view_func=StageParamsAPI.as_view('stage_params', microscope=microscope_obj)
)
return(blueprint)

View file

@ -0,0 +1,26 @@
from flask.views import MethodView
class MicroscopeView(MethodView):
def __init__(self, microscope, **kwargs):
"""
Create a generic MethodView with a globally available
microscope object passed as an argument.
"""
self.microscope = microscope
MethodView.__init__(self, **kwargs)
class MicroscopeViewPlugin(MicroscopeView):
def __init__(self, microscope, plugin=None, **kwargs):
"""
Create a generic MethodView with a globally available
microscope object passed as an argument, and a plugin
reference stored in 'self'. Initially None
"""
self.plugin = plugin
MicroscopeView.__init__(self, microscope=microscope, **kwargs)

View file

@ -9,7 +9,7 @@ import numpy as np
from openflexure_stage import OpenFlexureStage
from .camera.pi import StreamingCamera
from .plugins import PluginMount, load_plugin, search_plugin_dirs
from .plugins import PluginMount
from .config import load_config, save_config, convert_config
@ -22,16 +22,24 @@ class Microscope(object):
Args:
camera (:py:class:`openflexure_microscope.camera.pi.StreamingCamera`): camera object
microscope (:py:class:`openflexure_stage.stage.OpenFlexureStage`): stage object
load_config (bool): Should a config file be automatically loaded on init?
config_path (str): Path to the config YAML file to be loaded. If None, defaults to USER_CONFIG_PATH.
config (dict): Dictionary of the runtime config to apply to the microscope. Does not automatically apply to camera and stage.
attach_plugins (bool): Should the microscope attach plugins listen in 'config' immediately.
"""
def __init__(self, camera: StreamingCamera, stage: OpenFlexureStage):
def __init__(self, camera: StreamingCamera, stage: OpenFlexureStage, config: dict={}, attach_plugins: bool=True):
# Attach initial hardware (may be NoneTypes)
self.attach(camera, stage)
# Store entire runtime-config
self.config = config
# Create plugin mountpoint
self.plugin = PluginMount(self) #: :py:class:`openflexure_microscope.plugins.PluginMount`: Mounting point for all microscope plugins
# Attach plugins
if attach_plugins:
self.attach_plugins()
def __enter__(self):
"""Create microscope on context enter."""
return self
@ -60,29 +68,51 @@ class Microscope(object):
apply_config (bool): Should the microscope config dictionary be applied to the attached hardware?
"""
logging.debug("Attaching camera...")
self.camera = camera #: :py:class:`openflexure_microscope.camera.pi.StreamingCamera`: Picamera object
if isinstance(camera, StreamingCamera):
if isinstance(self.camera, StreamingCamera):
logging.info("Attached camera {}".format(camera))
elif not self.camera:
logging.info("Attached dummy camera.")
logging.debug("Attaching stage...")
self.stage = stage #: :py:class:`openflexure_stage.stage.OpenFlexureStage`: OpenFlexure stage object
if isinstance(self.stage, OpenFlexureStage): # If a stage object has been attached
logging.info("Attached stage {}".format(stage))
self.stage.backlash = np.zeros(3, dtype=np.int)
elif not self.stage:
logging.info("Attached dummy stage.")
def find_plugins(self, plugin_paths=[], include_default=True):
def attach_plugin_maps(self, plugin_maps):
"""
Automatically search for plugins, and attach to self.
Attach plugins from a list of plugin map strings
Args:
plugin_paths (list): List of strings of plugin directories.
include_default (bool): Also load plugins from the module default directory (DEFAULT_PLUGIN_PATH)
plugin_maps (list): List of strings of plugin maps.
"""
# TODO: Get paths from rc file
plugins_list = search_plugin_dirs(plugin_paths, include_default=include_default)
logging.debug("Attaching plugins...")
for i in plugins_list:
module = load_plugin(i)
self.plugin.attach(module)
for plugin_map in plugin_maps:
self.plugin.attach(plugin_map)
logging.debug("Plugins attached.")
def attach_plugins(self):
"""
Automatically search for plugin maps in self.config, and attach.
"""
if 'plugins' in self.config:
self.attach_plugin_maps(self.config['plugins'])
else:
logging.warning("No plugins specified in microscoperc.yaml. Skipping.")
def reload_plugins(self):
"""
Empty the plugin mount and re-attach from self.config.
"""
logging.info("Tearing down existing PluginMount...")
self.plugin = PluginMount(self)
logging.info("Repopulating PluginMount...")
self.attach_plugins()
# Create unified state
@property

View file

@ -10,6 +10,7 @@ numpy_resolution: [1312, 976]
# Capture quality
jpeg_quality: 75
# Parameters specific to PiCamera objects
picamera_params:
exposure_mode: 'off'
awb_mode: 'off'
@ -17,4 +18,9 @@ picamera_params:
framerate: 24
shutter_speed: 30000
saturation: 0
led: false
led: false
# Default plugins
plugins:
- openflexure_microscope.plugins.default:Plugin
- openflexure_microscope.plugins.test_plugin:FirstPlugin

View file

@ -1,3 +1,3 @@
__all__ = ['search_plugin_dirs', 'find_plugins', 'load_plugin', 'PluginMount', 'MicroscopePlugin']
__all__ = ['module_from_file', 'load_plugin_class', 'load_plugin_module', 'class_from_map', 'PluginMount', 'MicroscopePlugin']
from .loader import search_plugin_dirs, find_plugins, load_plugin, PluginMount, MicroscopePlugin
from .loader import module_from_file, load_plugin_class, load_plugin_module, class_from_map, PluginMount, MicroscopePlugin

View file

@ -0,0 +1 @@
from .plugin import Plugin

View file

@ -0,0 +1,20 @@
from openflexure_microscope.api.utilities import parse_payload
from openflexure_microscope.api.v1.views import MicroscopeViewPlugin
from flask import request, Response, escape
import logging
class IdentifyAPI(MicroscopeViewPlugin):
def get(self):
data = self.microscope.plugin.default.identify() # Call a method from our plugin, using the full route
return Response(escape(data))
class HelloWorldAPI(MicroscopeViewPlugin):
def get(self):
data = self.plugin.hello_world() # Call a method from our plugin, using the MicroscopeViewPlugin.plugin shortcut
return Response(data)

View file

@ -0,0 +1,30 @@
from openflexure_microscope.plugins import MicroscopePlugin
from .api import IdentifyAPI, HelloWorldAPI
class Plugin(MicroscopePlugin):
"""
A set of default plugins
"""
api_views = {
'/identify': IdentifyAPI,
'/hello': HelloWorldAPI,
}
def identify(self):
"""
Demonstrate access to Microscope.camera, and Microscope.stage
"""
response = "My parent camera is {}, and my parent stage is {}.".format(self.microscope.camera, self.microscope.stage)
print(response)
return response
def hello_world(self):
"""
Demonstrate passive method
"""
return "Hello world!"

View file

@ -1,34 +0,0 @@
from openflexure_microscope.plugins import MicroscopePlugin
class FirstPlugin(MicroscopePlugin):
"""
An example Microscope plugin.
"""
def run(self):
"""
Demonstrate access to Microscope.camera, and Microscope.stage
"""
print("Parent camera:")
print(self.camera)
print("Parent stage:")
print(self.stage)
class OtherPlugin(MicroscopePlugin):
"""
An example of a second Microscope plugin, loaded from the same plugin file.
"""
def run(self):
"""
Demonstrate access to Microscope.camera, and Microscope.stage
"""
print("Stage plugin parent stage:")
print(self.stage)
PLUGINS = {
'test1': FirstPlugin,
'test2': OtherPlugin,
} #: dict: Dictionary describing the plugins. Keys are the names of the plugin's namescape, with a value corresponding to the class of that plugin.

View file

@ -1,76 +1,81 @@
import importlib
import os
import warnings
import inspect
import logging
MAIN_MODULE = '__init__'
HERE = os.path.abspath(os.path.dirname(__file__))
DEFAULT_PLUGIN_PATH = os.path.join(HERE, 'default')
class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
def search_plugin_dirs(plugin_paths, include_default=True):
"""
Search through, and load from, a list of plugin directories.
def module_from_file(plugin_path):
# Expand environment variables in path string
plugin_path = os.path.expandvars(plugin_path)
# Expand user directory in path string
plugin_path = os.path.expanduser(plugin_path)
Args:
plugin_paths (list): List of strings of plugin directories.
include_default (bool): Also load plugins from the module default directory (DEFAULT_PLUGIN_PATH)
"""
global DEFAULT_PLUGIN_PATH
# Check if the path is to a file
if not os.path.isfile(plugin_path):
logging.warning(bcolors.FAIL + "No valid plugin found at {}.".format(plugin_path) + bcolors.ENDC)
return None, None, None
if include_default: # If including default plugins
plugin_paths.append(DEFAULT_PLUGIN_PATH) # Add default directory to the search paths
else:
# Get name of plugin from the file
plugin_name = os.path.splitext(os.path.basename(plugin_path))[0]
logging.debug(plugin_paths)
plugin_spec = importlib.util.spec_from_file_location(plugin_name, plugin_path)
plugin_module = importlib.util.module_from_spec(plugin_spec)
plugins = [] # List of loaded plugins
for plugin_dir in plugin_paths: # For each plugin directory
logging.debug("Searching {}".format(plugin_dir))
plugins.extend(find_plugins(plugin_dir)) # Find plugin folders, and load into list
return plugins
return plugin_spec, plugin_module, plugin_name
def find_plugins(plugin_dir):
"""
Find all plugins residing within a directory
def load_plugin_module(plugin_path):
# First, try importing from standard modules
try:
plugin_module = importlib.import_module(plugin_path)
except ModuleNotFoundError:
plugin_spec, plugin_module, plugin_name = module_from_file(plugin_path)
Args:
plugin_dir (str): String of directory to be searched
"""
plugins = []
plugins_folders = os.listdir(plugin_dir)
# If a valid plugin was found
if plugin_spec and plugin_module:
# Execute the module, so we have access to it
plugin_spec.loader.exec_module(plugin_module)
else:
plugin_name = plugin_path.split('.')[-1]
loader_details = (
importlib.machinery.SourceFileLoader,
importlib.machinery.SOURCE_SUFFIXES
)
for i in plugins_folders:
plugin_folder = os.path.join(plugin_dir, i)
logging.info(plugin_folder)
if not os.path.isdir(plugin_folder):
continue
if not MAIN_MODULE + '.py' in os.listdir(plugin_folder):
continue
module_spec = importlib.machinery.FileFinder(plugin_folder, loader_details).find_spec(MAIN_MODULE)
plugins.append(module_spec)
return plugins
return plugin_module, plugin_name
def load_plugin(module_spec):
"""
Load a source file from a given spec.
def load_plugin_class(plugin_path, plugin_class_name):
plugin_module, plugin_name = load_plugin_module(plugin_path)
if plugin_module:
# Now try to extract the class
try:
plugin_class = getattr(plugin_module, plugin_class_name)
except AttributeError:
logging.warning(bcolors.FAIL + "Class {} does not exist in plugin {}. Skipping.".format(plugin_class_name, plugin_path) + bcolors.ENDC)
return None, None
else:
return plugin_class, plugin_name
else:
return None, None
Args:
module_spec: Module spec of module to be returned
"""
module = importlib.util.module_from_spec(module_spec)
module_spec.loader.exec_module(module)
return module
def class_from_map(plugin_map):
plugin_arr = plugin_map.split(':')
if not len(plugin_arr) == 2:
logging.warning(bcolors.WARNING + "Malformed plugin map {}. Skipping.".format(plugin_map) + bcolors.ENDC)
return None, None
else:
return load_plugin_class(*plugin_arr)
class PluginMount(object):
@ -82,41 +87,56 @@ class PluginMount(object):
"""
def __init__(self, parent):
self.parent = parent
self.plugins = []
print("Creating plugin mount")
def attach(self, plugin_module):
@property
def members(self):
plugin_array = []
for obj_name in dir(self):
if not obj_name == "plugins" 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):
"""
Attach a MicroscopePlugin instance to the plugin mount.
Args:
plugin_module: A loaded module to be attached. Module can be loaded using :py:meth:`openflexure_microscope.plugins.load_plugin`
plugin_map (str): A plugin map describing the file or module to load a MicroscopePlugin child from. Maps should be in the format 'module.to.load:ClassName' or '/path/to/file:ClassName'.
"""
if not hasattr(plugin_module, 'PLUGINS') or not isinstance(plugin_module.PLUGINS, dict):
raise Exception("No falid PLUGINS dictionary found in {}".format(plugin_module))
for plugin_name, plugin_class in plugin_module.PLUGINS.items():
plugin_class, plugin_name = class_from_map(plugin_map)
if plugin_class and plugin_name:
plugin_object = plugin_class()
if hasattr(self, plugin_name):
warnings.warn("A plugin named {} has already been loaded. Skipping {}.".format(plugin_name, plugin_class))
else:
if hasattr(self, plugin_name): # If a plugin with the same name is already attached.
logging.warning(bcolors.WARNING + "A plugin named {} has already been loaded. Skipping {}.".format(plugin_name, plugin_map) + bcolors.ENDC)
elif isinstance(plugin_object, MicroscopePlugin): # If plugin_object is an instance of MicroscopePlugin
# Attach plugin_object to the plugin mount
setattr(self, plugin_name, plugin_object)
self.plugins.append((plugin_name, plugin_object))
# Grant plugin access to the hardware
assert(isinstance(plugin_object, MicroscopePlugin))
plugin_object.camera = self.parent.camera
plugin_object.stage = self.parent.stage
plugin_object.microscope = self.parent
print("Adding plugin: {}".format(plugin_name))
logging.info(bcolors.OKGREEN + "Plugin {} loaded as {}.".format(plugin_map, plugin_name) + bcolors.ENDC)
class MicroscopePlugin():
"""
Parent class for all microscope plugins.
Initially only defines an empty object for camera and stage. All plugins
Initially only defines an empty object for microscope. All plugins
must be an instance of this class to successfully attach to PluginMount.
"""
api_views = {} # Initially empty dictionary of API views associated with the plugin
def __init__(self):
self.camera = None #: :py:class:`openflexure_microscope.camera.pi.StreamingCamera`: Picamera object
self.stage = None #: :py:class:`openflexure_stage.stage.OpenFlexureStage`: OpenFlexure stage object
self.microscope = None #: :py:class:`openflexure_microscope.microscope.Microscope`: Microscope object

View file

@ -0,0 +1 @@
from .plugin import FirstPlugin

View file

@ -0,0 +1,14 @@
from openflexure_microscope.plugins import MicroscopePlugin
class FirstPlugin(MicroscopePlugin):
"""
An example Microscope plugin.
"""
def run(self):
"""
Demonstrate access to Microscope.camera, and Microscope.stage
"""
response = "My parent camera is {}, and my parent stage is {}.".format(self.microscope.camera, self.microscope.stage)
return response

View file

@ -1 +1 @@
gunicorn --threads 5 --workers 1 --bind 0.0.0.0:5000 openflexure_microscope.api.v1:app
gunicorn --threads 5 --workers 1 --bind 0.0.0.0:5000 openflexure_microscope.api.app:app

View file

@ -10,13 +10,13 @@ logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
if __name__ == '__main__':
openflexurerc = config.load_config()
microscope = Microscope(StreamingCamera(config=openflexurerc), OpenFlexureStage("/dev/ttyUSB0"))
print(openflexurerc)
microscope.find_plugins() # Automatically find microscope plugins
microscope = Microscope(StreamingCamera(config=openflexurerc), OpenFlexureStage("/dev/ttyUSB0"), config=openflexurerc)
print(microscope.plugin.plugins)
# Check that default tets plugins have loaded
print("RUNNING PLUGIN METHODS:")
microscope.plugin.test1.run()
microscope.plugin.test2.run()
print(microscope.plugin.default.identify())
microscope.close()