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 = ` -
-
-
- ${element.file}
- 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..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,18 +50,14 @@ body { clear: both; } -/* Columns within panels */ +/* Flexboxes */ +.flexbox { + display: flex; + margin-bottom: 5px; +} -.left-col { - float:left; -} -.right-col { - float:right; -} -.clearfix::after { - content: ""; - clear: both; - display: table; +.flexgrow { + flex-grow: 1; } /* Data divs */ @@ -72,10 +68,39 @@ 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 { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-left: 5px; } .capture img { - width: 60px; + width: 100%; + height: 100%; + object-fit: cover; +} + +.capture-button { + width: 75px; + margin-bottom: 3px; + height: 22px; } \ 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/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:
+
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