Merge branch 'v1-blueprints' into 'master'
View blueprints and plugin system See merge request openflexure/openflexure-microscope-software!2
This commit is contained in:
commit
c0c3cf6963
29 changed files with 1025 additions and 1171 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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())))
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
0
openflexure_microscope/api/v1/__init__.py
Normal file
0
openflexure_microscope/api/v1/__init__.py
Normal file
1
openflexure_microscope/api/v1/blueprints/__init__.py
Normal file
1
openflexure_microscope/api/v1/blueprints/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import camera, stage, base, plugins
|
||||
92
openflexure_microscope/api/v1/blueprints/base.py
Normal file
92
openflexure_microscope/api/v1/blueprints/base.py
Normal 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)
|
||||
34
openflexure_microscope/api/v1/blueprints/camera/__init__.py
Normal file
34
openflexure_microscope/api/v1/blueprints/camera/__init__.py
Normal 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)
|
||||
289
openflexure_microscope/api/v1/blueprints/camera/capture.py
Normal file
289
openflexure_microscope/api/v1/blueprints/camera/capture.py
Normal 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)
|
||||
38
openflexure_microscope/api/v1/blueprints/camera/preview.py
Normal file
38
openflexure_microscope/api/v1/blueprints/camera/preview.py
Normal 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)
|
||||
44
openflexure_microscope/api/v1/blueprints/plugins.py
Normal file
44
openflexure_microscope/api/v1/blueprints/plugins.py
Normal 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)
|
||||
177
openflexure_microscope/api/v1/blueprints/stage.py
Normal file
177
openflexure_microscope/api/v1/blueprints/stage.py
Normal 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)
|
||||
26
openflexure_microscope/api/v1/views.py
Normal file
26
openflexure_microscope/api/v1/views.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
1
openflexure_microscope/plugins/default/__init__.py
Normal file
1
openflexure_microscope/plugins/default/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .plugin import Plugin
|
||||
20
openflexure_microscope/plugins/default/api.py
Normal file
20
openflexure_microscope/plugins/default/api.py
Normal 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)
|
||||
30
openflexure_microscope/plugins/default/plugin.py
Normal file
30
openflexure_microscope/plugins/default/plugin.py
Normal 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!"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
1
openflexure_microscope/plugins/test_plugin/__init__.py
Normal file
1
openflexure_microscope/plugins/test_plugin/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .plugin import FirstPlugin
|
||||
14
openflexure_microscope/plugins/test_plugin/plugin.py
Normal file
14
openflexure_microscope/plugins/test_plugin/plugin.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue