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..10c9c94d
--- /dev/null
+++ b/doc/requirements.txt
@@ -0,0 +1,6 @@
+Sphinx
+sphinxcontrib-httpdomain
+sphinx_rtd_theme
+openflexure-stage
+Pillow
+pyyaml
\ 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..1c914c2c
--- /dev/null
+++ b/doc/source/conf.py
@@ -0,0 +1,218 @@
+# -*- 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
+
+# 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 -----------------------------------------------------
+
+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/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/static/main_v1.js b/openflexure_microscope/api/static/main_v1.js
index 3371fded..03ab67be 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) {
@@ -66,13 +77,16 @@ function updateCaptures(response) {
// Generate inner HTML from capture object
html = `
-
diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py
index 8c50d524..e49a0d2f 100644
--- a/openflexure_microscope/api/v1.py
+++ b/openflexure_microscope/api/v1.py
@@ -1,24 +1,23 @@
#!/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)
"""
-from pprint import pprint
+import numpy as np
from importlib import import_module
import time
import datetime
+import os
from flask import (
- Flask, render_template, Response,
+ Flask, render_template, Response, url_for,
redirect, request, jsonify, send_file, abort,
make_response)
-
-import numpy as np
+from flask.views import MethodView
+from werkzeug.exceptions import default_exceptions
from openflexure_microscope.api.utilities import parse_payload, gen, get_bool
@@ -27,67 +26,201 @@ from openflexure_microscope.camera.pi import StreamingCamera
from openflexure_stage import OpenFlexureStage
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.")
-# 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(),
- OpenFlexureStage("/dev/ttyUSB0")
-)
+# Generate API URI based on version from filename
+def uri(suffix, base=None):
+ if not base:
+ api_ver = os.path.splitext(os.path.basename(__file__))[0]
+ base = "/api/{}".format(api_ver)
+ uri = base + suffix
+ logging.debug("Created app route: {}".format(uri))
+ return uri
# Create flask app
app = Flask(__name__)
+app.url_map.strict_slashes = False
-# Some useful functions
-def uri(suffix, base='/api/v1'):
- return base + suffix
-# Define front-end routes
+# Make errors more API friendly
+
+def _handle_http_exception(e):
+ return make_response(
+ jsonify({
+ 'status_code': e.code,
+ 'error': e.name,
+ 'details': e.description
+ }),
+ e.code)
+
+for code in default_exceptions:
+ app.errorhandler(code)(_handle_http_exception)
+
+
+# After app starts, but before first request, attach hardware to global microscope
+@app.before_first_request
+def attach_microscope():
+ # Create the microscope object globally (common to all spawned server threads)
+ global api_microscope
+ logging.debug("First request made. Populating microscope with hardware...")
+ api_microscope.attach(
+ StreamingCamera(),
+ OpenFlexureStage("/dev/ttyUSB0")
+ )
+ logging.debug("Microscope successfully attached!")
+
+
+##### WEBAPP ROUTES ######
@app.route('/')
def index():
- """Video streaming home page."""
+ """
+ API demo app
+ """
return render_template(
'index_v1.html'
)
-# Define API routes
+##### API ROUTES ######
-# Basic routes
-@app.route(uri('/stream'))
-def stream():
- """Video streaming route. Put this in the src attribute of an img tag."""
- global microscope
+# Basic microscope view
- # Restart stream worker thread
- microscope.camera.start_worker()
+class MicroscopeView(MethodView):
- return Response(
- gen(microscope.camera),
- mimetype='multipart/x-mixed-replace; boundary=frame')
+ def __init__(self, microscope):
+ """
+ Create a generic MethodView with a globally available
+ microscope object passed as an argument.
+ """
+ self.microscope = microscope
-@app.route(uri('/state'))
-def state():
- """Return JSONified microscope state"""
- global microscope
+ MethodView.__init__(self)
- return jsonify(microscope.state)
-# Positioning routes
+class StreamAPI(MicroscopeView):
-@app.route(uri('/position'), methods=['GET', 'POST', 'PUT'])
-def position():
- """Set and get the microscope stage position"""
- global microscope
+ def get(self):
+ """
+ Real-time MJPEG stream from the microscope camera
- if request.method == 'POST' or request.method == 'PUT':
+ .. :quickref: State; Camera stream
+
+ :>header Accept: image/jpeg
+ :>header Content-Type: image/jpeg
+ :status 200: stream active
+ """
+ # Restart stream worker thread
+ self.microscope.camera.start_worker()
+
+ return Response(
+ gen(self.microscope.camera),
+ mimetype='multipart/x-mixed-replace; boundary=frame')
+
+app.add_url_rule(
+ uri('/stream/'),
+ view_func=StreamAPI.as_view('stream', microscope=api_microscope))
+
+
+class StateAPI(MicroscopeView):
+
+ def get(self):
+ """
+ JSON representation of the microscope object.
+
+ .. :quickref: State; Microscope state
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ GET /state/ HTTP/1.1
+ Accept: application/json
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 200 OK
+ Vary: Accept
+ Content-Type: application/json
+
+ {
+ "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)
+
+app.add_url_rule(
+ uri('/state/'),
+ view_func=StateAPI.as_view('state', microscope=api_microscope))
+
+
+class PositionAPI(MicroscopeView):
+
+ def get(self):
+ """
+ Return current x, y and z positions of the stage.
+
+ .. :quickref: Position; Get current position
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ GET /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
+ : travel_limit:
# Respond with 400 Bad Request
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=api_microscope))
-# Capture routes
+class CaptureListAPI(MicroscopeView):
-@app.route(uri('/capture/'), methods=['GET', 'POST', 'PUT'])
-def capture():
- """Return JSONified microscope state"""
- global microscope
+ def get(self):
+ """
+ Get list of image captures.
- if request.method == 'POST' or request.method == 'PUT':
+ .. :quickref: Capture collection; Get collection of captures
+
+ :>header Accept: application/json
+ :query include_unavailable: return json representations of captures that have been completely deleted
+
+ :>jsonarr boolean available: availability of capture data
+ :>jsonarr string filename: filename of capture
+ :>jsonarr string id: unique id of the capture object
+ :>jsonarr boolean keep_on_disk: keep the capture file on microscope after closing
+ :>jsonarr boolean locked: file locked for modifications (mostly used for video recording)
+ :>jsonarr string path: path on pi storage to the capture file, if available
+ :>jsonarr boolean stream: capture stored in-memory as a BytesIO stream
+ :>jsonarr json uri: - **download** *(string)*: api uri to the capture file download
+ - **metadata** *(string)*: api uri to the capture json representation
+
+ :>header Content-Type: application/json
+ :status 200: capture found
+ :status 404: no capture found with that id
+ """
+ include_unavailable = get_bool(request.args.get('include_unavailable'))
+
+ if include_unavailable:
+ captures = [image.metadata for image in self.microscope.camera.images]
+ else:
+ captures = [image.metadata for image in self.microscope.camera.images if image.metadata['available']]
+
+ return jsonify(captures)
+
+ def delete(self):
+ """
+ Delete all captures (not yet implemented)
+
+ .. :quickref: Capture collection; Delete all captures
+ """
+ return jsonify({"error": "not yet implemented"})
+
+ def post(self):
+ """
+ Create a new image capture.
+
+ .. :quickref: Capture collection; New capture
+
+ **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)
+
+ if not capture_obj:
+ return abort(404) # 404 Not Found
+
+ # Get capture metadata
+ capture_metadata = capture_obj.metadata
+
+ # TODO: Tidy up adding URI to metadata
+ # Add API routes to returned metadata
+ uri_dict = {
+ 'uri': {'metadata': uri('/capture/{}/'.format(capture_id))}
+ }
+
+ # If available, also add download link
+ if capture_metadata['available']:
+ uri_dict['uri']['download'] = uri('/capture/{}/download/{}'.format(capture_obj.id, capture_obj.filename))
+
+ capture_metadata.update(uri_dict)
+
+ return jsonify(capture_metadata)
+
+ def delete(self, capture_id):
+ """
+ Delete all capture data from the Pi, even if `keep_on_disk=true;`.
+
+ .. :quickref: Capture; Delete capture.
+ """
+ capture_obj = self.microscope.camera.image_from_id(capture_id)
+
+ if not capture_obj:
+ return abort(404) # 404 Not Found
+
+ capture_obj.delete()
+
+ return jsonify({"return": capture_id})
+
+ def put(self, capture_id):
+ """
+ Modify the metadata of a capture (not yet implemented)
+
+ .. :quickref: Capture; Update capture metadata
+ """
+ return jsonify({"return": capture_id})
+
+app.add_url_rule(
+ uri('/capture//'),
+ 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.
+
+ Return capture data as an image file with the requested filename.
+ I.e., `/(capture_id)/download/foo.jpeg` will download the image as
+ `foo.jpeg`, regardless of the capture's initially set filename.
+
+ .. :quickref: Capture; Download capture file
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ GET /capture/d0b2067abbb946f19351e075c5e7cd5b/download/2018-11-20_16-04-17.jpeg HTTP/1.1
+ Accept: image/jpeg
+
+ :>header Accept: image/jpeg
+ :query thumbnail: return an image thumbnail e.g. ?thumbnail=true
+ :query as_attachment: return the image as an attachment download e.g. ?as_attachment=true
+
+ :>header Content-Type: image/jpeg
+ :status 200: capture data found
+ :status 404: no capture found with that id
+ """
+ capture_obj = self.microscope.camera.image_from_id(capture_id)
+
+ if not capture_obj or not capture_obj.metadata['available']:
+ return abort(404) # 404 Not Found
+
+ as_attachment = get_bool(request.args.get('as_attachment'))
+ thumbnail = get_bool(request.args.get('thumbnail'))
+
+ # If no filename is specified, redirect to the capture's currently set filename
+ if not filename:
+ return redirect(url_for('capture_download', capture_id=capture_id, filename=capture_obj.filename, as_attachment=as_attachment, thumbnail=thumbnail), code=307)
+
+ # Download the image data using the requested filename
+ if thumbnail:
+ img = capture_obj.thumbnail
else:
- 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=filename)
- return jsonify(metadata_array)
+app.add_url_rule(
+ uri('/capture//download/'),
+ view_func=CaptureDownloadAPI.as_view('capture_download', microscope=api_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', 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/camera/base.py b/openflexure_microscope/camera/base.py
index 0649cb71..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,14 +34,18 @@ def entry_by_id(id: str, object_list: list):
return found
-class CameraEvent(object):
- def __init__(self):
- """
- Create a frame-signaller object for StreamingCamera.
+def generate_basename():
+ """Return a default filename based on the capture datetime"""
+ return datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
- An event-like class that signals all active clients
- when a new frame is available.
- """
+
+class CameraEvent(object):
+ """
+ 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.
+ """
+ def __init__(self):
self.events = {}
def wait(self, timeout: int=5):
@@ -79,21 +84,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."""
@@ -221,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 34fb3339..d9572513 100644
--- a/openflexure_microscope/camera/capture.py
+++ b/openflexure_microscope/camera/capture.py
@@ -11,9 +11,12 @@ 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,
+ write_to_file: bool=False,
keep_on_disk: bool=True,
filename: str=None,
folder: str=None,
@@ -21,25 +24,15 @@ 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
- # 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, self.format)
# Byte stream properties
self.stream = io.BytesIO() # Byte stream that data will be written to
@@ -62,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):
@@ -82,21 +75,17 @@ 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)
+ appendix = ""
+ if not self.keep_on_disk:
+ appendix += ".tmp"
+
+ file_name = "{}.{}".format(filename, fmt)
# Create folder and file
if folder:
@@ -107,7 +96,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."""
@@ -144,16 +135,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
@@ -183,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
@@ -204,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)
@@ -222,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
@@ -254,6 +237,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
diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py
index 9e631251..2bc63d19 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']:
@@ -222,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()
@@ -274,9 +292,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 +314,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,28 +348,33 @@ 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 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
@@ -382,9 +405,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(
@@ -394,9 +414,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 355f23ea..75449b07 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 logging
import numpy as np
from openflexure_stage import OpenFlexureStage
@@ -6,11 +10,17 @@ 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."""
- self.camera = camera
- self.stage = stage
- self.stage.backlash = np.zeros(3, dtype=np.int)
+ self.attach(camera, stage)
def __enter__(self):
"""Create microscope on context enter."""
@@ -24,10 +34,36 @@ 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
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