From fb6f171e9b73212ad73c9cde539dad949b47800b Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Thu, 15 Nov 2018 13:56:06 +0000 Subject: [PATCH 01/28] Fixed Flask error handler set before app --- openflexure_microscope/api/v1.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index 8c50d524..53b9da62 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -30,11 +30,6 @@ import logging, sys logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) -# Make errors more API friendly -@app.errorhandler(404) -def not_found(error): - return make_response(jsonify({'error': 'Not found'}), 404) - # Create the microscope object globally (common to all spawned server threads) microscope = Microscope( StreamingCamera(), @@ -44,6 +39,11 @@ microscope = Microscope( # Create flask app app = Flask(__name__) +# Make errors more API friendly +@app.errorhandler(404) +def not_found(error): + return make_response(jsonify({'error': 'Not found'}), 404) + # Some useful functions def uri(suffix, base='/api/v1'): return base + suffix From f106c2ecba5223c8e5e2cb475ea33fcd547da048 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Thu, 15 Nov 2018 17:18:04 +0000 Subject: [PATCH 02/28] Started testing pluggable views --- openflexure_microscope/api/app.py | 187 ++++++++++++++++++ openflexure_microscope/api/doc/Makefile | 20 ++ openflexure_microscope/api/doc/make.bat | 36 ++++ .../api/doc/requirements.txt | 3 + openflexure_microscope/api/doc/source/conf.py | 172 ++++++++++++++++ .../api/doc/source/index.rst | 36 ++++ .../api/templates/index.html | 9 + openflexure_microscope/api/v1.py | 48 ++++- openflexure_microscope/microscope.py | 2 + 9 files changed, 503 insertions(+), 10 deletions(-) create mode 100644 openflexure_microscope/api/app.py create mode 100644 openflexure_microscope/api/doc/Makefile create mode 100644 openflexure_microscope/api/doc/make.bat create mode 100644 openflexure_microscope/api/doc/requirements.txt create mode 100644 openflexure_microscope/api/doc/source/conf.py create mode 100644 openflexure_microscope/api/doc/source/index.rst create mode 100644 openflexure_microscope/api/templates/index.html diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py new file mode 100644 index 00000000..4a109823 --- /dev/null +++ b/openflexure_microscope/api/app.py @@ -0,0 +1,187 @@ +#!/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.camera.pi import StreamingCamera + +app = Flask(__name__) +cam = StreamingCamera() + + +def parse_payload(request): + """Convert request to JSON. Will eventually handle error-checking.""" + # TODO: Handle invalid JSON payloads + state = request.get_json() + return state + + +def gen(camera): + """Video streaming generator function.""" + while True: + # the obtained frame is a jpeg + frame = camera.get_frame() + + yield (b'--frame\r\n' + b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') + + +@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. + """ + 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/doc/Makefile b/openflexure_microscope/api/doc/Makefile new file mode 100644 index 00000000..8663bf75 --- /dev/null +++ b/openflexure_microscope/api/doc/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = openflexure_microscope_api +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/openflexure_microscope/api/doc/make.bat b/openflexure_microscope/api/doc/make.bat new file mode 100644 index 00000000..5c929a79 --- /dev/null +++ b/openflexure_microscope/api/doc/make.bat @@ -0,0 +1,36 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build +set SPHINXPROJ=openflexure_microscope_api + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/openflexure_microscope/api/doc/requirements.txt b/openflexure_microscope/api/doc/requirements.txt new file mode 100644 index 00000000..45ac0217 --- /dev/null +++ b/openflexure_microscope/api/doc/requirements.txt @@ -0,0 +1,3 @@ +Sphinx +sphinxcontrib-httpdomain +sphinx_rtd_theme \ No newline at end of file diff --git a/openflexure_microscope/api/doc/source/conf.py b/openflexure_microscope/api/doc/source/conf.py new file mode 100644 index 00000000..2d3c8f87 --- /dev/null +++ b/openflexure_microscope/api/doc/source/conf.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = 'openflexure_microscope_api' +copyright = '2018, Joel Collins' +author = 'Joel Collins' + +# The short X.Y version +version = '' +# The full version, including alpha/beta/rc tags +release = '0.0.1' + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.ifconfig', + 'sphinxcontrib.httpdomain', + 'sphinxcontrib.autohttp.flask', + 'sphinxcontrib.autohttp.flaskqref', +] + +# Override ordering +autodoc_member_order = 'bysource' + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path . +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'openflexure_microscope_apidoc' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'openflexure_microscope_api.tex', 'openflexure\\_microscope\\_api Documentation', + 'Joel Collins', 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'openflexure_microscope_api', 'openflexure_microscope_api Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'openflexure_microscope_api', 'openflexure_microscope_api Documentation', + author, 'openflexure_microscope_api', 'One line description of project.', + 'Miscellaneous'), +] + + +# -- Extension configuration ------------------------------------------------- + +# -- Options for intersphinx extension --------------------------------------- + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'https://docs.python.org/': None} \ No newline at end of file diff --git a/openflexure_microscope/api/doc/source/index.rst b/openflexure_microscope/api/doc/source/index.rst new file mode 100644 index 00000000..2fd5d45a --- /dev/null +++ b/openflexure_microscope/api/doc/source/index.rst @@ -0,0 +1,36 @@ +.. openflexure_microscope_api documentation master file, created by + sphinx-quickstart on Thu Nov 15 11:08:18 2018. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +OpenFlexure Microscope API +====================================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + + + +API Documentation +================= + +Summary +------- +.. qrefflask:: openflexure_microscope.api.v1:app + :undoc-static: + +API Details +----------- + +.. autoflask:: openflexure_microscope.api.v1:app + :undoc-static: \ No newline at end of file diff --git a/openflexure_microscope/api/templates/index.html b/openflexure_microscope/api/templates/index.html new file mode 100644 index 00000000..d1f67f86 --- /dev/null +++ b/openflexure_microscope/api/templates/index.html @@ -0,0 +1,9 @@ + + + Video Streaming Demonstration + + +

Video Streaming Demonstration

+ + + diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index 53b9da62..f0e21cc2 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -17,6 +17,7 @@ from flask import ( redirect, request, jsonify, send_file, abort, make_response) +from flask.views import MethodView import numpy as np @@ -29,21 +30,22 @@ from openflexure_stage import OpenFlexureStage import logging, sys logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) +logging.info("Creating StreamingCamera") +cam = StreamingCamera() +logging.info("Creating stage") +stage = OpenFlexureStage("/dev/ttyUSB0") # Create the microscope object globally (common to all spawned server threads) +logging.info("Creating microscope") microscope = Microscope( - StreamingCamera(), - OpenFlexureStage("/dev/ttyUSB0") + cam, + stage ) +logging.info("Creating app") # Create flask app app = Flask(__name__) -# Make errors more API friendly -@app.errorhandler(404) -def not_found(error): - return make_response(jsonify({'error': 'Not found'}), 404) - # Some useful functions def uri(suffix, base='/api/v1'): return base + suffix @@ -57,6 +59,30 @@ def index(): 'index_v1.html' ) + +##### TESTING CLASS STUFFS ###### +class MicroscopeView(MethodView): + + def __init__(self, microscope_obj): + self.microscope_obj = microscope_obj + + +class TestAPI(MicroscopeView): + + def get(self): + """ + Get method for my cool new MethodView + """ + return jsonify(self.microscope_obj.state) + + def post(self): + """ + Post method for my cool new MethodView + """ + return jsonify(request.get_json()) + +app.add_url_rule('/test', view_func=TestAPI.as_view('test', microscope_obj=microscope)) + # Define API routes # Basic routes @@ -84,7 +110,9 @@ def state(): @app.route(uri('/position'), methods=['GET', 'POST', 'PUT']) def position(): - """Set and get the microscope stage position""" + """ + Set and get the microscope stage position + """ global microscope if request.method == 'POST' or request.method == 'PUT': @@ -249,5 +277,5 @@ def thumb_capture(capture_uuid): capture_obj.thumbnail, mimetype='image/jpeg') -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/microscope.py b/openflexure_microscope/microscope.py index 355f23ea..672f1670 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -8,7 +8,9 @@ from .camera.pi import StreamingCamera class Microscope(object): def __init__(self, camera: StreamingCamera, stage: OpenFlexureStage): """Create the microscope object. The camera and stage should already be initialised.""" + print("Assigning camera") self.camera = camera + print("Assigning stage") self.stage = stage self.stage.backlash = np.zeros(3, dtype=np.int) From 5fb200eefe0d50dc8d80ca8a5ce2392656f8dc56 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Thu, 15 Nov 2018 17:31:08 +0000 Subject: [PATCH 03/28] Moved stream to new view model --- openflexure_microscope/api/v1.py | 46 +++++++++++++------------------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index f0e21cc2..36828a1d 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -60,44 +60,34 @@ def index(): ) -##### TESTING CLASS STUFFS ###### +##### Basic microscope view ###### class MicroscopeView(MethodView): - def __init__(self, microscope_obj): - self.microscope_obj = microscope_obj - - -class TestAPI(MicroscopeView): - - def get(self): + def __init__(self, microscope): """ - Get method for my cool new MethodView + Create a generic MethodView with a globally available + microscope object passed as an argument. """ - return jsonify(self.microscope_obj.state) - - def post(self): - """ - Post method for my cool new MethodView - """ - return jsonify(request.get_json()) - -app.add_url_rule('/test', view_func=TestAPI.as_view('test', microscope_obj=microscope)) + self.microscope = microscope # Define API routes -# Basic routes +# Basic views -@app.route(uri('/stream')) -def stream(): - """Video streaming route. Put this in the src attribute of an img tag.""" - global microscope +class StreamAPI(MicroscopeView): + def get(self): + """ + Video streaming route. Put this in the src attribute of an img tag. + """ + # Restart stream worker thread + microscope.camera.start_worker() - # Restart stream worker thread - 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=microscope)) - return Response( - gen(microscope.camera), - mimetype='multipart/x-mixed-replace; boundary=frame') @app.route(uri('/state')) def state(): From a274c561e7361b58a229704c1d69b53afe232475 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Thu, 15 Nov 2018 18:31:10 +0000 Subject: [PATCH 04/28] Finished first batch of MethodView-based routes --- openflexure_microscope/api/static/main_v1.js | 2 +- openflexure_microscope/api/v1.py | 214 ++++++++++--------- 2 files changed, 118 insertions(+), 98 deletions(-) diff --git a/openflexure_microscope/api/static/main_v1.js b/openflexure_microscope/api/static/main_v1.js index 3371fded..2ed7ba3c 100644 --- a/openflexure_microscope/api/static/main_v1.js +++ b/openflexure_microscope/api/static/main_v1.js @@ -67,7 +67,7 @@ function updateCaptures(response) { // Generate inner HTML from capture object html = `
-
+
${element.file}
View Download Delete JSON diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index 36828a1d..c2ab1d7e 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -69,18 +69,37 @@ class MicroscopeView(MethodView): microscope object passed as an argument. """ self.microscope = microscope + MethodView.__init__(self) + +# Test method +class TestAPI(MicroscopeView): + + def get(self): + """ + Get method for my cool new MethodView + """ + return jsonify(self.microscope.state) + + def post(self): + """ + Post method for my cool new MethodView + """ + return jsonify(request.get_json()) + +app.add_url_rule('/test', view_func=TestAPI.as_view('test', microscope=microscope)) # Define API routes # Basic views + class StreamAPI(MicroscopeView): def get(self): """ Video streaming route. Put this in the src attribute of an img tag. """ # Restart stream worker thread - microscope.camera.start_worker() + self.microscope.camera.start_worker() return Response( gen(self.microscope.camera), @@ -89,23 +108,30 @@ class StreamAPI(MicroscopeView): app.add_url_rule(uri('/stream'), view_func=StreamAPI.as_view('stream', microscope=microscope)) -@app.route(uri('/state')) -def state(): - """Return JSONified microscope state""" - global microscope +class StateAPI(MicroscopeView): + def get(self): + """ + Return JSONified microscope state. + """ + return jsonify(self.microscope.state) + +app.add_url_rule(uri('/state'), view_func=StateAPI.as_view('state', microscope=microscope)) - return jsonify(microscope.state) # Positioning routes -@app.route(uri('/position'), methods=['GET', 'POST', 'PUT']) -def position(): - """ - Set and get the microscope stage position - """ - global microscope +class PositionAPI(MicroscopeView): - if request.method == 'POST' or request.method == 'PUT': + def get(self): + """ + Get current position + """ + return jsonify(self.microscope.state['position']) + + def post(self): + """ + Update current position + """ # Get payload state = parse_payload(request) logging.debug(state) @@ -118,7 +144,7 @@ def position(): # Get coordinates from payload for axis, key in enumerate(['x', 'y', 'z']): if key in state: - position[axis] = int(state[key]-microscope.stage.position[axis]) + position[axis] = int(state[key]-self.microscope.stage.position[axis]) else: # Get coordinates from payload @@ -139,21 +165,60 @@ def position(): response = {'error': 'Cannot move to absolute position beyond the safeguard limit.'} return jsonify(response), 400 - microscope.stage.move_rel(position) + self.microscope.stage.move_rel(position) - return jsonify(microscope.state['position']) + return jsonify(self.microscope.state['position']) + +app.add_url_rule(uri('/position'), view_func=PositionAPI.as_view('position', microscope=microscope)) # Capture routes +class CaptureAPI(MicroscopeView): -@app.route(uri('/capture/'), methods=['GET', 'POST', 'PUT']) -def capture(): - """Return JSONified microscope state""" - global microscope + def get(self, capture_id): + """ + Get list of image captures + """ + if not capture_id: + include_unavailable = get_bool(request.args.get('include_unavailable')) - if request.method == 'POST' or request.method == 'PUT': + if include_unavailable: + captures = [image.metadata for image in self.microscope.camera.images if image.metadata['available']] + else: + captures = [image.metadata for image in self.microscope.camera.images] + + return jsonify(captures) + else: + print(capture_id) + capture_obj = 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 + + # 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_id)) + + capture_metadata.update(uri_dict) + + return jsonify(capture_metadata) + + def delete(self, capture_id): + return jsonify({"error": "not yet implemented"}) + + def post(self): + """ + Create a new image capture + """ state = parse_payload(request) - print(state) if 'filename' in state: filename = state['filename'] @@ -178,7 +243,7 @@ def capture(): else: resize = None - capture_obj = microscope.camera.capture( + 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, @@ -187,85 +252,40 @@ def capture(): return jsonify(capture_obj.metadata) - else: # GET requests - include_unavailable = get_bool(request.args.get('include_unavailable')) +capture_view = CaptureAPI.as_view('capture', microscope=microscope) +app.add_url_rule(uri('/capture/'), defaults={'capture_id': None}, view_func=capture_view, methods=['GET',]) +app.add_url_rule(uri('/capture//'), view_func=capture_view, methods=['GET', 'DELETE']) +app.add_url_rule(uri('/capture/'), view_func=capture_view, methods=['POST',]) - if include_unavailable: - captures = [image for image in microscope.camera.images if image.metadata['available']] + +# Capture download routes +class CaptureDlAPI(MicroscopeView): + def get(self, capture_id): + """ + Return image data for a capture. + """ + print(capture_id) + capture_obj = microscope.camera.image_from_id(capture_id) + + if not capture_obj: + return abort(404) # 404 Not Found + + as_attachment = get_bool(request.args.get('as_attachment')) + thumbnail = get_bool(request.args.get('thumbnail')) + + if thumbnail: + img = capture_obj.thumbnail else: - captures = microscope.camera.images + img = capture_obj.data - metadata_array = [capture.metadata for capture in captures] + return send_file( + img, + mimetype='image/jpeg', + as_attachment=as_attachment, + attachment_filename=capture_obj.filename) - return jsonify(metadata_array) +app.add_url_rule(uri('/capture//download/'), view_func=CaptureDlAPI.as_view('capture_download', microscope=microscope)) -@app.route(uri('/capture//'), methods=['GET', 'DELETE']) -def get_capture(capture_uuid): - """Return JSONified capture by UUID""" - global microscope - - print(capture_uuid) - capture_obj = microscope.camera.image_from_id(capture_uuid) - - if not capture_obj: - return abort(404) # 404 Not Found - - # Get capture metadata - capture_metadata = capture_obj.metadata - - # Add API routes to returned metadata - uri_dict = { - 'uri': {'metadata': uri('/capture/{}/'.format(capture_uuid))} - } - - # If available, also add download link - if capture_metadata['available']: - uri_dict['uri']['download'] = uri('/capture/{}/download'.format(capture_uuid)) - - capture_metadata.update(uri_dict) - - if request.method == 'DELETE': - print("DELETE NOT YET IMPLEMENETED") - return jsonify(capture_metadata) - - else: # GET requests - return jsonify(capture_metadata) - - -@app.route(uri('/capture//download'), methods=['GET']) -def download_capture(capture_uuid): - """Return capture file by UUID""" - global microscope - - print(capture_uuid) - capture_obj = microscope.camera.image_from_id(capture_uuid) - - if not capture_obj: - return abort(404) # 404 Not Found - - as_attachment = get_bool(request.args.get('as_attachment')) - - return send_file( - capture_obj.data, - mimetype='image/jpeg', - as_attachment=as_attachment, - attachment_filename=capture_obj.filename) - -@app.route(uri('/capture//thumbnail'), methods=['GET']) -def thumb_capture(capture_uuid): - """Return capture thumbnail by UUID""" - global microscope - - print(capture_uuid) - capture_obj = microscope.camera.image_from_id(capture_uuid) - - if not capture_obj: - return abort(404) # 404 Not Found - - return send_file( - capture_obj.thumbnail, - mimetype='image/jpeg') - if __name__ == "__main__": app.run(host='0.0.0.0', port="5000", threaded=True, debug=True, use_reloader=False) From 6b62737aaf75e4badbae3fc40e85e92d77b8f4b8 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Thu, 15 Nov 2018 22:06:55 +0000 Subject: [PATCH 05/28] Restructures API views --- .../api/doc/source/index.rst | 7 +- openflexure_microscope/api/v1.py | 174 ++++++++++-------- 2 files changed, 102 insertions(+), 79 deletions(-) diff --git a/openflexure_microscope/api/doc/source/index.rst b/openflexure_microscope/api/doc/source/index.rst index 2fd5d45a..436aabf7 100644 --- a/openflexure_microscope/api/doc/source/index.rst +++ b/openflexure_microscope/api/doc/source/index.rst @@ -27,10 +27,15 @@ API Documentation Summary ------- .. qrefflask:: openflexure_microscope.api.v1:app + :undoc-endpoints: index :undoc-static: + :endpoints: API Details ----------- .. autoflask:: openflexure_microscope.api.v1:app - :undoc-static: \ No newline at end of file + :undoc-endpoints: index + :undoc-static: + :endpoints: + :order: path \ No newline at end of file diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index c2ab1d7e..a3a4d224 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -7,7 +7,7 @@ TODO: Implement API route to cleanly shut down server TODO: Implement microscope function API routes (autofocus etc) """ -from pprint import pprint +import numpy as np from importlib import import_module import time import datetime @@ -19,8 +19,6 @@ from flask import ( from flask.views import MethodView -import numpy as np - from openflexure_microscope.api.utilities import parse_payload, gen, get_bool from openflexure_microscope import Microscope @@ -28,39 +26,43 @@ from openflexure_microscope.camera.pi import StreamingCamera from openflexure_stage import OpenFlexureStage import logging, sys + logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) -logging.info("Creating StreamingCamera") -cam = StreamingCamera() -logging.info("Creating stage") -stage = OpenFlexureStage("/dev/ttyUSB0") - # Create the microscope object globally (common to all spawned server threads) -logging.info("Creating microscope") -microscope = Microscope( - cam, - stage +api_microscope = Microscope( + StreamingCamera(), + OpenFlexureStage("/dev/ttyUSB0") ) -logging.info("Creating app") # Create flask app app = Flask(__name__) +# Make errors more API friendly +@app.errorhandler(404) +def not_found(error): + return make_response(jsonify({'error': 'Not found'}), 404) + # Some useful functions +# TODO: Maybe auto-generate API URI base from module name def uri(suffix, base='/api/v1'): return base + suffix -# Define front-end routes + +##### WEBAPP ROUTES ###### @app.route('/') def index(): - """Video streaming home page.""" + """ + API demo app + """ return render_template( 'index_v1.html' ) +##### API ROUTES ###### -##### Basic microscope view ###### +# Basic microscope view class MicroscopeView(MethodView): def __init__(self, microscope): @@ -69,31 +71,14 @@ class MicroscopeView(MethodView): microscope object passed as an argument. """ self.microscope = microscope + MethodView.__init__(self) -# Test method -class TestAPI(MicroscopeView): - - def get(self): - """ - Get method for my cool new MethodView - """ - return jsonify(self.microscope.state) - - def post(self): - """ - Post method for my cool new MethodView - """ - return jsonify(request.get_json()) - -app.add_url_rule('/test', view_func=TestAPI.as_view('test', microscope=microscope)) - -# Define API routes - -# Basic views +# State endpoints class StreamAPI(MicroscopeView): + def get(self): """ Video streaming route. Put this in the src attribute of an img tag. @@ -105,20 +90,25 @@ class StreamAPI(MicroscopeView): gen(self.microscope.camera), mimetype='multipart/x-mixed-replace; boundary=frame') -app.add_url_rule(uri('/stream'), view_func=StreamAPI.as_view('stream', microscope=microscope)) +app.add_url_rule( + uri('/stream/'), + view_func=StreamAPI.as_view('stream', microscope=api_microscope)) class StateAPI(MicroscopeView): + def get(self): """ Return JSONified microscope state. """ return jsonify(self.microscope.state) -app.add_url_rule(uri('/state'), view_func=StateAPI.as_view('state', microscope=microscope)) +app.add_url_rule( + uri('/state/'), + view_func=StateAPI.as_view('state', microscope=api_microscope)) -# Positioning routes +# Positioning endpoints class PositionAPI(MicroscopeView): @@ -169,49 +159,33 @@ class PositionAPI(MicroscopeView): return jsonify(self.microscope.state['position']) -app.add_url_rule(uri('/position'), view_func=PositionAPI.as_view('position', microscope=microscope)) +app.add_url_rule( + uri('/position/'), + view_func=PositionAPI.as_view('position', microscope=api_microscope)) -# Capture routes +# Capture endpoints + class CaptureAPI(MicroscopeView): - def get(self, capture_id): + def get(self): """ Get list of image captures """ - if not capture_id: - include_unavailable = get_bool(request.args.get('include_unavailable')) + include_unavailable = get_bool(request.args.get('include_unavailable')) - if include_unavailable: - captures = [image.metadata for image in self.microscope.camera.images if image.metadata['available']] - else: - captures = [image.metadata for image in self.microscope.camera.images] - - return jsonify(captures) + if include_unavailable: + captures = [image.metadata for image in self.microscope.camera.images if image.metadata['available']] else: - print(capture_id) - capture_obj = microscope.camera.image_from_id(capture_id) + captures = [image.metadata for image in self.microscope.camera.images] - if not capture_obj: - return abort(404) # 404 Not Found + return jsonify(captures) - # Get capture metadata - capture_metadata = capture_obj.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_id)) - - capture_metadata.update(uri_dict) - - return jsonify(capture_metadata) - - def delete(self, capture_id): + def delete(self): + """ + Delete all captures + """ return jsonify({"error": "not yet implemented"}) def post(self): @@ -252,20 +226,62 @@ class CaptureAPI(MicroscopeView): return jsonify(capture_obj.metadata) -capture_view = CaptureAPI.as_view('capture', microscope=microscope) -app.add_url_rule(uri('/capture/'), defaults={'capture_id': None}, view_func=capture_view, methods=['GET',]) -app.add_url_rule(uri('/capture//'), view_func=capture_view, methods=['GET', 'DELETE']) -app.add_url_rule(uri('/capture/'), view_func=capture_view, methods=['POST',]) +app.add_url_rule( + uri('/capture/'), + view_func=CaptureAPI.as_view('capture', microscope=api_microscope)) -# Capture download routes -class CaptureDlAPI(MicroscopeView): +class CaptureIdAPI(MicroscopeView): + + def get(self, capture_id): + """ + Get JSON representation of a capture + """ + 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 + + # 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_id)) + + capture_metadata.update(uri_dict) + + return jsonify(capture_metadata) + + def delete(self, capture_id): + """ + Delete a capture + """ + return jsonify({"return": capture_id}) + + def put(self, capture_id): + """ + Modify the metadata of a capture + """ + return jsonify({"return": capture_id}) + +app.add_url_rule( + uri('/capture//'), + view_func=CaptureIdAPI.as_view('capture_id', microscope=api_microscope)) + + +class CaptureDownloadAPI(MicroscopeView): def get(self, capture_id): """ Return image data for a capture. """ print(capture_id) - capture_obj = microscope.camera.image_from_id(capture_id) + capture_obj = self.microscope.camera.image_from_id(capture_id) if not capture_obj: return abort(404) # 404 Not Found @@ -284,7 +300,9 @@ class CaptureDlAPI(MicroscopeView): as_attachment=as_attachment, attachment_filename=capture_obj.filename) -app.add_url_rule(uri('/capture//download/'), view_func=CaptureDlAPI.as_view('capture_download', microscope=microscope)) +app.add_url_rule( + uri('/capture//download/'), + view_func=CaptureDownloadAPI.as_view('capture_download', microscope=api_microscope)) if __name__ == "__main__": From c6b99c3284a1af778818a1288a1e25c27168a80d Mon Sep 17 00:00:00 2001 From: jtc42 Date: Thu, 15 Nov 2018 22:47:55 +0000 Subject: [PATCH 06/28] Started adding Sphinx stuff to docstrings --- openflexure_microscope/api/v1.py | 45 +++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index a3a4d224..1c7ba1cf 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -114,13 +114,25 @@ class PositionAPI(MicroscopeView): def get(self): """ - Get current position + Return current x, y and z positions of the stage. + + .. :quickref: Position; Get current position """ return jsonify(self.microscope.state['position']) def post(self): """ - Update current position + Set x, y and z positions of the stage. + + .. :quickref: Position; Update current position + + :reqheader Accept: application/json + : Date: Fri, 16 Nov 2018 08:27:26 +0000 Subject: [PATCH 07/28] Renamed CaptureAPI views --- openflexure_microscope/api/v1.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index 1c7ba1cf..acce7fad 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -178,7 +178,7 @@ app.add_url_rule( # Capture endpoints -class CaptureAPI(MicroscopeView): +class CaptureListAPI(MicroscopeView): def get(self): """ @@ -253,10 +253,10 @@ class CaptureAPI(MicroscopeView): app.add_url_rule( uri('/capture/'), - view_func=CaptureAPI.as_view('capture', microscope=api_microscope)) + view_func=CaptureListAPI.as_view('capture_list', microscope=api_microscope)) -class CaptureIdAPI(MicroscopeView): +class CaptureAPI(MicroscopeView): def get(self, capture_id): """ @@ -303,7 +303,7 @@ class CaptureIdAPI(MicroscopeView): app.add_url_rule( uri('/capture//'), - view_func=CaptureIdAPI.as_view('capture_id', microscope=api_microscope)) + view_func=CaptureAPI.as_view('capture', microscope=api_microscope)) class CaptureDownloadAPI(MicroscopeView): From e8446ad66daa39137b3a65d2e6713b4985569e32 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Fri, 16 Nov 2018 08:34:55 +0000 Subject: [PATCH 08/28] Updated todo --- openflexure_microscope/api/v1.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index acce7fad..6e287473 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -219,6 +219,7 @@ class CaptureListAPI(MicroscopeView): """ state = parse_payload(request) + # TODO: Roll all of these ugly if statements into a method for getting payload elements if 'filename' in state: filename = state['filename'] else: @@ -238,6 +239,7 @@ class CaptureListAPI(MicroscopeView): if 'width' in state['size'] and 'height' in state['size']: resize = (state['size']['width'], state['size']['height']) else: + # TODO: Return error 4XX instead of exception raise Exception("Invalid resize parameters passed. Ensure both width and height are specified.") else: resize = None From 07c5c0115dfbd5850751f47f5830be33a41bc7ed Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 16 Nov 2018 10:21:13 +0000 Subject: [PATCH 09/28] Updated TODO --- openflexure_microscope/api/v1.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index 6e287473..f006b1b3 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -274,6 +274,7 @@ class CaptureAPI(MicroscopeView): # 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))} From 9d46ac1ca5a689c54131e9454f3b70cdffb8718e Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 16 Nov 2018 10:21:24 +0000 Subject: [PATCH 10/28] Updated returned capture metadata --- openflexure_microscope/api/static/main_v1.js | 2 +- openflexure_microscope/camera/capture.py | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/openflexure_microscope/api/static/main_v1.js b/openflexure_microscope/api/static/main_v1.js index 2ed7ba3c..8eda3ea7 100644 --- a/openflexure_microscope/api/static/main_v1.js +++ b/openflexure_microscope/api/static/main_v1.js @@ -69,7 +69,7 @@ function updateCaptures(response) {
- ${element.file}
+ ${element.filename}
View Download Delete JSON
diff --git a/openflexure_microscope/camera/capture.py b/openflexure_microscope/camera/capture.py index 34fb3339..7094967b 100644 --- a/openflexure_microscope/camera/capture.py +++ b/openflexure_microscope/camera/capture.py @@ -144,16 +144,10 @@ class StreamObject(object): 'id': self.id, 'locked': self.locked, 'keep_on_disk': self.keep_on_disk, + 'filename': self.filename, 'path': self.file, - 'context_manager': self.context_manager, } - # Get file path - if self.file_exists: - d['file'] = self.filename - else: - d['file'] = None - # Check stream if self.stream_exists: d['stream'] = True From dd7a3ff2e5a5222f8d0b4ad829d35af1434e4bed Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 16 Nov 2018 12:04:54 +0000 Subject: [PATCH 11/28] Removed pointless image_recent from microscope state --- openflexure_microscope/camera/pi.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index 9e631251..3cd0bd21 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -382,9 +382,6 @@ class StreamingCamera(BaseCamera): bayer=False, use_video_port=True) - # Update state dictionary - self.state['image_recent'] = str(target_obj) - return target_obj def array( From ea10cf1ce9e99602688390a4e2d23397a86b5156 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 16 Nov 2018 15:46:19 +0000 Subject: [PATCH 12/28] Created initial general documentation --- doc/Makefile | 19 +++ doc/make.bat | 35 ++++ doc/requirements.txt | 3 + doc/source/api.rst | 24 +++ doc/source/basecamera.rst | 5 + doc/source/camera.rst | 10 ++ doc/source/capture.rst | 5 + doc/source/conf.py | 202 +++++++++++++++++++++++ doc/source/index.rst | 18 ++ doc/source/microscope.rst | 9 + doc/source/picamera.rst | 5 + openflexure_microscope/api/v1.py | 197 ++++++++++++++++++++-- openflexure_microscope/camera/base.py | 31 ++-- openflexure_microscope/camera/capture.py | 3 + openflexure_microscope/camera/pi.py | 91 +++++----- openflexure_microscope/microscope.py | 23 ++- 16 files changed, 607 insertions(+), 73 deletions(-) create mode 100644 doc/Makefile create mode 100644 doc/make.bat create mode 100644 doc/requirements.txt create mode 100644 doc/source/api.rst create mode 100644 doc/source/basecamera.rst create mode 100644 doc/source/camera.rst create mode 100644 doc/source/capture.rst create mode 100644 doc/source/conf.py create mode 100644 doc/source/index.rst create mode 100644 doc/source/microscope.rst create mode 100644 doc/source/picamera.rst diff --git a/doc/Makefile b/doc/Makefile new file mode 100644 index 00000000..69fe55ec --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,19 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/doc/make.bat b/doc/make.bat new file mode 100644 index 00000000..4d9eb83d --- /dev/null +++ b/doc/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 00000000..45ac0217 --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,3 @@ +Sphinx +sphinxcontrib-httpdomain +sphinx_rtd_theme \ No newline at end of file diff --git a/doc/source/api.rst b/doc/source/api.rst new file mode 100644 index 00000000..c27f6f60 --- /dev/null +++ b/doc/source/api.rst @@ -0,0 +1,24 @@ +REST API +====================================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + +API Documentation +================= + +Summary +------- +.. qrefflask:: openflexure_microscope.api.v1:app + :undoc-endpoints: index + :undoc-static: + :endpoints: + +Details +----------- +.. autoflask:: openflexure_microscope.api.v1:app + :undoc-endpoints: index + :undoc-static: + :endpoints: + :order: path \ No newline at end of file diff --git a/doc/source/basecamera.rst b/doc/source/basecamera.rst new file mode 100644 index 00000000..a29e74a7 --- /dev/null +++ b/doc/source/basecamera.rst @@ -0,0 +1,5 @@ +Base Streaming Camera +======================================================= + +.. automodule:: openflexure_microscope.camera.base + :members: \ No newline at end of file diff --git a/doc/source/camera.rst b/doc/source/camera.rst new file mode 100644 index 00000000..c14dd770 --- /dev/null +++ b/doc/source/camera.rst @@ -0,0 +1,10 @@ +Camera Functionality +======================================================= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + picamera.rst + basecamera.rst + capture.rst \ No newline at end of file diff --git a/doc/source/capture.rst b/doc/source/capture.rst new file mode 100644 index 00000000..f1dc035a --- /dev/null +++ b/doc/source/capture.rst @@ -0,0 +1,5 @@ +Stream Object +======================================================= + +.. automodule:: openflexure_microscope.camera.capture + :members: \ No newline at end of file diff --git a/doc/source/conf.py b/doc/source/conf.py new file mode 100644 index 00000000..580a1c32 --- /dev/null +++ b/doc/source/conf.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = 'OpenFlexure Microscope Software' +copyright = '2018, Bath Open Instrumentation Group' +author = 'Bath Open Instrumentation Group' + +# The short X.Y version +version = '' +# The full version, including alpha/beta/rc tags +release = '' + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.napoleon', + 'sphinx.ext.intersphinx', + 'sphinx.ext.todo', + 'sphinx.ext.viewcode', + 'sphinx.ext.githubpages', + 'sphinx.ext.ifconfig', + 'sphinxcontrib.httpdomain', + 'sphinxcontrib.autohttp.flask', + 'sphinxcontrib.autohttp.flaskqref', +] + +# Override ordering +autodoc_member_order = 'bysource' + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = None + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'OpenFlexureMicroscopeSoftwaredoc' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'OpenFlexureMicroscopeSoftware.tex', 'OpenFlexure Microscope Software Documentation', + 'Bath Open Instrumentation Group', 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'openflexuremicroscopesoftware', 'OpenFlexure Microscope Software Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'OpenFlexureMicroscopeSoftware', 'OpenFlexure Microscope Software Documentation', + author, 'OpenFlexureMicroscopeSoftware', 'One line description of project.', + 'Miscellaneous'), +] + + +# -- Options for Epub output ------------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# +# epub_identifier = '' + +# A unique identification for the text. +# +# epub_uid = '' + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + + +# -- Extension configuration ------------------------------------------------- + +# -- Options for intersphinx extension --------------------------------------- + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = { + 'openflexure_stage': ('https://openflexure-stage.readthedocs.io/en/latest/', None), + 'picamera': ('https://picamera.readthedocs.io/en/release-1.13/', None) + } + +# -- Options for todo extension ---------------------------------------------- + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True \ No newline at end of file diff --git a/doc/source/index.rst b/doc/source/index.rst new file mode 100644 index 00000000..fc003f62 --- /dev/null +++ b/doc/source/index.rst @@ -0,0 +1,18 @@ +Welcome to OpenFlexure Microscope Software's documentation! +=========================================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + microscope.rst + camera.rst + api.rst + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/doc/source/microscope.rst b/doc/source/microscope.rst new file mode 100644 index 00000000..13cd25ab --- /dev/null +++ b/doc/source/microscope.rst @@ -0,0 +1,9 @@ +Microscope class +======================================================= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + +.. automodule:: openflexure_microscope.microscope + :members: \ No newline at end of file diff --git a/doc/source/picamera.rst b/doc/source/picamera.rst new file mode 100644 index 00000000..4f7d8cdc --- /dev/null +++ b/doc/source/picamera.rst @@ -0,0 +1,5 @@ +Raspberry Pi Streaming Camera +======================================================= + +.. automodule:: openflexure_microscope.camera.pi + :members: \ No newline at end of file diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index f006b1b3..486f1f67 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -81,7 +81,13 @@ class StreamAPI(MicroscopeView): def get(self): """ - Video streaming route. Put this in the src attribute of an img tag. + 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() @@ -99,7 +105,39 @@ class StateAPI(MicroscopeView): def get(self): """ - Return JSONified microscope state. + 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 + + { + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "preview_active": false, + "record_active": false, + "stream_active": true + } + + :>header Accept: application/json + :>header Content-Type: application/json + :status 200: state available """ return jsonify(self.microscope.state) @@ -115,17 +153,42 @@ 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 /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['position']) def post(self): """ Set x, y and z positions of the stage. - + .. :quickref: Position; Update current position - + :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')) @@ -197,8 +277,8 @@ class CaptureListAPI(MicroscopeView): def delete(self): """ - Delete all captures. - + Delete all captures (not yet implemented) + .. :quickref: Capture collection; Delete all captures """ return jsonify({"error": "not yet implemented"}) @@ -206,16 +286,47 @@ class CaptureListAPI(MicroscopeView): def post(self): """ Create a new image capture. - + .. :quickref: Capture collection; New capture - - :reqheader Accept: application/json + + **Example request**: + + .. sourcecode:: http + + POST /position/ 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) @@ -290,7 +441,7 @@ class CaptureAPI(MicroscopeView): def delete(self, capture_id): """ - Delete a capture + Delete a capture (not yet implemented) .. :quickref: Capture; Delete capture """ @@ -298,7 +449,7 @@ class CaptureAPI(MicroscopeView): def put(self, capture_id): """ - Modify the metadata of a capture + Modify the metadata of a capture (not yet implemented) .. :quickref: Capture; Update capture metadata """ @@ -315,6 +466,20 @@ class CaptureDownloadAPI(MicroscopeView): Return image data for a capture. .. :quickref: Capture; Download capture file + + **Example request**: + + .. sourcecode:: http + + GET /capture/d0b2067abbb946f19351e075c5e7cd5b/download?thumbnail=true HTTP/1.1 + Accept: image/jpeg + + :>header Accept: image/jpeg + :query thumbnail: return an image thumbnail e.g. ?thumbnail=true + + :>header Content-Type: image/jpeg + :status 200: capture data found + :status 404: no capture found with that id """ print(capture_id) capture_obj = self.microscope.camera.image_from_id(capture_id) diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py index 0649cb71..76f828cd 100644 --- a/openflexure_microscope/camera/base.py +++ b/openflexure_microscope/camera/base.py @@ -34,13 +34,12 @@ def entry_by_id(id: str, object_list: list): class CameraEvent(object): - def __init__(self): - """ - Create a frame-signaller object for StreamingCamera. + """ + A frame-signaller object used by any instances or subclasses of BaseCamera. - An event-like class that signals all active clients - when a new frame is available. - """ + An event-like class that signals all active clients when a new frame is available. + """ + def __init__(self): self.events = {} def wait(self, timeout: int=5): @@ -79,21 +78,23 @@ class CameraEvent(object): class BaseCamera(object): + """ + Base implementation of StreamingCamera. + """ def __init__(self): - """Base implementation of StreamingCamera.""" - self.thread = None # Background thread that reads frames from camera - self.camera = None # Camera object, for direct access to camera + self.thread = None #: Background thread reading frames from camera + self.camera = None #: Camera object - self.frame = None # Current frame is stored here by background thread - self.last_access = 0 # Time of last client access to the camera + self.frame = None #: bytes: Current frame is stored here by background thread + self.last_access = 0 #: time: Time of last client access to the camera self.event = CameraEvent() - self.state = {} # Create dict for capture state - self.settings = {} # Create dict to store settings + self.state = {} #: dict: Dictionary for capture state + self.settings = {} #: dict: Dictionary of camera settings # Capture data - self.images = [] - self.videos = [] + self.images = [] #: list: List of image capture objects + self.videos = [] #: list: List of video recording objects def __enter__(self): """Create camera on context enter.""" diff --git a/openflexure_microscope/camera/capture.py b/openflexure_microscope/camera/capture.py index 7094967b..f245a60f 100644 --- a/openflexure_microscope/camera/capture.py +++ b/openflexure_microscope/camera/capture.py @@ -11,6 +11,9 @@ thumbnail_size = (60, 60) class StreamObject(object): + """ + StreamObject used to store and process capture data, and metadata. + """ def __init__( self, write_to_file: bool=None, diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index 3cd0bd21..a11805ae 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -55,12 +55,12 @@ DEFAULT_CONFIG = os.path.join(HERE, 'config_picamera.yaml') class StreamingCamera(BaseCamera): + """Raspberry Pi camera implementation of StreamingCamera.""" def __init__(self): - """Raspberry Pi camera implementation of StreamingCamera.""" # Run BaseCamera init BaseCamera.__init__(self) # Attach to Pi camera - self.camera = picamera.PiCamera() + self.camera = picamera.PiCamera() #: :py:class:`picamera.PiCamera`: Picamera object # Camera settings self.settings.update({ @@ -93,13 +93,21 @@ class StreamingCamera(BaseCamera): # HANDLE SETTINGS - # TODO: Handle exceptions - # TODO: Have this take a dictionary (not a config file) - # TODO: Web API entry point to send settings as JSON to this method - # TODO: Separate method to store current settings back to YAML file - # TODO: API entry point to get settings to JSON def update_settings(self, config_path: str=None) -> None: - """Open config_picamera.yaml file and write to camera.""" + """ + Open config_picamera.yaml file and write to camera. + + Todo: + TODO: Handle exceptions + + TODO: Have this take a dictionary (not a config file) + + TODO: Web API entry point to send settings as JSON to this method + + TODO: Separate method to store current settings back to YAML file + + TODO: API entry point to get settings to JSON + """ global DEFAULT_CONFIG paused_stream = False @@ -164,7 +172,12 @@ class StreamingCamera(BaseCamera): "Cannot update camera settings while recording is active.") def change_zoom(self, zoom_value: int=1) -> None: - """Change the camera zoom, handling recentering and scaling.""" + """Change the camera zoom, handling recentering and scaling. + + Todo: + TODO: Needs to be re-implemented + + """ zoom_value = float(zoom_value) if zoom_value < 1: zoom_value = 1 @@ -204,17 +217,20 @@ class StreamingCamera(BaseCamera): folder: str='record', fmt: str='h264', quality: int=15): - """Start a new video recording, writing to a target object. + """Start recording. + + Start a new video recording, writing to a target object. + + Args: + target (str/BytesIO): Target object to write bytes to. + write_to_file (bool/NoneType): Should the StreamObject write to a file? + filename (str): Name of the stored file. Defaults to timestamp. + folder (str): Relative directory to store data file in. + fmt (str): Format of the capture. + + Returns: + target_object (str/BytesIO): Target object. - target (str/BytesIO): Target object to write bytes to. - (default StreamObject) - write_to_file (bool/NoneType): Should the StreamObject write to a file? - (default True for video capture) - filename (str): Name of the stored file. - (defaults to timestamp) - folder (str): Relative directory to store data file in. - fmt (str): Format of the capture. - (default 'h264') """ # Start recording method only if a current recording is not running if not self.state['record_active']: @@ -274,9 +290,9 @@ class StreamingCamera(BaseCamera): """ Pause capture on a splitter port. - splitter_port (int): Splitter port to stop recording on - resolution ((int, int)): Resolution to set the camera to, - after stopping recording. + Args: + splitter_port (int): Splitter port to stop recording on + resolution ((int, int)): Resolution to set the camera to, after stopping recording. """ logging.debug("Pausing stream") # If no resolution is specified, default to image_resolution @@ -296,9 +312,9 @@ class StreamingCamera(BaseCamera): """ Resume capture on a splitter port. - splitter_port (int): Splitter port to start recording on - resolution ((int, int)): Resolution to set the camera to, - before starting recording. + Args: + splitter_port (int): Splitter port to start recording on + resolution ((int, int)): Resolution to set the camera to, before starting recording. """ logging.debug("Unpausing stream") if not resolution: @@ -330,17 +346,14 @@ class StreamingCamera(BaseCamera): Defaults to JPEG format. Target object can be overridden for development purposes. - target (str/BytesIO): Target object to write data bytes to. - write_to_file (bool): Should the StreamObject write to a file, - instead of BytesIO stream? - use_video_port (bool): Capture from the video port used for streaming. - (lower resolution, faster) - filename (str): Name of the stored file. - (defaults to timestamp) - folder (str): Relative directory to store data file in. - fmt (str): Format of the capture. - (default 'h264') - resize ((int, int)): Resize the captured image. + Args: + target (str/BytesIO): Target object to write data bytes to. + write_to_file (bool): Should the StreamObject write to a file, instead of BytesIO stream? + use_video_port (bool): Capture from the video port used for streaming. Lower resolution, faster. + filename (str): Name of the stored file. Defaults to timestamp. + folder (str): Relative directory to store data file in. + fmt (str): Format of the capture. + resize ((int, int)): Resize the captured image. """ # If no target is specified, store to StreamingCamera if not target: @@ -391,9 +404,9 @@ class StreamingCamera(BaseCamera): resize: Tuple[int, int]=None) -> np.ndarray: """Capture an uncompressed still YUV image to a Numpy array. - use_video_port (bool): Capture from the video port used for streaming. - (lower resolution, faster) - resize ((int, int)): Resize the captured image. + Args: + use_video_port (bool): Capture from the video port used for streaming. Lower resolution, faster. + resize ((int, int)): Resize the captured image. """ if use_video_port: resolution = self.settings['video_resolution'] diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index 672f1670..1758df3b 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -1,4 +1,8 @@ # -*- coding: utf-8 -*- +""" +Defines a microscope object, binding a camera and stage with basic functionality. +""" + import numpy as np from openflexure_stage import OpenFlexureStage @@ -6,12 +10,20 @@ from .camera.pi import StreamingCamera class Microscope(object): + """ + A basic microscope object. + + The camera and stage should already be initialised, and passed as arguments. + + Args: + camera (:py:class:`openflexure_microscope.camera.pi.StreamingCamera`): camera object + microscope (:py:class:`openflexure_stage.stage.OpenFlexureStage`): stage object + """ def __init__(self, camera: StreamingCamera, stage: OpenFlexureStage): - """Create the microscope object. The camera and stage should already be initialised.""" print("Assigning camera") - self.camera = camera + self.camera = camera #: :py:class:`openflexure_microscope.camera.pi.StreamingCamera`: Picamera object print("Assigning stage") - self.stage = stage + self.stage = stage #: :py:class:`openflexure_stage.stage.OpenFlexureStage`: OpenFlexure stage object self.stage.backlash = np.zeros(3, dtype=np.int) def __enter__(self): @@ -30,6 +42,11 @@ class Microscope(object): # Create unified state @property def state(self): + """Dictionary of the basic microscope state. + + Return: + dict: Dictionary containing position data, and :py:attr:`openflexure_microscope.camera.base.BaseCamera.state` + """ state = {} # Add stage position From ad5ec7930552378b63aac5ce59ef5e2c09009b3d Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 16 Nov 2018 15:47:20 +0000 Subject: [PATCH 13/28] Removed old API-only documentation --- openflexure_microscope/api/doc/Makefile | 20 -- openflexure_microscope/api/doc/make.bat | 36 ---- .../api/doc/requirements.txt | 3 - openflexure_microscope/api/doc/source/conf.py | 172 ------------------ .../api/doc/source/index.rst | 41 ----- 5 files changed, 272 deletions(-) delete mode 100644 openflexure_microscope/api/doc/Makefile delete mode 100644 openflexure_microscope/api/doc/make.bat delete mode 100644 openflexure_microscope/api/doc/requirements.txt delete mode 100644 openflexure_microscope/api/doc/source/conf.py delete mode 100644 openflexure_microscope/api/doc/source/index.rst diff --git a/openflexure_microscope/api/doc/Makefile b/openflexure_microscope/api/doc/Makefile deleted file mode 100644 index 8663bf75..00000000 --- a/openflexure_microscope/api/doc/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -SPHINXPROJ = openflexure_microscope_api -SOURCEDIR = source -BUILDDIR = build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/openflexure_microscope/api/doc/make.bat b/openflexure_microscope/api/doc/make.bat deleted file mode 100644 index 5c929a79..00000000 --- a/openflexure_microscope/api/doc/make.bat +++ /dev/null @@ -1,36 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=source -set BUILDDIR=build -set SPHINXPROJ=openflexure_microscope_api - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% - -:end -popd diff --git a/openflexure_microscope/api/doc/requirements.txt b/openflexure_microscope/api/doc/requirements.txt deleted file mode 100644 index 45ac0217..00000000 --- a/openflexure_microscope/api/doc/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -Sphinx -sphinxcontrib-httpdomain -sphinx_rtd_theme \ No newline at end of file diff --git a/openflexure_microscope/api/doc/source/conf.py b/openflexure_microscope/api/doc/source/conf.py deleted file mode 100644 index 2d3c8f87..00000000 --- a/openflexure_microscope/api/doc/source/conf.py +++ /dev/null @@ -1,172 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Configuration file for the Sphinx documentation builder. -# -# This file does only contain a selection of the most common options. For a -# full list see the documentation: -# http://www.sphinx-doc.org/en/master/config - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) - - -# -- Project information ----------------------------------------------------- - -project = 'openflexure_microscope_api' -copyright = '2018, Joel Collins' -author = 'Joel Collins' - -# The short X.Y version -version = '' -# The full version, including alpha/beta/rc tags -release = '0.0.1' - - -# -- General configuration --------------------------------------------------- - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.ifconfig', - 'sphinxcontrib.httpdomain', - 'sphinxcontrib.autohttp.flask', - 'sphinxcontrib.autohttp.flaskqref', -] - -# Override ordering -autodoc_member_order = 'bysource' - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The master toctree document. -master_doc = 'index' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path . -exclude_patterns = [] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "sphinx_rtd_theme" - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Custom sidebar templates, must be a dictionary that maps document names -# to template names. -# -# The default sidebars (for documents that don't match any pattern) are -# defined by theme itself. Builtin themes are using these templates by -# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', -# 'searchbox.html']``. -# -# html_sidebars = {} - - -# -- Options for HTMLHelp output --------------------------------------------- - -# Output file base name for HTML help builder. -htmlhelp_basename = 'openflexure_microscope_apidoc' - - -# -- Options for LaTeX output ------------------------------------------------ - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'openflexure_microscope_api.tex', 'openflexure\\_microscope\\_api Documentation', - 'Joel Collins', 'manual'), -] - - -# -- Options for manual page output ------------------------------------------ - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'openflexure_microscope_api', 'openflexure_microscope_api Documentation', - [author], 1) -] - - -# -- Options for Texinfo output ---------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'openflexure_microscope_api', 'openflexure_microscope_api Documentation', - author, 'openflexure_microscope_api', 'One line description of project.', - 'Miscellaneous'), -] - - -# -- Extension configuration ------------------------------------------------- - -# -- Options for intersphinx extension --------------------------------------- - -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'https://docs.python.org/': None} \ No newline at end of file diff --git a/openflexure_microscope/api/doc/source/index.rst b/openflexure_microscope/api/doc/source/index.rst deleted file mode 100644 index 436aabf7..00000000 --- a/openflexure_microscope/api/doc/source/index.rst +++ /dev/null @@ -1,41 +0,0 @@ -.. openflexure_microscope_api documentation master file, created by - sphinx-quickstart on Thu Nov 15 11:08:18 2018. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -OpenFlexure Microscope API -====================================================== - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - - - -API Documentation -================= - -Summary -------- -.. qrefflask:: openflexure_microscope.api.v1:app - :undoc-endpoints: index - :undoc-static: - :endpoints: - -API Details ------------ - -.. autoflask:: openflexure_microscope.api.v1:app - :undoc-endpoints: index - :undoc-static: - :endpoints: - :order: path \ No newline at end of file From 3b28f8c4caaa3cc5b6f2a4a6f0c39ac5cfdc8f13 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 19 Nov 2018 16:31:38 +0000 Subject: [PATCH 14/28] Microscope now only attaches to hardware once an API request is made. --- openflexure_microscope/api/v1.py | 30 +++++++++++++++++++++------- openflexure_microscope/microscope.py | 29 +++++++++++++++++++++------ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index 486f1f67..447110cf 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -11,6 +11,7 @@ import numpy as np from importlib import import_module import time import datetime +import os from flask import ( Flask, render_template, Response, @@ -29,11 +30,6 @@ import logging, sys logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) -# Create the microscope object globally (common to all spawned server threads) -api_microscope = Microscope( - StreamingCamera(), - OpenFlexureStage("/dev/ttyUSB0") -) # Create flask app app = Flask(__name__) @@ -45,9 +41,29 @@ def not_found(error): # Some useful functions # TODO: Maybe auto-generate API URI base from module name -def uri(suffix, base='/api/v1'): - return base + suffix +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 a dummy microscope object, with no hardware attachments +api_microscope = Microscope(None, None) +logging.debug("Created an empty microscope in global.") + +# 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...") + api_microscope.attach( + StreamingCamera(), + OpenFlexureStage("/dev/ttyUSB0") + ) + logging.debug("Microscope successfully attached!") ##### WEBAPP ROUTES ###### diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index 1758df3b..75449b07 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -2,7 +2,7 @@ """ Defines a microscope object, binding a camera and stage with basic functionality. """ - +import logging import numpy as np from openflexure_stage import OpenFlexureStage @@ -20,11 +20,7 @@ class Microscope(object): microscope (:py:class:`openflexure_stage.stage.OpenFlexureStage`): stage object """ def __init__(self, camera: StreamingCamera, stage: OpenFlexureStage): - print("Assigning camera") - self.camera = camera #: :py:class:`openflexure_microscope.camera.pi.StreamingCamera`: Picamera object - print("Assigning stage") - self.stage = stage #: :py:class:`openflexure_stage.stage.OpenFlexureStage`: OpenFlexure stage object - self.stage.backlash = np.zeros(3, dtype=np.int) + self.attach(camera, stage) def __enter__(self): """Create microscope on context enter.""" @@ -38,6 +34,27 @@ class Microscope(object): """Shut down the microscope hardware.""" self.camera.close() self.stage.close() + + def attach(self, camera: StreamingCamera, stage: OpenFlexureStage): + """ + Retroactively attaches a camera and stage to the microscope object. + + Allows the microscope to be created as a "dummy", with hardware communications + opened at a later time. + + Args: + camera (:py:class:`openflexure_microscope.camera.pi.StreamingCamera`): camera object + microscope (:py:class:`openflexure_stage.stage.OpenFlexureStage`): stage object + """ + + self.camera = camera #: :py:class:`openflexure_microscope.camera.pi.StreamingCamera`: Picamera object + if isinstance(camera, StreamingCamera): + logging.info("Attached camera {}".format(camera)) + + 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) # Create unified state @property From 259ec4150fa36a24199f0cc4d07e8177422a81c0 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 19 Nov 2018 16:33:21 +0000 Subject: [PATCH 15/28] Minor restructure --- openflexure_microscope/api/v1.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index 447110cf..a3609b7d 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -1,8 +1,6 @@ #!/usr/bin/env python """ TODO: Add proper docstrings -TODO: Bind to port 80 -TODO: Reimplement capture methods TODO: Implement API route to cleanly shut down server TODO: Implement microscope function API routes (autofocus etc) """ @@ -30,6 +28,18 @@ 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__) @@ -39,19 +49,6 @@ app = Flask(__name__) def not_found(error): return make_response(jsonify({'error': 'Not found'}), 404) -# Some useful functions -# TODO: Maybe auto-generate API URI base from module name -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 a dummy microscope object, with no hardware attachments -api_microscope = Microscope(None, None) -logging.debug("Created an empty microscope in global.") # After app starts, but before first request, attach hardware to global microscope @app.before_first_request From 8490d028a50bcd3eef823a5f64eac58de6949d08 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 19 Nov 2018 17:35:40 +0000 Subject: [PATCH 16/28] Handle mock imports for non-platform-agnostic modules --- doc/requirements.txt | 5 ++++- doc/source/conf.py | 22 +++++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/doc/requirements.txt b/doc/requirements.txt index 45ac0217..10c9c94d 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,3 +1,6 @@ Sphinx sphinxcontrib-httpdomain -sphinx_rtd_theme \ No newline at end of file +sphinx_rtd_theme +openflexure-stage +Pillow +pyyaml \ No newline at end of file diff --git a/doc/source/conf.py b/doc/source/conf.py index 580a1c32..1c914c2c 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -12,10 +12,26 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) +import os +import sys +# Load module from relative imports + +module_path = os.path.abspath('../..') +sys.path.insert(0, module_path) + +# Handle mock imports for non-platform-agnostic modules + +from unittest.mock import MagicMock + +class Mock(MagicMock): + @classmethod + def __getattr__(cls, name): + return MagicMock() + +mock_imports = ['picamera', 'picamera.array', 'picamera.mmalobj'] + +sys.modules.update((mod_name, Mock()) for mod_name in mock_imports) # -- Project information ----------------------------------------------------- From e3724ffefcf432971da0c3500e15a22fcfbea790 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 20 Nov 2018 11:22:36 +0000 Subject: [PATCH 17/28] JSONified all HTTP errors --- openflexure_microscope/api/v1.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index a3609b7d..07a7e2d0 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -17,6 +17,7 @@ from flask import ( make_response) from flask.views import MethodView +from werkzeug.exceptions import default_exceptions from openflexure_microscope.api.utilities import parse_payload, gen, get_bool @@ -45,10 +46,18 @@ def uri(suffix, base=None): app = Flask(__name__) # Make errors more API friendly -@app.errorhandler(404) -def not_found(error): - return make_response(jsonify({'error': 'Not found'}), 404) +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 @@ -339,7 +348,6 @@ class CaptureListAPI(MicroscopeView): :
Date: Tue, 20 Nov 2018 12:13:36 +0000 Subject: [PATCH 18/28] Implemented API delete method --- openflexure_microscope/api/static/main_v1.js | 24 +++++++++++++++---- .../api/static/style_v1.css | 2 +- openflexure_microscope/api/v1.py | 24 ++++++++++++------- openflexure_microscope/camera/capture.py | 6 +++++ 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/openflexure_microscope/api/static/main_v1.js b/openflexure_microscope/api/static/main_v1.js index 8eda3ea7..af4a1899 100644 --- a/openflexure_microscope/api/static/main_v1.js +++ b/openflexure_microscope/api/static/main_v1.js @@ -44,15 +44,26 @@ window.onload = function() { getCaptures() } +function deleteCapture(capture_id) { + function deleteCaptureCallback(response, status) { + console.log(status); + getCaptures(); + } + var r = confirm("Warning! This will delete all copies of this capture from the Raspberry Pi. Click OK to proceed."); + if (r == true) { + safeRequest("DELETE", baseURI+"/capture/"+capture_id+"/", null, deleteCaptureCallback, false) + } +} + function getCaptures() { function updateCapturesCallback(response, status) { console.log(status); - updateCaptures(response); + updateCaptureList(response); } safeRequest("GET", baseURI+"/capture", null, updateCapturesCallback, false) } -function updateCaptures(response) { +function updateCaptureList(response) { // Clear captures list var capturesNode = document.getElementById("captures"); while (capturesNode.firstChild) { @@ -69,8 +80,13 @@ function updateCaptures(response) {
- ${element.filename}
- View Download Delete JSON + ${element.filename} +
+ + +
+ +
diff --git a/openflexure_microscope/api/static/style_v1.css b/openflexure_microscope/api/static/style_v1.css index a21f7573..90cb3fd4 100644 --- a/openflexure_microscope/api/static/style_v1.css +++ b/openflexure_microscope/api/static/style_v1.css @@ -77,5 +77,5 @@ body { } .capture img { - width: 60px; + width: 80px; } \ No newline at end of file diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index 07a7e2d0..28fc4a2c 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -44,6 +44,7 @@ def uri(suffix, base=None): # Create flask app app = Flask(__name__) +app.url_map.strict_slashes = False # Make errors more API friendly @@ -291,9 +292,9 @@ class CaptureListAPI(MicroscopeView): include_unavailable = get_bool(request.args.get('include_unavailable')) if include_unavailable: - captures = [image.metadata for image in self.microscope.camera.images if image.metadata['available']] - else: 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) @@ -462,16 +463,23 @@ class CaptureAPI(MicroscopeView): def delete(self, capture_id): """ - Delete a capture (not yet implemented) - - .. :quickref: Capture; Delete capture + 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}) @@ -485,7 +493,7 @@ class CaptureDownloadAPI(MicroscopeView): def get(self, capture_id): """ Return image data for a capture. - + .. :quickref: Capture; Download capture file **Example request**: @@ -505,7 +513,7 @@ class CaptureDownloadAPI(MicroscopeView): print(capture_id) capture_obj = self.microscope.camera.image_from_id(capture_id) - if not capture_obj: + 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')) diff --git a/openflexure_microscope/camera/capture.py b/openflexure_microscope/camera/capture.py index f245a60f..c1ced0e6 100644 --- a/openflexure_microscope/camera/capture.py +++ b/openflexure_microscope/camera/capture.py @@ -251,6 +251,12 @@ class StreamObject(object): else: return False + def delete(self): + """Entirely delete all capture data.""" + logging.info("Deleting {}".format(self.id)) + self.delete_stream() + self.delete_file() + def shunt(self): """Demote the StreamObject from being stored in memory.""" if not self.file_exists: # If file doesn't already exist From 0e76ae673a9e467e5871364b933552d9a380266b Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 20 Nov 2018 13:56:39 +0000 Subject: [PATCH 19/28] Use flexboxes everywhere --- openflexure_microscope/api/static/main_v1.js | 22 ++++++++--------- .../api/static/style_v1.css | 20 ++++++++++++++++ .../api/templates/index_v1.html | 24 +++++++++---------- 3 files changed, 42 insertions(+), 24 deletions(-) diff --git a/openflexure_microscope/api/static/main_v1.js b/openflexure_microscope/api/static/main_v1.js index af4a1899..1d8bb6ff 100644 --- a/openflexure_microscope/api/static/main_v1.js +++ b/openflexure_microscope/api/static/main_v1.js @@ -77,18 +77,16 @@ function updateCaptureList(response) { // Generate inner HTML from capture object html = ` -
-
-
- ${element.filename} -
- - -
- - -
-
+
+
+ ${element.filename} +
+ + +
+ + +
` diff --git a/openflexure_microscope/api/static/style_v1.css b/openflexure_microscope/api/static/style_v1.css index 90cb3fd4..58b9ae38 100644 --- a/openflexure_microscope/api/static/style_v1.css +++ b/openflexure_microscope/api/static/style_v1.css @@ -64,6 +64,14 @@ body { display: table; } +.flexbox { + display: flex; + margin-bottom: 5px; +} +.flexgrow { + flex-grow: 1; +} + /* Data divs */ #captures { @@ -74,6 +82,18 @@ body { .capture { background-color: #d3d3d3; margin-bottom: 5px; + display: flex; +} + +.capture-thumb { + width: 80px; +} + +.capture-actions { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-left: 5px; } .capture img { diff --git a/openflexure_microscope/api/templates/index_v1.html b/openflexure_microscope/api/templates/index_v1.html index f9be076c..59504329 100644 --- a/openflexure_microscope/api/templates/index_v1.html +++ b/openflexure_microscope/api/templates/index_v1.html @@ -25,16 +25,16 @@

Doubleclick to position:
-

-
FOV width:
-
+
+
FOV width:
+
-
-
FOV height:
-
+
+
FOV height:
+
@@ -51,16 +51,16 @@

Velocity:

-
-
x-y:
-
+
+
x-y:
+
-
-
z:
-
+
+
z:
+
From 69e97ba5a3b20ec21eb06442732e26b4874a557d Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 20 Nov 2018 13:56:51 +0000 Subject: [PATCH 20/28] Fixed non-clashing basename generation --- openflexure_microscope/camera/base.py | 18 ++++++++++++ openflexure_microscope/camera/capture.py | 36 +++++++----------------- openflexure_microscope/camera/pi.py | 34 ++++++++++++++-------- 3 files changed, 50 insertions(+), 38 deletions(-) diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py index 76f828cd..e5550b9d 100644 --- a/openflexure_microscope/camera/base.py +++ b/openflexure_microscope/camera/base.py @@ -3,6 +3,7 @@ import time import io import threading from PIL import Image +import datetime import logging try: @@ -33,6 +34,11 @@ def entry_by_id(id: str, object_list: list): return found +def generate_basename(): + """Return a default filename based on the capture datetime""" + return datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + + class CameraEvent(object): """ A frame-signaller object used by any instances or subclasses of BaseCamera. @@ -222,6 +228,18 @@ class BaseCamera(object): self.videos, shunt_others=shunt_others) + # INTELLIGENTLY GENERATE FILENAMES + def generate_basename(self, obj_list: list) -> str: + initial_basename = generate_basename() + basename = initial_basename + # Handle clashing + iterator = 1 + while basename in [obj.basename for obj in obj_list]: + basename = initial_basename + "_{}".format(iterator) + iterator += 1 + + return basename + # WORKER THREAD def _thread(self): diff --git a/openflexure_microscope/camera/capture.py b/openflexure_microscope/camera/capture.py index c1ced0e6..4856297e 100644 --- a/openflexure_microscope/camera/capture.py +++ b/openflexure_microscope/camera/capture.py @@ -29,20 +29,10 @@ class StreamObject(object): # Store file format self.format = fmt - # Create file name - iterator = 0 - f_path, f_name = self.build_file_path(filename, folder, fmt) - - while os.path.isfile(f_name): # While file already exists - iterator += 1 # Add a file name iterator - f_path, f_name = self.build_file_path( - filename, - folder, - fmt, - iterator=iterator) # Rebuild file name - - self.file = f_path - self.filename = f_name + # Create file name. Default to UUID + if not filename: + filename = self.id + self.build_file_path(filename, folder, fmt) # Byte stream properties self.stream = io.BytesIO() # Byte stream that data will be written to @@ -85,21 +75,13 @@ class StreamObject(object): self, filename: str, folder: str, - fmt: str, - iterator: int=0) -> str: + fmt: str): """ Construct a full file path, based on filename, folder, and file format. - Defaults to datestamp. - Iterator adds a numeric increment to the file name. + Defaults to datestamp. """ - if filename: - file_name = "{}.{}".format(filename, fmt) - else: - file_name_base = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - if iterator: - file_name_base = "{}_{}".format(file_name_base, iterator) - file_name = "{}.{}".format(file_name_base, fmt) + file_name = "{}.{}".format(filename, fmt) # Create folder and file if folder: @@ -110,7 +92,9 @@ class StreamObject(object): else: file_path = file_name - return (file_path, file_name) + self.basename = filename + self.file = file_path + self.filename = file_name def lock(self): """Set locked flag to True.""" diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index a11805ae..2bc63d19 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -238,11 +238,13 @@ class StreamingCamera(BaseCamera): # If no target is specified, store to StreamingCamera if not target: # Create a new video and add to the video list - target_obj = self.new_video(StreamObject( - write_to_file=write_to_file, - filename=filename, - folder=folder, - fmt=fmt)) + target_obj = self.new_video( + StreamObject( + write_to_file=write_to_file, + filename=filename, + folder=folder, + fmt=fmt) + ) # Lock the StreamObject while recording target_obj.lock() @@ -355,16 +357,24 @@ class StreamingCamera(BaseCamera): fmt (str): Format of the capture. resize ((int, int)): Resize the captured image. """ + # If no filename is specified, build a non-clashing one + if not filename: + filename = self.generate_basename(self.images) + logging.debug(filename) + # If no target is specified, store to StreamingCamera if not target: + # TODO: Handle clashing file names here instead of in capture method. # Create a new image and add to the image list - target_obj = self.new_image(StreamObject( - write_to_file=write_to_file, - keep_on_disk=keep_on_disk, - filename=filename, - folder=folder, - fmt=fmt)) - target = target_obj.target # Store to the StreamObject BytesIO + target_obj = self.new_image( + StreamObject( + write_to_file=write_to_file, + keep_on_disk=keep_on_disk, + filename=filename, + folder=folder, + fmt=fmt) + ) + target = target_obj.target # Store to the StreamObject BytesIO else: target_obj = target From 44b72ed74f51892b5eb8af07a0cb1b527308f0d0 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 20 Nov 2018 16:14:11 +0000 Subject: [PATCH 21/28] Reformatted capture buttons --- openflexure_microscope/api/static/main_v1.js | 10 ++--- .../api/static/style_v1.css | 45 ++++++++++--------- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/openflexure_microscope/api/static/main_v1.js b/openflexure_microscope/api/static/main_v1.js index 1d8bb6ff..03ab67be 100644 --- a/openflexure_microscope/api/static/main_v1.js +++ b/openflexure_microscope/api/static/main_v1.js @@ -79,13 +79,13 @@ function updateCaptureList(response) { html = `
- ${element.filename} +
${element.filename}

- - + +
- - + +
` diff --git a/openflexure_microscope/api/static/style_v1.css b/openflexure_microscope/api/static/style_v1.css index 58b9ae38..eddbdf29 100644 --- a/openflexure_microscope/api/static/style_v1.css +++ b/openflexure_microscope/api/static/style_v1.css @@ -28,18 +28,18 @@ body { } .left{ - width: 250px; + width: 220px; float: left; } .Right{ - width: 250px; + width: 280px; float: right; } .middle { - margin-left: 250px; - margin-right: 250px; + margin-left: 220px; + margin-right: 280px; background-color: #d3d3d3; } @@ -50,24 +50,12 @@ body { clear: both; } -/* Columns within panels */ - -.left-col { - float:left; -} -.right-col { - float:right; -} -.clearfix::after { - content: ""; - clear: both; - display: table; -} - +/* Flexboxes */ .flexbox { display: flex; margin-bottom: 5px; } + .flexgrow { flex-grow: 1; } @@ -80,13 +68,22 @@ body { } .capture { - background-color: #d3d3d3; + height: 75px; + width: 260px; margin-bottom: 5px; + background-color: #d3d3d3; display: flex; } .capture-thumb { width: 80px; + height: 75px; +} + +.capture-heading { + font-weight: bold; + margin: 5px 0 5px 0; + display: inline-block; } .capture-actions { @@ -97,5 +94,13 @@ body { } .capture img { - width: 80px; + width: 100%; + height: 100%; + object-fit: cover; +} + +.capture-button { + width: 75px; + margin-bottom: 3px; + height: 22px; } \ No newline at end of file From e98552a63d2383c6312f03e46ae2809e0c57b3ec Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 20 Nov 2018 16:14:28 +0000 Subject: [PATCH 22/28] Downloading images now redirects to specify a filename --- openflexure_microscope/api/v1.py | 38 +++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index 28fc4a2c..99ad1c96 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -12,7 +12,7 @@ import datetime import os from flask import ( - Flask, render_template, Response, + Flask, render_template, Response, url_for, redirect, request, jsonify, send_file, abort, make_response) @@ -455,7 +455,7 @@ class CaptureAPI(MicroscopeView): # If available, also add download link if capture_metadata['available']: - uri_dict['uri']['download'] = uri('/capture/{}/download'.format(capture_id)) + uri_dict['uri']['download'] = uri('/capture/{}/download/{}'.format(capture_obj.id, capture_obj.filename)) capture_metadata.update(uri_dict) @@ -485,26 +485,33 @@ class CaptureAPI(MicroscopeView): return jsonify({"return": capture_id}) app.add_url_rule( - uri('/capture//'), + uri('/capture//'), view_func=CaptureAPI.as_view('capture', microscope=api_microscope)) class CaptureDownloadAPI(MicroscopeView): - def get(self, capture_id): + def get(self, capture_id, filename): """ Return image data for a capture. + If no (filename) is specified, the request will redirect to download the capture + under it's currently set filename. + + If (filename) is specified, the capture data will be returned as an image + file with the requested filename. + .. :quickref: Capture; Download capture file **Example request**: .. sourcecode:: http - GET /capture/d0b2067abbb946f19351e075c5e7cd5b/download?thumbnail=true HTTP/1.1 + GET /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 @@ -519,6 +526,11 @@ class CaptureDownloadAPI(MicroscopeView): 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: @@ -528,11 +540,21 @@ class CaptureDownloadAPI(MicroscopeView): img, mimetype='image/jpeg', as_attachment=as_attachment, - attachment_filename=capture_obj.filename) + attachment_filename=filename) +# General download view +download_view = CaptureDownloadAPI.as_view('capture_download', microscope=api_microscope) + +# Full route, including filename app.add_url_rule( - uri('/capture//download/'), - view_func=CaptureDownloadAPI.as_view('capture_download', microscope=api_microscope)) + uri('/capture//download/'), + view_func=download_view) + +# Route for shortcut where no filename is specified +app.add_url_rule( + uri('/capture//download/'), + defaults={'filename': None}, + view_func=download_view) if __name__ == "__main__": From c2f30b56c3cce6eb7c8d22bfd24fc38c6aa1b124 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 20 Nov 2018 16:19:17 +0000 Subject: [PATCH 23/28] Updated docs for new download method --- openflexure_microscope/api/v1.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index 99ad1c96..01279202 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -495,10 +495,12 @@ class CaptureDownloadAPI(MicroscopeView): Return image data for a capture. If no (filename) is specified, the request will redirect to download the capture - under it's currently set filename. + under it's currently set filename. I.e., `/(capture_id)/download` will + redirect to `/(capture_id)/download/(filename)`. If (filename) is specified, the capture data will be returned as an image - file with the requested filename. + 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 From ea3ae9f898825f9cfa642e1a988fed1b64833658 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 20 Nov 2018 18:01:13 +0000 Subject: [PATCH 24/28] Increased travel limit --- openflexure_microscope/api/v1.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index 01279202..75b678d1 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -246,7 +246,7 @@ class PositionAPI(MicroscopeView): if not 'force' 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 = 2000 + travel_limit = 20000 for axis, pos in enumerate(position): if abs(pos) > travel_limit: # Respond with 400 Bad Request From c249e846dc2e3e9f2e1a1a2cfe94b63cb6d064ca Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 20 Nov 2018 18:55:30 +0000 Subject: [PATCH 25/28] Add .tmp to temporary capture files for clarity --- openflexure_microscope/camera/capture.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/camera/capture.py b/openflexure_microscope/camera/capture.py index 4856297e..7af82175 100644 --- a/openflexure_microscope/camera/capture.py +++ b/openflexure_microscope/camera/capture.py @@ -16,7 +16,7 @@ class StreamObject(object): """ def __init__( self, - write_to_file: bool=None, + write_to_file: bool=False, keep_on_disk: bool=True, filename: str=None, folder: str=None, @@ -81,6 +81,10 @@ class StreamObject(object): Defaults to datestamp. """ + appendix = "" + if not self.keep_on_disk: + appendix += ".tmp" + file_name = "{}.{}".format(filename, fmt) # Create folder and file From 838c832272a6a02d5a696ee134dcb0e62070c4cc Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 20 Nov 2018 19:04:23 +0000 Subject: [PATCH 26/28] Minor cleaning --- openflexure_microscope/camera/capture.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/openflexure_microscope/camera/capture.py b/openflexure_microscope/camera/capture.py index 7af82175..d9572513 100644 --- a/openflexure_microscope/camera/capture.py +++ b/openflexure_microscope/camera/capture.py @@ -24,7 +24,7 @@ class StreamObject(object): """Create a new StreamObject, to manage capture data.""" # Store a nice ID self.id = uuid.uuid4().hex - logging.info("Created {}".format(self.id)) + logging.info("Created StreamObject {}".format(self.id)) # Store file format self.format = fmt @@ -32,7 +32,7 @@ class StreamObject(object): # Create file name. Default to UUID if not filename: filename = self.id - self.build_file_path(filename, folder, fmt) + self.build_file_path(filename, folder, self.format) # Byte stream properties self.stream = io.BytesIO() # Byte stream that data will be written to @@ -55,7 +55,7 @@ class StreamObject(object): # Object lock self.locked = False - # Thumbnail (populated only for JPEG captures) + # Thumbnail (populated only for PIL captures) self.thumb_bytes = None def __enter__(self): @@ -168,7 +168,6 @@ class StreamObject(object): else: # If data stream is empty if self.file_exists: # If data file exists - # TODO: Streamline this bit logging.info("Opening from file {}".format(self.file)) with open(self.file, 'rb') as f: d = io.BytesIO(f.read()) # Load bytes from file @@ -189,7 +188,7 @@ class StreamObject(object): def thumbnail(self) -> io.BytesIO: # If no thumbnail exists, try and make one if not self.thumb_bytes: - print("Building thumbnail") + logging.info("Building thumbnail") if self.format.upper() in pil_formats: im = Image.open(self.data) im.thumbnail(thumbnail_size) @@ -207,7 +206,6 @@ class StreamObject(object): def load_file(self) -> bool: """Load data stored on disk to the in-memory stream.""" if self.file_exists: # If data file exists - # TODO: Streamline this bit with open(self.file, 'rb') as f: self.stream = io.BytesIO(f.read()) # Load bytes from file self.stream.seek(0) # Rewind data bytes again From 22665cde8727b768ce12855b1a9581548627aa67 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 20 Nov 2018 19:17:47 +0000 Subject: [PATCH 27/28] Minor tidy up --- openflexure_microscope/api/v1.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index 75b678d1..6366a57b 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -46,6 +46,7 @@ def uri(suffix, base=None): app = Flask(__name__) app.url_map.strict_slashes = False + # Make errors more API friendly def _handle_http_exception(e): @@ -60,6 +61,7 @@ def _handle_http_exception(e): 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(): @@ -72,6 +74,7 @@ def attach_microscope(): ) logging.debug("Microscope successfully attached!") + ##### WEBAPP ROUTES ###### @app.route('/') @@ -85,7 +88,9 @@ def index(): ##### API ROUTES ###### + # Basic microscope view + class MicroscopeView(MethodView): def __init__(self, microscope): @@ -98,8 +103,6 @@ class MicroscopeView(MethodView): MethodView.__init__(self) -# State endpoints - class StreamAPI(MicroscopeView): def get(self): @@ -169,8 +172,6 @@ app.add_url_rule( view_func=StateAPI.as_view('state', microscope=api_microscope)) -# Positioning endpoints - class PositionAPI(MicroscopeView): def get(self): @@ -243,7 +244,7 @@ class PositionAPI(MicroscopeView): logging.debug(position) # Safeguard to prevent moving to an absolute position beyond a fixed limit - if not 'force' in state or state['force'] is False: # Allow for override + 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 @@ -262,8 +263,6 @@ app.add_url_rule( view_func=PositionAPI.as_view('position', microscope=api_microscope)) -# Capture endpoints - class CaptureListAPI(MicroscopeView): def get(self): @@ -372,8 +371,7 @@ class CaptureListAPI(MicroscopeView): if 'width' in state['size'] and 'height' in state['size']: resize = (state['size']['width'], state['size']['height']) else: - # TODO: Return error 4XX instead of exception - raise Exception("Invalid resize parameters passed. Ensure both width and height are specified.") + abort(400) else: resize = None @@ -519,7 +517,6 @@ class CaptureDownloadAPI(MicroscopeView): :status 200: capture data found :status 404: no capture found with that id """ - print(capture_id) capture_obj = self.microscope.camera.image_from_id(capture_id) if not capture_obj or not capture_obj.metadata['available']: From 2b89bea407fb6c351c569f5283926c7a044f6b31 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 20 Nov 2018 22:16:11 +0000 Subject: [PATCH 28/28] Separated out download redirect route --- openflexure_microscope/api/v1.py | 50 ++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index 6366a57b..e49a0d2f 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -487,18 +487,42 @@ app.add_url_rule( view_func=CaptureAPI.as_view('capture', microscope=api_microscope)) +class CaptureDownloadRedirectAPI(MicroscopeView): + def get(self, capture_id, filename): + """ + 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('/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. - If no (filename) is specified, the request will redirect to download the capture - under it's currently set filename. I.e., `/(capture_id)/download` will - redirect to `/(capture_id)/download/(filename)`. - - If (filename) is specified, the capture data will be returned 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. + 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 @@ -541,19 +565,9 @@ class CaptureDownloadAPI(MicroscopeView): as_attachment=as_attachment, attachment_filename=filename) -# General download view -download_view = CaptureDownloadAPI.as_view('capture_download', microscope=api_microscope) - -# Full route, including filename app.add_url_rule( uri('/capture//download/'), - view_func=download_view) - -# Route for shortcut where no filename is specified -app.add_url_rule( - uri('/capture//download/'), - defaults={'filename': None}, - view_func=download_view) + view_func=CaptureDownloadAPI.as_view('capture_download', microscope=api_microscope)) if __name__ == "__main__":