diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 4a109823..1ae28f16 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -1,187 +1,118 @@ #!/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 +api_microscope = Microscope(None, None) +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 + logging.debug("First request made. Populating microscope with hardware...") + openflexurerc = config.load_config() # Load default user config - yield (b'--frame\r\n' - b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') + api_microscope.attach( + StreamingCamera(config=openflexurerc), + OpenFlexureStage("/dev/ttyUSB0") + ) + 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 +# 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) +# List all routes +list_routes(app) - return str(response) + "\n" +# Automatically clean up microscope at exit +def cleanup(): + global api_microscope + api_microscope.close() - else: # If GET request +atexit.register(cleanup) - 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) +if __name__ == "__main__": + app.run(host='0.0.0.0', port="5000", threaded=True, debug=True, use_reloader=False) diff --git a/openflexure_microscope/api/utilities.py b/openflexure_microscope/api/utilities.py index f82a6f42..f5b8de98 100644 --- a/openflexure_microscope/api/utilities.py +++ b/openflexure_microscope/api/utilities.py @@ -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 \ No newline at end of file + return False + + +def list_routes(app): + """Print available functions.""" + pprint.pprint(list(map(lambda x: repr(x), app.url_map.iter_rules()))) \ No newline at end of file diff --git a/openflexure_microscope/api/v0.py b/openflexure_microscope/api/v0.py deleted file mode 100644 index 5987a9c0..00000000 --- a/openflexure_microscope/api/v0.py +++ /dev/null @@ -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) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py deleted file mode 100644 index c403894a..00000000 --- a/openflexure_microscope/api/v1.py +++ /dev/null @@ -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 - : 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 - :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 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 - - :
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//'), - 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//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//download/'), - 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 - - :
'), - 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) diff --git a/openflexure_microscope/api/v1/__init__.py b/openflexure_microscope/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openflexure_microscope/api/v1/blueprints/__init__.py b/openflexure_microscope/api/v1/blueprints/__init__.py new file mode 100644 index 00000000..5bee2fc7 --- /dev/null +++ b/openflexure_microscope/api/v1/blueprints/__init__.py @@ -0,0 +1 @@ +from . import camera, stage, base diff --git a/openflexure_microscope/api/v1/blueprints/base.py b/openflexure_microscope/api/v1/blueprints/base.py new file mode 100644 index 00000000..da4205c7 --- /dev/null +++ b/openflexure_microscope/api/v1/blueprints/base.py @@ -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) diff --git a/openflexure_microscope/api/v1/blueprints/camera/__init__.py b/openflexure_microscope/api/v1/blueprints/camera/__init__.py new file mode 100644 index 00000000..c367c97c --- /dev/null +++ b/openflexure_microscope/api/v1/blueprints/camera/__init__.py @@ -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//download/', + view_func=capture.DownloadAPI.as_view('capture_download', microscope=microscope_obj)) + + blueprint.add_url_rule( + '/capture//download', + view_func=capture.DownloadRedirectAPI.as_view('capture_download_redirect', microscope=microscope_obj)) + + blueprint.add_url_rule( + '/capture//', + 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/', + view_func=preview.GPUPreviewAPI.as_view('gpu_preview', microscope=microscope_obj)) + + return(blueprint) \ No newline at end of file diff --git a/openflexure_microscope/api/v1/blueprints/camera/capture.py b/openflexure_microscope/api/v1/blueprints/camera/capture.py new file mode 100644 index 00000000..5fe959cc --- /dev/null +++ b/openflexure_microscope/api/v1/blueprints/camera/capture.py @@ -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 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 + + :
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) \ No newline at end of file diff --git a/openflexure_microscope/api/v1/blueprints/camera/preview.py b/openflexure_microscope/api/v1/blueprints/camera/preview.py new file mode 100644 index 00000000..12d59529 --- /dev/null +++ b/openflexure_microscope/api/v1/blueprints/camera/preview.py @@ -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 + + :
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 + : 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 + :