Merge branch 'method-views' into 'master'
Method views See merge request openflexure/openflexure-microscope-software!1
This commit is contained in:
commit
c5bba5d855
21 changed files with 1258 additions and 284 deletions
19
doc/Makefile
Normal file
19
doc/Makefile
Normal file
|
|
@ -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)
|
||||||
35
doc/make.bat
Normal file
35
doc/make.bat
Normal file
|
|
@ -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
|
||||||
6
doc/requirements.txt
Normal file
6
doc/requirements.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
Sphinx
|
||||||
|
sphinxcontrib-httpdomain
|
||||||
|
sphinx_rtd_theme
|
||||||
|
openflexure-stage
|
||||||
|
Pillow
|
||||||
|
pyyaml
|
||||||
24
doc/source/api.rst
Normal file
24
doc/source/api.rst
Normal file
|
|
@ -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
|
||||||
5
doc/source/basecamera.rst
Normal file
5
doc/source/basecamera.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
Base Streaming Camera
|
||||||
|
=======================================================
|
||||||
|
|
||||||
|
.. automodule:: openflexure_microscope.camera.base
|
||||||
|
:members:
|
||||||
10
doc/source/camera.rst
Normal file
10
doc/source/camera.rst
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
Camera Functionality
|
||||||
|
=======================================================
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
:caption: Contents:
|
||||||
|
|
||||||
|
picamera.rst
|
||||||
|
basecamera.rst
|
||||||
|
capture.rst
|
||||||
5
doc/source/capture.rst
Normal file
5
doc/source/capture.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
Stream Object
|
||||||
|
=======================================================
|
||||||
|
|
||||||
|
.. automodule:: openflexure_microscope.camera.capture
|
||||||
|
:members:
|
||||||
218
doc/source/conf.py
Normal file
218
doc/source/conf.py
Normal file
|
|
@ -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
|
||||||
18
doc/source/index.rst
Normal file
18
doc/source/index.rst
Normal file
|
|
@ -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`
|
||||||
9
doc/source/microscope.rst
Normal file
9
doc/source/microscope.rst
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
Microscope class
|
||||||
|
=======================================================
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
:caption: Contents:
|
||||||
|
|
||||||
|
.. automodule:: openflexure_microscope.microscope
|
||||||
|
:members:
|
||||||
5
doc/source/picamera.rst
Normal file
5
doc/source/picamera.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
Raspberry Pi Streaming Camera
|
||||||
|
=======================================================
|
||||||
|
|
||||||
|
.. automodule:: openflexure_microscope.camera.pi
|
||||||
|
:members:
|
||||||
187
openflexure_microscope/api/app.py
Normal file
187
openflexure_microscope/api/app.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -44,15 +44,26 @@ window.onload = function() {
|
||||||
getCaptures()
|
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 getCaptures() {
|
||||||
function updateCapturesCallback(response, status) {
|
function updateCapturesCallback(response, status) {
|
||||||
console.log(status);
|
console.log(status);
|
||||||
updateCaptures(response);
|
updateCaptureList(response);
|
||||||
}
|
}
|
||||||
safeRequest("GET", baseURI+"/capture", null, updateCapturesCallback, false)
|
safeRequest("GET", baseURI+"/capture", null, updateCapturesCallback, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCaptures(response) {
|
function updateCaptureList(response) {
|
||||||
// Clear captures list
|
// Clear captures list
|
||||||
var capturesNode = document.getElementById("captures");
|
var capturesNode = document.getElementById("captures");
|
||||||
while (capturesNode.firstChild) {
|
while (capturesNode.firstChild) {
|
||||||
|
|
@ -66,13 +77,16 @@ function updateCaptures(response) {
|
||||||
|
|
||||||
// Generate inner HTML from capture object
|
// Generate inner HTML from capture object
|
||||||
html = `
|
html = `
|
||||||
<div class="clearfix">
|
<div class="capture-thumb"><img src="${element_uri}/download?thumbnail=true"></div>
|
||||||
<div class="left-col"><img src="${element_uri}/thumbnail"></div>
|
<div class="capture-actions">
|
||||||
<div class="right-col">
|
<div class='capture-heading'>${element.filename}</div>
|
||||||
<b>${element.file}</b><br>
|
<br>
|
||||||
<a href="${element_uri}/download" target="_blank">View</a> <a href="${element_uri}/download?as_attachment=true">Download</a> Delete <a href="${element_uri}/" target="_blank">JSON</a>
|
<button type="button" class="capture-button" onclick="window.open('${element_uri}/download', '_blank');">View</button>
|
||||||
<br>
|
<button type="button" class="capture-button" onclick="window.open('${element_uri}/download?as_attachment=true');">Download</button>
|
||||||
</div>
|
<br>
|
||||||
|
<button type="button" class="capture-button" onclick="window.open('${element_uri}/', '_blank');">JSON</button>
|
||||||
|
<button type="button" class="capture-button" onclick="deleteCapture('${element.id}');">Delete</button>
|
||||||
|
<br>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,18 +28,18 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.left{
|
.left{
|
||||||
width: 250px;
|
width: 220px;
|
||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Right{
|
.Right{
|
||||||
width: 250px;
|
width: 280px;
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.middle {
|
.middle {
|
||||||
margin-left: 250px;
|
margin-left: 220px;
|
||||||
margin-right: 250px;
|
margin-right: 280px;
|
||||||
background-color: #d3d3d3;
|
background-color: #d3d3d3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,18 +50,14 @@ body {
|
||||||
clear: both;
|
clear: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Columns within panels */
|
/* Flexboxes */
|
||||||
|
.flexbox {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.left-col {
|
.flexgrow {
|
||||||
float:left;
|
flex-grow: 1;
|
||||||
}
|
|
||||||
.right-col {
|
|
||||||
float:right;
|
|
||||||
}
|
|
||||||
.clearfix::after {
|
|
||||||
content: "";
|
|
||||||
clear: both;
|
|
||||||
display: table;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Data divs */
|
/* Data divs */
|
||||||
|
|
@ -72,10 +68,39 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.capture {
|
.capture {
|
||||||
background-color: #d3d3d3;
|
height: 75px;
|
||||||
|
width: 260px;
|
||||||
margin-bottom: 5px;
|
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 {
|
.capture img {
|
||||||
width: 60px;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-button {
|
||||||
|
width: 75px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
height: 22px;
|
||||||
}
|
}
|
||||||
9
openflexure_microscope/api/templates/index.html
Normal file
9
openflexure_microscope/api/templates/index.html
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Video Streaming Demonstration</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Video Streaming Demonstration</h1>
|
||||||
|
<img src="{{ url_for('stream') }}">
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -25,16 +25,16 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Doubleclick to position: <input type="checkbox" id="clickPositionCheck" checked><br>
|
Doubleclick to position: <input type="checkbox" id="clickPositionCheck" checked><br>
|
||||||
<div class="clearfix">
|
<div class="flexbox">
|
||||||
<div class="left-col">FOV width:</div>
|
<div class="flexgrow">FOV width:</div>
|
||||||
<div class="right-col">
|
<div>
|
||||||
<input type="number" id="fovXText" onchange="fovX = Number(this.value); updateTextBoxes();" style="width: 7em" >
|
<input type="number" id="fovXText" onchange="fovX = Number(this.value); updateTextBoxes();" style="width: 7em" >
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="clearfix">
|
<div class="flexbox">
|
||||||
<div class="left-col">FOV height:</div>
|
<div class="flexgrow">FOV height:</div>
|
||||||
<div class="right-col">
|
<div>
|
||||||
<input type="number" id="fovYText" onchange="fovY = Number(this.value); updateTextBoxes();" style="width: 7em" >
|
<input type="number" id="fovYText" onchange="fovY = Number(this.value); updateTextBoxes();" style="width: 7em" >
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -51,16 +51,16 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h4>Velocity:</h4>
|
<h4>Velocity:</h4>
|
||||||
<div class="clearfix">
|
<div class="flexbox">
|
||||||
<div class="left-col">x-y:</div>
|
<div class="flexgrow">x-y:</div>
|
||||||
<div class="right-col">
|
<div>
|
||||||
<input type="range" id="stageVelocityInput" min="50" max="200" oninput="stageVelocity = Number(this.value); updateTextBoxes();">
|
<input type="range" id="stageVelocityInput" min="50" max="200" oninput="stageVelocity = Number(this.value); updateTextBoxes();">
|
||||||
<input type="text" id="stageVelocityText" size="3" disabled>
|
<input type="text" id="stageVelocityText" size="3" disabled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="clearfix">
|
<div class="flexbox">
|
||||||
<div class="left-col">z:</div>
|
<div class="flexgrow">z:</div>
|
||||||
<div class="right-col">
|
<div>
|
||||||
<input type="range" id="focusVelocityInput" min="10" max="100" oninput="focusVelocity = Number(this.value); updateTextBoxes();">
|
<input type="range" id="focusVelocityInput" min="10" max="100" oninput="focusVelocity = Number(this.value); updateTextBoxes();">
|
||||||
<input type="text" id="focusVelocityText" size="3" disabled>
|
<input type="text" id="focusVelocityText" size="3" disabled>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,23 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""
|
"""
|
||||||
TODO: Add proper docstrings
|
TODO: Add proper docstrings
|
||||||
TODO: Bind to port 80
|
|
||||||
TODO: Reimplement capture methods
|
|
||||||
TODO: Implement API route to cleanly shut down server
|
TODO: Implement API route to cleanly shut down server
|
||||||
TODO: Implement microscope function API routes (autofocus etc)
|
TODO: Implement microscope function API routes (autofocus etc)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pprint import pprint
|
import numpy as np
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
import time
|
import time
|
||||||
import datetime
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Flask, render_template, Response,
|
Flask, render_template, Response, url_for,
|
||||||
redirect, request, jsonify, send_file, abort,
|
redirect, request, jsonify, send_file, abort,
|
||||||
make_response)
|
make_response)
|
||||||
|
|
||||||
|
from flask.views import MethodView
|
||||||
import numpy as np
|
from werkzeug.exceptions import default_exceptions
|
||||||
|
|
||||||
from openflexure_microscope.api.utilities import parse_payload, gen, get_bool
|
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
|
from openflexure_stage import OpenFlexureStage
|
||||||
|
|
||||||
import logging, sys
|
import logging, sys
|
||||||
|
|
||||||
logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
|
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
|
# Generate API URI based on version from filename
|
||||||
@app.errorhandler(404)
|
def uri(suffix, base=None):
|
||||||
def not_found(error):
|
if not base:
|
||||||
return make_response(jsonify({'error': 'Not found'}), 404)
|
api_ver = os.path.splitext(os.path.basename(__file__))[0]
|
||||||
|
base = "/api/{}".format(api_ver)
|
||||||
# Create the microscope object globally (common to all spawned server threads)
|
uri = base + suffix
|
||||||
microscope = Microscope(
|
logging.debug("Created app route: {}".format(uri))
|
||||||
StreamingCamera(),
|
return uri
|
||||||
OpenFlexureStage("/dev/ttyUSB0")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create flask app
|
# Create flask app
|
||||||
app = Flask(__name__)
|
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('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
"""Video streaming home page."""
|
"""
|
||||||
|
API demo app
|
||||||
|
"""
|
||||||
return render_template(
|
return render_template(
|
||||||
'index_v1.html'
|
'index_v1.html'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Define API routes
|
##### API ROUTES ######
|
||||||
|
|
||||||
# Basic routes
|
|
||||||
|
|
||||||
@app.route(uri('/stream'))
|
# Basic microscope view
|
||||||
def stream():
|
|
||||||
"""Video streaming route. Put this in the src attribute of an img tag."""
|
|
||||||
global microscope
|
|
||||||
|
|
||||||
# Restart stream worker thread
|
class MicroscopeView(MethodView):
|
||||||
microscope.camera.start_worker()
|
|
||||||
|
|
||||||
return Response(
|
def __init__(self, microscope):
|
||||||
gen(microscope.camera),
|
"""
|
||||||
mimetype='multipart/x-mixed-replace; boundary=frame')
|
Create a generic MethodView with a globally available
|
||||||
|
microscope object passed as an argument.
|
||||||
|
"""
|
||||||
|
self.microscope = microscope
|
||||||
|
|
||||||
@app.route(uri('/state'))
|
MethodView.__init__(self)
|
||||||
def state():
|
|
||||||
"""Return JSONified microscope state"""
|
|
||||||
global microscope
|
|
||||||
|
|
||||||
return jsonify(microscope.state)
|
|
||||||
|
|
||||||
# Positioning routes
|
class StreamAPI(MicroscopeView):
|
||||||
|
|
||||||
@app.route(uri('/position'), methods=['GET', 'POST', 'PUT'])
|
def get(self):
|
||||||
def position():
|
"""
|
||||||
"""Set and get the microscope stage position"""
|
Real-time MJPEG stream from the microscope camera
|
||||||
global microscope
|
|
||||||
|
|
||||||
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
|
||||||
|
:<json boolean absolute: (true) move to absolute position, (false) move by relative amount
|
||||||
|
:<json boolean force: allow moving by more than programmed limit
|
||||||
|
:<json int x: x steps
|
||||||
|
:<json int y: y steps
|
||||||
|
:<json int z: z steps
|
||||||
|
|
||||||
|
"""
|
||||||
# Get payload
|
# Get payload
|
||||||
state = parse_payload(request)
|
state = parse_payload(request)
|
||||||
logging.debug(state)
|
logging.debug(state)
|
||||||
|
|
@ -100,7 +233,7 @@ def position():
|
||||||
# Get coordinates from payload
|
# Get coordinates from payload
|
||||||
for axis, key in enumerate(['x', 'y', 'z']):
|
for axis, key in enumerate(['x', 'y', 'z']):
|
||||||
if key in state:
|
if key in state:
|
||||||
position[axis] = int(state[key]-microscope.stage.position[axis])
|
position[axis] = int(state[key]-self.microscope.stage.position[axis])
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Get coordinates from payload
|
# Get coordinates from payload
|
||||||
|
|
@ -111,32 +244,114 @@ def position():
|
||||||
logging.debug(position)
|
logging.debug(position)
|
||||||
|
|
||||||
# Safeguard to prevent moving to an absolute position beyond a fixed limit
|
# Safeguard to prevent moving to an absolute position beyond a fixed limit
|
||||||
if not 'force' in state or state['force'] is False: # Allow for override
|
if 'force' not in state or state['force'] is False: # Allow for override
|
||||||
# TODO: Make travel_limit a property of the stage or microscope
|
# TODO: Make travel_limit a property of the stage or microscope
|
||||||
# TODO: Make travel_limit a 3-axis list
|
# TODO: Make travel_limit a 3-axis list
|
||||||
travel_limit = 2000
|
travel_limit = 20000
|
||||||
for axis, pos in enumerate(position):
|
for axis, pos in enumerate(position):
|
||||||
if abs(pos) > travel_limit:
|
if abs(pos) > travel_limit:
|
||||||
# Respond with 400 Bad Request
|
# Respond with 400 Bad Request
|
||||||
response = {'error': 'Cannot move to absolute position beyond the safeguard limit.'}
|
response = {'error': 'Cannot move to absolute position beyond the safeguard limit.'}
|
||||||
return jsonify(response), 400
|
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 get(self):
|
||||||
def capture():
|
"""
|
||||||
"""Return JSONified microscope state"""
|
Get list of image captures.
|
||||||
global microscope
|
|
||||||
|
|
||||||
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 string filename: filename of stored capture
|
||||||
|
:<json boolean keep_on_disk: keep the capture file on microscope after closing
|
||||||
|
:<json boolean use_video_port: capture still image from the video port
|
||||||
|
:<json json size: - **x** *(int)*: x-axis resize
|
||||||
|
- **y** *(int)*: y-axis resize
|
||||||
|
|
||||||
|
:>json boolean available: availability of capture data
|
||||||
|
:>json string filename: filename of capture
|
||||||
|
:>json string id: unique id of the capture object
|
||||||
|
:>json boolean keep_on_disk: keep the capture file on microscope after closing
|
||||||
|
:>json boolean locked: file locked for modifications (mostly used for video recording)
|
||||||
|
:>json string path: path on pi storage to the capture file, if available
|
||||||
|
:>json boolean stream: capture stored in-memory as a BytesIO stream
|
||||||
|
:>json json uri: - **download** *(string)*: api uri to the capture file download
|
||||||
|
- **metadata** *(string)*: api uri to the capture json representation
|
||||||
|
|
||||||
|
:<header Content-Type: application/json
|
||||||
|
:status 200: capture created
|
||||||
|
"""
|
||||||
state = parse_payload(request)
|
state = parse_payload(request)
|
||||||
print(state)
|
|
||||||
|
|
||||||
|
# TODO: Roll all of these ugly if statements into a method for getting payload elements
|
||||||
if 'filename' in state:
|
if 'filename' in state:
|
||||||
filename = state['filename']
|
filename = state['filename']
|
||||||
else:
|
else:
|
||||||
|
|
@ -156,11 +371,11 @@ def capture():
|
||||||
if 'width' in state['size'] and 'height' in state['size']:
|
if 'width' in state['size'] and 'height' in state['size']:
|
||||||
resize = (state['size']['width'], state['size']['height'])
|
resize = (state['size']['width'], state['size']['height'])
|
||||||
else:
|
else:
|
||||||
raise Exception("Invalid resize parameters passed. Ensure both width and height are specified.")
|
abort(400)
|
||||||
else:
|
else:
|
||||||
resize = None
|
resize = None
|
||||||
|
|
||||||
capture_obj = microscope.camera.capture(
|
capture_obj = self.microscope.camera.capture(
|
||||||
write_to_file=True, # Always write data to disk when using API
|
write_to_file=True, # Always write data to disk when using API
|
||||||
keep_on_disk=keep_on_disk,
|
keep_on_disk=keep_on_disk,
|
||||||
use_video_port=use_video_port,
|
use_video_port=use_video_port,
|
||||||
|
|
@ -169,85 +384,191 @@ def capture():
|
||||||
|
|
||||||
return jsonify(capture_obj.metadata)
|
return jsonify(capture_obj.metadata)
|
||||||
|
|
||||||
else: # GET requests
|
app.add_url_rule(
|
||||||
include_unavailable = get_bool(request.args.get('include_unavailable'))
|
uri('/capture/'),
|
||||||
|
view_func=CaptureListAPI.as_view('capture_list', microscope=api_microscope))
|
||||||
|
|
||||||
if include_unavailable:
|
|
||||||
captures = [image for image in microscope.camera.images if image.metadata['available']]
|
class CaptureAPI(MicroscopeView):
|
||||||
|
|
||||||
|
def get(self, capture_id):
|
||||||
|
"""
|
||||||
|
Get JSON representation of a capture
|
||||||
|
|
||||||
|
.. :quickref: Capture; Get capture
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /capture/d0b2067abbb946f19351e075c5e7cd5b/ HTTP/1.1
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"available": true,
|
||||||
|
"filename": "2018-11-16_10-21-53.jpeg",
|
||||||
|
"id": "d0b2067abbb946f19351e075c5e7cd5b",
|
||||||
|
"keep_on_disk": false,
|
||||||
|
"locked": false,
|
||||||
|
"path": "capture/2018-11-16_10-21-53.jpeg",
|
||||||
|
"stream": false,
|
||||||
|
"uri": {
|
||||||
|
"download": "/api/v1/capture/d0b2067abbb946f19351e075c5e7cd5b/download",
|
||||||
|
"metadata": "/api/v1/capture/d0b2067abbb946f19351e075c5e7cd5b/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:>json boolean available: availability of capture data
|
||||||
|
:>json string filename: filename of capture
|
||||||
|
:>json string id: unique id of the capture object
|
||||||
|
:>json boolean keep_on_disk: keep the capture file on microscope after closing
|
||||||
|
:>json boolean locked: file locked for modifications (mostly used for video recording)
|
||||||
|
:>json string path: path on pi storage to the capture file, if available
|
||||||
|
:>json boolean stream: capture stored in-memory as a BytesIO stream
|
||||||
|
:>json json uri: - **download** *(string)*: api uri to the capture file download
|
||||||
|
- **metadata** *(string)*: api uri to the capture json representation
|
||||||
|
|
||||||
|
"""
|
||||||
|
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
||||||
|
|
||||||
|
if not capture_obj:
|
||||||
|
return abort(404) # 404 Not Found
|
||||||
|
|
||||||
|
# Get capture metadata
|
||||||
|
capture_metadata = capture_obj.metadata
|
||||||
|
|
||||||
|
# TODO: Tidy up adding URI to metadata
|
||||||
|
# Add API routes to returned metadata
|
||||||
|
uri_dict = {
|
||||||
|
'uri': {'metadata': uri('/capture/{}/'.format(capture_id))}
|
||||||
|
}
|
||||||
|
|
||||||
|
# If available, also add download link
|
||||||
|
if capture_metadata['available']:
|
||||||
|
uri_dict['uri']['download'] = uri('/capture/{}/download/{}'.format(capture_obj.id, capture_obj.filename))
|
||||||
|
|
||||||
|
capture_metadata.update(uri_dict)
|
||||||
|
|
||||||
|
return jsonify(capture_metadata)
|
||||||
|
|
||||||
|
def delete(self, capture_id):
|
||||||
|
"""
|
||||||
|
Delete all capture data from the Pi, even if `keep_on_disk=true;`.
|
||||||
|
|
||||||
|
.. :quickref: Capture; Delete capture.
|
||||||
|
"""
|
||||||
|
capture_obj = self.microscope.camera.image_from_id(capture_id)
|
||||||
|
|
||||||
|
if not capture_obj:
|
||||||
|
return abort(404) # 404 Not Found
|
||||||
|
|
||||||
|
capture_obj.delete()
|
||||||
|
|
||||||
|
return jsonify({"return": capture_id})
|
||||||
|
|
||||||
|
def put(self, capture_id):
|
||||||
|
"""
|
||||||
|
Modify the metadata of a capture (not yet implemented)
|
||||||
|
|
||||||
|
.. :quickref: Capture; Update capture metadata
|
||||||
|
"""
|
||||||
|
return jsonify({"return": capture_id})
|
||||||
|
|
||||||
|
app.add_url_rule(
|
||||||
|
uri('/capture/<capture_id>/'),
|
||||||
|
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/<capture_id>/download/'),
|
||||||
|
view_func=CaptureDownloadRedirectAPI.as_view('capture_download_redirect', microscope=api_microscope))
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureDownloadAPI(MicroscopeView):
|
||||||
|
def get(self, capture_id, filename):
|
||||||
|
"""
|
||||||
|
Return image data for a capture.
|
||||||
|
|
||||||
|
Return capture data as an image file with the requested filename.
|
||||||
|
I.e., `/(capture_id)/download/foo.jpeg` will download the image as
|
||||||
|
`foo.jpeg`, regardless of the capture's initially set filename.
|
||||||
|
|
||||||
|
.. :quickref: Capture; Download capture file
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /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:
|
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/<capture_id>/download/<filename>'),
|
||||||
|
view_func=CaptureDownloadAPI.as_view('capture_download', microscope=api_microscope))
|
||||||
|
|
||||||
|
|
||||||
@app.route(uri('/capture/<capture_uuid>/'), methods=['GET', 'DELETE'])
|
if __name__ == "__main__":
|
||||||
def get_capture(capture_uuid):
|
app.run(host='0.0.0.0', port="5000", threaded=True, debug=True, use_reloader=False)
|
||||||
"""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/<capture_uuid>/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/<capture_uuid>/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)
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import time
|
||||||
import io
|
import io
|
||||||
import threading
|
import threading
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -33,14 +34,18 @@ def entry_by_id(id: str, object_list: list):
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
class CameraEvent(object):
|
def generate_basename():
|
||||||
def __init__(self):
|
"""Return a default filename based on the capture datetime"""
|
||||||
"""
|
return datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
Create a frame-signaller object for StreamingCamera.
|
|
||||||
|
|
||||||
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 = {}
|
self.events = {}
|
||||||
|
|
||||||
def wait(self, timeout: int=5):
|
def wait(self, timeout: int=5):
|
||||||
|
|
@ -79,21 +84,23 @@ class CameraEvent(object):
|
||||||
|
|
||||||
|
|
||||||
class BaseCamera(object):
|
class BaseCamera(object):
|
||||||
|
"""
|
||||||
|
Base implementation of StreamingCamera.
|
||||||
|
"""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Base implementation of StreamingCamera."""
|
self.thread = None #: Background thread reading frames from camera
|
||||||
self.thread = None # Background thread that reads frames from camera
|
self.camera = None #: Camera object
|
||||||
self.camera = None # Camera object, for direct access to camera
|
|
||||||
|
|
||||||
self.frame = None # Current frame is stored here by background thread
|
self.frame = None #: bytes: Current frame is stored here by background thread
|
||||||
self.last_access = 0 # Time of last client access to the camera
|
self.last_access = 0 #: time: Time of last client access to the camera
|
||||||
self.event = CameraEvent()
|
self.event = CameraEvent()
|
||||||
|
|
||||||
self.state = {} # Create dict for capture state
|
self.state = {} #: dict: Dictionary for capture state
|
||||||
self.settings = {} # Create dict to store settings
|
self.settings = {} #: dict: Dictionary of camera settings
|
||||||
|
|
||||||
# Capture data
|
# Capture data
|
||||||
self.images = []
|
self.images = [] #: list: List of image capture objects
|
||||||
self.videos = []
|
self.videos = [] #: list: List of video recording objects
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
"""Create camera on context enter."""
|
"""Create camera on context enter."""
|
||||||
|
|
@ -221,6 +228,18 @@ class BaseCamera(object):
|
||||||
self.videos,
|
self.videos,
|
||||||
shunt_others=shunt_others)
|
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
|
# WORKER THREAD
|
||||||
|
|
||||||
def _thread(self):
|
def _thread(self):
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,12 @@ thumbnail_size = (60, 60)
|
||||||
|
|
||||||
|
|
||||||
class StreamObject(object):
|
class StreamObject(object):
|
||||||
|
"""
|
||||||
|
StreamObject used to store and process capture data, and metadata.
|
||||||
|
"""
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
write_to_file: bool=None,
|
write_to_file: bool=False,
|
||||||
keep_on_disk: bool=True,
|
keep_on_disk: bool=True,
|
||||||
filename: str=None,
|
filename: str=None,
|
||||||
folder: str=None,
|
folder: str=None,
|
||||||
|
|
@ -21,25 +24,15 @@ class StreamObject(object):
|
||||||
"""Create a new StreamObject, to manage capture data."""
|
"""Create a new StreamObject, to manage capture data."""
|
||||||
# Store a nice ID
|
# Store a nice ID
|
||||||
self.id = uuid.uuid4().hex
|
self.id = uuid.uuid4().hex
|
||||||
logging.info("Created {}".format(self.id))
|
logging.info("Created StreamObject {}".format(self.id))
|
||||||
|
|
||||||
# Store file format
|
# Store file format
|
||||||
self.format = fmt
|
self.format = fmt
|
||||||
|
|
||||||
# Create file name
|
# Create file name. Default to UUID
|
||||||
iterator = 0
|
if not filename:
|
||||||
f_path, f_name = self.build_file_path(filename, folder, fmt)
|
filename = self.id
|
||||||
|
self.build_file_path(filename, folder, self.format)
|
||||||
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
|
|
||||||
|
|
||||||
# Byte stream properties
|
# Byte stream properties
|
||||||
self.stream = io.BytesIO() # Byte stream that data will be written to
|
self.stream = io.BytesIO() # Byte stream that data will be written to
|
||||||
|
|
@ -62,7 +55,7 @@ class StreamObject(object):
|
||||||
# Object lock
|
# Object lock
|
||||||
self.locked = False
|
self.locked = False
|
||||||
|
|
||||||
# Thumbnail (populated only for JPEG captures)
|
# Thumbnail (populated only for PIL captures)
|
||||||
self.thumb_bytes = None
|
self.thumb_bytes = None
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
|
|
@ -82,21 +75,17 @@ class StreamObject(object):
|
||||||
self,
|
self,
|
||||||
filename: str,
|
filename: str,
|
||||||
folder: str,
|
folder: str,
|
||||||
fmt: str,
|
fmt: str):
|
||||||
iterator: int=0) -> str:
|
|
||||||
"""
|
"""
|
||||||
Construct a full file path, based on filename, folder, and file format.
|
Construct a full file path, based on filename, folder, and file format.
|
||||||
|
|
||||||
Defaults to datestamp.
|
Defaults to datestamp.
|
||||||
Iterator adds a numeric increment to the file name.
|
|
||||||
"""
|
"""
|
||||||
if filename:
|
appendix = ""
|
||||||
file_name = "{}.{}".format(filename, fmt)
|
if not self.keep_on_disk:
|
||||||
else:
|
appendix += ".tmp"
|
||||||
file_name_base = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
||||||
if iterator:
|
file_name = "{}.{}".format(filename, fmt)
|
||||||
file_name_base = "{}_{}".format(file_name_base, iterator)
|
|
||||||
file_name = "{}.{}".format(file_name_base, fmt)
|
|
||||||
|
|
||||||
# Create folder and file
|
# Create folder and file
|
||||||
if folder:
|
if folder:
|
||||||
|
|
@ -107,7 +96,9 @@ class StreamObject(object):
|
||||||
else:
|
else:
|
||||||
file_path = file_name
|
file_path = file_name
|
||||||
|
|
||||||
return (file_path, file_name)
|
self.basename = filename
|
||||||
|
self.file = file_path
|
||||||
|
self.filename = file_name
|
||||||
|
|
||||||
def lock(self):
|
def lock(self):
|
||||||
"""Set locked flag to True."""
|
"""Set locked flag to True."""
|
||||||
|
|
@ -144,16 +135,10 @@ class StreamObject(object):
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'locked': self.locked,
|
'locked': self.locked,
|
||||||
'keep_on_disk': self.keep_on_disk,
|
'keep_on_disk': self.keep_on_disk,
|
||||||
|
'filename': self.filename,
|
||||||
'path': self.file,
|
'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
|
# Check stream
|
||||||
if self.stream_exists:
|
if self.stream_exists:
|
||||||
d['stream'] = True
|
d['stream'] = True
|
||||||
|
|
@ -183,7 +168,6 @@ class StreamObject(object):
|
||||||
|
|
||||||
else: # If data stream is empty
|
else: # If data stream is empty
|
||||||
if self.file_exists: # If data file exists
|
if self.file_exists: # If data file exists
|
||||||
# TODO: Streamline this bit
|
|
||||||
logging.info("Opening from file {}".format(self.file))
|
logging.info("Opening from file {}".format(self.file))
|
||||||
with open(self.file, 'rb') as f:
|
with open(self.file, 'rb') as f:
|
||||||
d = io.BytesIO(f.read()) # Load bytes from file
|
d = io.BytesIO(f.read()) # Load bytes from file
|
||||||
|
|
@ -204,7 +188,7 @@ class StreamObject(object):
|
||||||
def thumbnail(self) -> io.BytesIO:
|
def thumbnail(self) -> io.BytesIO:
|
||||||
# If no thumbnail exists, try and make one
|
# If no thumbnail exists, try and make one
|
||||||
if not self.thumb_bytes:
|
if not self.thumb_bytes:
|
||||||
print("Building thumbnail")
|
logging.info("Building thumbnail")
|
||||||
if self.format.upper() in pil_formats:
|
if self.format.upper() in pil_formats:
|
||||||
im = Image.open(self.data)
|
im = Image.open(self.data)
|
||||||
im.thumbnail(thumbnail_size)
|
im.thumbnail(thumbnail_size)
|
||||||
|
|
@ -222,7 +206,6 @@ class StreamObject(object):
|
||||||
def load_file(self) -> bool:
|
def load_file(self) -> bool:
|
||||||
"""Load data stored on disk to the in-memory stream."""
|
"""Load data stored on disk to the in-memory stream."""
|
||||||
if self.file_exists: # If data file exists
|
if self.file_exists: # If data file exists
|
||||||
# TODO: Streamline this bit
|
|
||||||
with open(self.file, 'rb') as f:
|
with open(self.file, 'rb') as f:
|
||||||
self.stream = io.BytesIO(f.read()) # Load bytes from file
|
self.stream = io.BytesIO(f.read()) # Load bytes from file
|
||||||
self.stream.seek(0) # Rewind data bytes again
|
self.stream.seek(0) # Rewind data bytes again
|
||||||
|
|
@ -254,6 +237,12 @@ class StreamObject(object):
|
||||||
else:
|
else:
|
||||||
return False
|
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):
|
def shunt(self):
|
||||||
"""Demote the StreamObject from being stored in memory."""
|
"""Demote the StreamObject from being stored in memory."""
|
||||||
if not self.file_exists: # If file doesn't already exist
|
if not self.file_exists: # If file doesn't already exist
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,12 @@ DEFAULT_CONFIG = os.path.join(HERE, 'config_picamera.yaml')
|
||||||
|
|
||||||
|
|
||||||
class StreamingCamera(BaseCamera):
|
class StreamingCamera(BaseCamera):
|
||||||
|
"""Raspberry Pi camera implementation of StreamingCamera."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Raspberry Pi camera implementation of StreamingCamera."""
|
|
||||||
# Run BaseCamera init
|
# Run BaseCamera init
|
||||||
BaseCamera.__init__(self)
|
BaseCamera.__init__(self)
|
||||||
# Attach to Pi camera
|
# Attach to Pi camera
|
||||||
self.camera = picamera.PiCamera()
|
self.camera = picamera.PiCamera() #: :py:class:`picamera.PiCamera`: Picamera object
|
||||||
|
|
||||||
# Camera settings
|
# Camera settings
|
||||||
self.settings.update({
|
self.settings.update({
|
||||||
|
|
@ -93,13 +93,21 @@ class StreamingCamera(BaseCamera):
|
||||||
|
|
||||||
# HANDLE SETTINGS
|
# 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:
|
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
|
global DEFAULT_CONFIG
|
||||||
|
|
||||||
paused_stream = False
|
paused_stream = False
|
||||||
|
|
@ -164,7 +172,12 @@ class StreamingCamera(BaseCamera):
|
||||||
"Cannot update camera settings while recording is active.")
|
"Cannot update camera settings while recording is active.")
|
||||||
|
|
||||||
def change_zoom(self, zoom_value: int=1) -> None:
|
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)
|
zoom_value = float(zoom_value)
|
||||||
if zoom_value < 1:
|
if zoom_value < 1:
|
||||||
zoom_value = 1
|
zoom_value = 1
|
||||||
|
|
@ -204,17 +217,20 @@ class StreamingCamera(BaseCamera):
|
||||||
folder: str='record',
|
folder: str='record',
|
||||||
fmt: str='h264',
|
fmt: str='h264',
|
||||||
quality: int=15):
|
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
|
# Start recording method only if a current recording is not running
|
||||||
if not self.state['record_active']:
|
if not self.state['record_active']:
|
||||||
|
|
@ -222,11 +238,13 @@ class StreamingCamera(BaseCamera):
|
||||||
# If no target is specified, store to StreamingCamera
|
# If no target is specified, store to StreamingCamera
|
||||||
if not target:
|
if not target:
|
||||||
# Create a new video and add to the video list
|
# Create a new video and add to the video list
|
||||||
target_obj = self.new_video(StreamObject(
|
target_obj = self.new_video(
|
||||||
write_to_file=write_to_file,
|
StreamObject(
|
||||||
filename=filename,
|
write_to_file=write_to_file,
|
||||||
folder=folder,
|
filename=filename,
|
||||||
fmt=fmt))
|
folder=folder,
|
||||||
|
fmt=fmt)
|
||||||
|
)
|
||||||
|
|
||||||
# Lock the StreamObject while recording
|
# Lock the StreamObject while recording
|
||||||
target_obj.lock()
|
target_obj.lock()
|
||||||
|
|
@ -274,9 +292,9 @@ class StreamingCamera(BaseCamera):
|
||||||
"""
|
"""
|
||||||
Pause capture on a splitter port.
|
Pause capture on a splitter port.
|
||||||
|
|
||||||
splitter_port (int): Splitter port to stop recording on
|
Args:
|
||||||
resolution ((int, int)): Resolution to set the camera to,
|
splitter_port (int): Splitter port to stop recording on
|
||||||
after stopping recording.
|
resolution ((int, int)): Resolution to set the camera to, after stopping recording.
|
||||||
"""
|
"""
|
||||||
logging.debug("Pausing stream")
|
logging.debug("Pausing stream")
|
||||||
# If no resolution is specified, default to image_resolution
|
# If no resolution is specified, default to image_resolution
|
||||||
|
|
@ -296,9 +314,9 @@ class StreamingCamera(BaseCamera):
|
||||||
"""
|
"""
|
||||||
Resume capture on a splitter port.
|
Resume capture on a splitter port.
|
||||||
|
|
||||||
splitter_port (int): Splitter port to start recording on
|
Args:
|
||||||
resolution ((int, int)): Resolution to set the camera to,
|
splitter_port (int): Splitter port to start recording on
|
||||||
before starting recording.
|
resolution ((int, int)): Resolution to set the camera to, before starting recording.
|
||||||
"""
|
"""
|
||||||
logging.debug("Unpausing stream")
|
logging.debug("Unpausing stream")
|
||||||
if not resolution:
|
if not resolution:
|
||||||
|
|
@ -330,28 +348,33 @@ class StreamingCamera(BaseCamera):
|
||||||
Defaults to JPEG format.
|
Defaults to JPEG format.
|
||||||
Target object can be overridden for development purposes.
|
Target object can be overridden for development purposes.
|
||||||
|
|
||||||
target (str/BytesIO): Target object to write data bytes to.
|
Args:
|
||||||
write_to_file (bool): Should the StreamObject write to a file,
|
target (str/BytesIO): Target object to write data bytes to.
|
||||||
instead of BytesIO stream?
|
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.
|
use_video_port (bool): Capture from the video port used for streaming. Lower resolution, faster.
|
||||||
(lower resolution, faster)
|
filename (str): Name of the stored file. Defaults to timestamp.
|
||||||
filename (str): Name of the stored file.
|
folder (str): Relative directory to store data file in.
|
||||||
(defaults to timestamp)
|
fmt (str): Format of the capture.
|
||||||
folder (str): Relative directory to store data file in.
|
resize ((int, int)): Resize the captured image.
|
||||||
fmt (str): Format of the capture.
|
|
||||||
(default 'h264')
|
|
||||||
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 no target is specified, store to StreamingCamera
|
||||||
if not target:
|
if not target:
|
||||||
|
# TODO: Handle clashing file names here instead of in capture method.
|
||||||
# Create a new image and add to the image list
|
# Create a new image and add to the image list
|
||||||
target_obj = self.new_image(StreamObject(
|
target_obj = self.new_image(
|
||||||
write_to_file=write_to_file,
|
StreamObject(
|
||||||
keep_on_disk=keep_on_disk,
|
write_to_file=write_to_file,
|
||||||
filename=filename,
|
keep_on_disk=keep_on_disk,
|
||||||
folder=folder,
|
filename=filename,
|
||||||
fmt=fmt))
|
folder=folder,
|
||||||
target = target_obj.target # Store to the StreamObject BytesIO
|
fmt=fmt)
|
||||||
|
)
|
||||||
|
target = target_obj.target # Store to the StreamObject BytesIO
|
||||||
|
|
||||||
else:
|
else:
|
||||||
target_obj = target
|
target_obj = target
|
||||||
|
|
@ -382,9 +405,6 @@ class StreamingCamera(BaseCamera):
|
||||||
bayer=False,
|
bayer=False,
|
||||||
use_video_port=True)
|
use_video_port=True)
|
||||||
|
|
||||||
# Update state dictionary
|
|
||||||
self.state['image_recent'] = str(target_obj)
|
|
||||||
|
|
||||||
return target_obj
|
return target_obj
|
||||||
|
|
||||||
def array(
|
def array(
|
||||||
|
|
@ -394,9 +414,9 @@ class StreamingCamera(BaseCamera):
|
||||||
resize: Tuple[int, int]=None) -> np.ndarray:
|
resize: Tuple[int, int]=None) -> np.ndarray:
|
||||||
"""Capture an uncompressed still YUV image to a Numpy array.
|
"""Capture an uncompressed still YUV image to a Numpy array.
|
||||||
|
|
||||||
use_video_port (bool): Capture from the video port used for streaming.
|
Args:
|
||||||
(lower resolution, faster)
|
use_video_port (bool): Capture from the video port used for streaming. Lower resolution, faster.
|
||||||
resize ((int, int)): Resize the captured image.
|
resize ((int, int)): Resize the captured image.
|
||||||
"""
|
"""
|
||||||
if use_video_port:
|
if use_video_port:
|
||||||
resolution = self.settings['video_resolution']
|
resolution = self.settings['video_resolution']
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Defines a microscope object, binding a camera and stage with basic functionality.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from openflexure_stage import OpenFlexureStage
|
from openflexure_stage import OpenFlexureStage
|
||||||
|
|
@ -6,11 +10,17 @@ from .camera.pi import StreamingCamera
|
||||||
|
|
||||||
|
|
||||||
class Microscope(object):
|
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):
|
def __init__(self, camera: StreamingCamera, stage: OpenFlexureStage):
|
||||||
"""Create the microscope object. The camera and stage should already be initialised."""
|
self.attach(camera, stage)
|
||||||
self.camera = camera
|
|
||||||
self.stage = stage
|
|
||||||
self.stage.backlash = np.zeros(3, dtype=np.int)
|
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
"""Create microscope on context enter."""
|
"""Create microscope on context enter."""
|
||||||
|
|
@ -24,10 +34,36 @@ class Microscope(object):
|
||||||
"""Shut down the microscope hardware."""
|
"""Shut down the microscope hardware."""
|
||||||
self.camera.close()
|
self.camera.close()
|
||||||
self.stage.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
|
# Create unified state
|
||||||
@property
|
@property
|
||||||
def state(self):
|
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 = {}
|
state = {}
|
||||||
|
|
||||||
# Add stage position
|
# Add stage position
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue