Merge remote-tracking branch 'origin/master' into deltastage
This commit is contained in:
commit
5e9e8cb2a3
222 changed files with 30104 additions and 7329 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
# Pyenv files
|
||||
.python-version
|
||||
.venv/
|
||||
.venv*/
|
||||
.env*/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
|
@ -25,6 +26,7 @@ var/
|
|||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
pip-wheel-metadata/
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
|
|
|
|||
68
.gitlab-ci.yml
Normal file
68
.gitlab-ci.yml
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
stages:
|
||||
- build
|
||||
- deploy
|
||||
|
||||
|
||||
# Cache modules in between jobs
|
||||
cache:
|
||||
key: ${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
- node_modules/
|
||||
|
||||
|
||||
# Electron app build
|
||||
build:
|
||||
stage: build
|
||||
image: nikolaik/python-nodejs:python3.7-nodejs14
|
||||
|
||||
before_script:
|
||||
- curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python
|
||||
|
||||
script:
|
||||
# Install server
|
||||
- $HOME/.poetry/bin/poetry install
|
||||
# Run static build script
|
||||
- $HOME/.poetry/bin/poetry run build_static
|
||||
# Build distribution archive
|
||||
- mkdir -p dist
|
||||
- tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz .
|
||||
|
||||
artifacts:
|
||||
name: "dist"
|
||||
expire_in: 1 week
|
||||
paths:
|
||||
- "./dist/*"
|
||||
|
||||
only:
|
||||
- merge_requests
|
||||
- tags
|
||||
- web
|
||||
|
||||
|
||||
# Deploy to builds.openflexure.org
|
||||
deploy:
|
||||
stage: deploy
|
||||
dependencies:
|
||||
- build
|
||||
image: ubuntu:latest
|
||||
|
||||
before_script:
|
||||
- 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
|
||||
- eval $(ssh-agent -s)
|
||||
- ssh-add <(echo "$SSH_PRIVATE_KEY_BATH_OPENFLEXURE_BASE64" | base64 --decode)
|
||||
- mkdir -p ~/.ssh
|
||||
- echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config
|
||||
|
||||
script:
|
||||
# Install rsync if not already installed
|
||||
- 'which rsync || ( apt-get update -y && apt-get install rsync -y )'
|
||||
|
||||
# Upload the builds folder to openflexure-microscope builds
|
||||
- rsync -hrvz -e ssh dist/ ci-user@openflexure.bath.ac.uk:/var/www/build/openflexure-microscope-server
|
||||
|
||||
# Run update-latest.py on the build server
|
||||
- ssh -t ci-user@openflexure.bath.ac.uk "/var/www/build/update-latest.py"
|
||||
|
||||
only:
|
||||
- tags
|
||||
- web
|
||||
33
.gitlab/issue_templates/Bug.md
Normal file
33
.gitlab/issue_templates/Bug.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Bug report
|
||||
|
||||
## Summary
|
||||
|
||||
(Summarize the bug encountered concisely)
|
||||
|
||||
## Configuration
|
||||
|
||||
I'm using:
|
||||
|
||||
**Camera:** (E.g. Raspberry Pi camera v2)
|
||||
|
||||
**Motor controller:** (E.g. Sangaboard, or Arduino Nano)
|
||||
|
||||
## Steps to reproduce
|
||||
|
||||
(How one can reproduce the issue - this is very important)
|
||||
|
||||
## Relevant logs and/or screenshots
|
||||
|
||||
To access your microscope log, either:
|
||||
|
||||
* Run `ofm log` on your microscope, and copy/paste the output here
|
||||
|
||||
* Go to `http://<your microscope IP>:5000/log`, download the log file, and attach it here using the "Attach a File" button below
|
||||
* In most setups, `http://microscope.local:5000/log` should work
|
||||
|
||||
## Additional details
|
||||
|
||||
(Anything else you think might be relevant to mention)
|
||||
|
||||
/label ~bug
|
||||
/cc @jtc42
|
||||
20
.gitlab/issue_templates/Feature request.md
Normal file
20
.gitlab/issue_templates/Feature request.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Feature request
|
||||
|
||||
## Summary
|
||||
|
||||
(Summarize the bug encountered concisely)
|
||||
|
||||
## Configuration
|
||||
|
||||
I'm using:
|
||||
|
||||
**Camera:** (E.g. Raspberry Pi camera v2)
|
||||
|
||||
**Motor controller:** (E.g. Sangaboard, or Arduino Nano)
|
||||
|
||||
## Additional details
|
||||
|
||||
(Anything else you think might be relevant to mention)
|
||||
|
||||
/label ~feature
|
||||
/cc @jtc42
|
||||
80
.tarignore
Normal file
80
.tarignore
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
Distribution archive rules
|
||||
node_modules
|
||||
.gitlab-ci.yml
|
||||
./dist
|
||||
!openflexure_microscope/api/static/dist
|
||||
.tarignore
|
||||
|
||||
# Pyenv files
|
||||
.python-version
|
||||
.venv*
|
||||
.env*
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Pytho
|
||||
env/
|
||||
build
|
||||
develop-eggs
|
||||
downloads
|
||||
eggs
|
||||
lib
|
||||
lib64
|
||||
parts
|
||||
sdist
|
||||
var
|
||||
*.egg-info
|
||||
.installed.cfg
|
||||
*.egg
|
||||
pip-wheel-metadata
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov
|
||||
.tox
|
||||
.coverage
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build
|
||||
|
||||
# PyBuilder
|
||||
target
|
||||
|
||||
#Big-o files
|
||||
capture
|
||||
record
|
||||
*.data
|
||||
|
||||
#IDE files
|
||||
.vscode
|
||||
.idea
|
||||
activate
|
||||
|
||||
# UML diagrams
|
||||
classes.png
|
||||
packages.png
|
||||
38
README.md
38
README.md
|
|
@ -1,7 +1,6 @@
|
|||
OpenFlexure Microscope Software
|
||||
=====================
|
||||
# OpenFlexure Microscope Software
|
||||
|
||||
# Quickstart
|
||||
## Quickstart
|
||||
|
||||
A general user-guide on setting up your microscope can be found [**here on our website**](https://www.openflexure.org/projects/microscope/).
|
||||
This includes basic installation instructions suitable for most users.
|
||||
|
|
@ -9,19 +8,44 @@ This includes basic installation instructions suitable for most users.
|
|||
Full developer documentation can be found on [**ReadTheDocs**](https://openflexure-microscope-software.readthedocs.io/en/stable/).
|
||||
This includes installing the server in a mode better suited for active development.
|
||||
|
||||
## Installation
|
||||
|
||||
* `git clone https://gitlab.com/openflexure/openflexure-microscope-server.git`
|
||||
* (If running on a microscope Pi) `ofm activate`
|
||||
* `poetry install`
|
||||
* `poetry run build_static`
|
||||
|
||||
### Node installation
|
||||
|
||||
* Note, building the static interface will require a valid Node.js installation
|
||||
* To build on a Raspberry Pi:
|
||||
* `curl -sL https://deb.nodesource.com/setup_14.x | sudo bash -`
|
||||
* `sudo apt install nodejs`
|
||||
|
||||
### Distributing
|
||||
|
||||
* `mkdir -p dist`
|
||||
* `tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-server.tar.gz .`
|
||||
|
||||
## Build-system
|
||||
|
||||
As of 1.0.0b0, we're using [Poetry](https://github.com/sdispater/poetry) to manage dependencies, build, and distribute the package. All package information and dependencies are found in `pyproject.toml`, in line with [PEP 518](https://www.python.org/dev/peps/pep-0518/). If you're developing this package, make use of `poetry.lock` to ensure you're using the latest locked dependency list.
|
||||
|
||||
## Microscope plugins
|
||||
|
||||
The Microscope module, and Flask app, both support plugins for extending lower-level functionality not well suited to web API calls.
|
||||
This plugin system is still in fairly early development, and is not yet properly documented. The current documentation can be found [here](https://openflexure-microscope-software.readthedocs.io/en/latest/plugins.html).
|
||||
|
||||
## Running tests through the PTVSD remote debugger (port 3000)
|
||||
- From the openflexure-microscope-software directory, run `python3 -m ptvsd --host 0.0.0.0 --port 3000 --wait tests/test_camera.py`
|
||||
|
||||
* From the openflexure-microscope-software directory, run `python3 -m ptvsd --host 0.0.0.0 --port 3000 --wait tests/test_camera.py`
|
||||
|
||||
## Credits
|
||||
|
||||
### Piexif
|
||||
|
||||
# Credits
|
||||
## Piexif
|
||||
The microscope server includes a forked copy of hMatoba's [Piexif](https://github.com/hMatoba/Piexif), licensed under the MIT License.
|
||||
## Video streaming
|
||||
|
||||
### Video streaming
|
||||
|
||||
Based on supporting code for the article [video streaming with Flask](http://blog.miguelgrinberg.com/post/video-streaming-with-flask) and its follow-up [Flask Video Streaming Revisited](http://blog.miguelgrinberg.com/post/flask-video-streaming-revisited).
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
#
|
||||
|
||||
# You can set these variables from the command line.
|
||||
SPHINXOPTS =
|
||||
SPHINXOPTS = -a -E
|
||||
SPHINXBUILD = sphinx-build
|
||||
SOURCEDIR = source
|
||||
BUILDDIR = build
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ if "%SPHINXBUILD%" == "" (
|
|||
set SOURCEDIR=source
|
||||
set BUILDDIR=build
|
||||
|
||||
set SPHINXOPTS="-E"
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
%SPHINXBUILD% >NUL 2>NUL
|
||||
|
|
|
|||
|
|
@ -1,25 +1,9 @@
|
|||
HTTP API
|
||||
========
|
||||
|
||||
Summary
|
||||
-------
|
||||
.. qrefflask:: openflexure_microscope.api.app:app
|
||||
:undoc-endpoints: index
|
||||
:undoc-static:
|
||||
:endpoints:
|
||||
Live documentation
|
||||
------------------
|
||||
|
||||
Details
|
||||
-------
|
||||
.. autoflask:: openflexure_microscope.api.app:app
|
||||
:undoc-endpoints: index
|
||||
:undoc-static:
|
||||
:endpoints:
|
||||
:order: path
|
||||
Full, interactive Swagger documentation for your microscopes web API is available from the microscope itself. From any browser, go to ``http://{your microscope IP address}/api/v2/docs/swagger-ui``.
|
||||
|
||||
Example Requests
|
||||
----------------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
./apirequests.rst
|
||||
.. note:: We should have an online copy of the API SwaggerUI documentation up soon.
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
Introduction
|
||||
------------
|
||||
|
||||
In these examples, our microscope is located at the IP ``192.168.1.126``, running on port 5000.
|
||||
|
||||
|
||||
New Image Capture
|
||||
-----------------
|
||||
|
||||
Temprary capture, using video port.
|
||||
|
||||
CURL
|
||||
++++
|
||||
.. code-block:: none
|
||||
|
||||
curl -X POST -H "Content-Type: application/json" -d \\
|
||||
'{"temporary": true, "use_video_port": true}' http://192.168.1.126:5000/api/v1/camera/capture
|
||||
|
||||
HTTPie
|
||||
++++++
|
||||
.. code-block:: none
|
||||
|
||||
http POST http://192.168.1.126:5000/api/v1/camera/capture temporary:=true use_video_port:=true
|
||||
|
||||
Python Requests
|
||||
+++++++++++++++
|
||||
.. code-block:: python
|
||||
|
||||
json_payload = {
|
||||
"keep_on_disk": keep_on_disk,
|
||||
"use_video_port": use_video_port
|
||||
}
|
||||
requests.post('http://192.168.1.126:5000/api/v1/camera/capture', json=json_payload)
|
||||
|
||||
Update Config
|
||||
-------------
|
||||
|
||||
Example, changing picamera shutter speed to 2000, and the JPEG quality to 90.
|
||||
|
||||
CURL
|
||||
++++
|
||||
.. code-block:: none
|
||||
|
||||
curl -X POST -H "Content-Type: application/json" -d \\
|
||||
'{"camera_settings": {"picamera_settings": {"shutter_speed": 2000}, "jpeg_quality": 90}}' http://192.168.1.126:5000/api/v1/config
|
||||
|
||||
HTTPie
|
||||
++++++
|
||||
.. code-block:: none
|
||||
|
||||
http POST http://192.168.1.126:5000/api/v1/config camera_settings:='{"jpeg_quality": 90, "picamera_settings": {"shutter_speed": 2000}}'
|
||||
|
||||
Python Requests
|
||||
+++++++++++++++
|
||||
.. code-block:: python
|
||||
|
||||
json_payload = {
|
||||
camera_settings: {
|
||||
"jpeg_quality": 90,
|
||||
"picamera_settings": {"shutter_speed": 2000}
|
||||
}
|
||||
}
|
||||
requests.post('http://192.168.1.126:5000/api/v1/config', json=json_payload)
|
||||
|
||||
Read Config
|
||||
-----------
|
||||
|
||||
CURL
|
||||
++++
|
||||
.. code-block:: none
|
||||
|
||||
curl http://192.168.1.126:5000/api/v1/config
|
||||
|
||||
HTTPie
|
||||
++++++
|
||||
.. code-block:: none
|
||||
|
||||
http GET http://192.168.1.126:5000/api/v1/config
|
||||
|
||||
Python Requests
|
||||
+++++++++++++++
|
||||
.. code-block:: python
|
||||
|
||||
requests.get('http://192.168.1.126:5000/api/v1/config')
|
||||
|
||||
Recalibrate Picamera
|
||||
--------------------
|
||||
|
||||
Starts the automatic recalibration plugin
|
||||
|
||||
CURL
|
||||
++++
|
||||
.. code-block:: none
|
||||
|
||||
curl -X POST -H "Content-Type: application/json" \\
|
||||
http://192.168.1.126:5000/api/v1/plugin/default/camera_calibration/recalibrate
|
||||
|
||||
HTTPie
|
||||
++++++
|
||||
.. code-block:: none
|
||||
|
||||
http POST http://192.168.1.126:5000/api/v1/plugin/default/camera_calibration/recalibrate
|
||||
|
||||
Python Requests
|
||||
+++++++++++++++
|
||||
.. code-block:: python
|
||||
|
||||
requests.post('http://192.168.1.126:5000/api/v1/plugin/default/camera_calibration/recalibrate')
|
||||
5
docs/source/basestage.rst
Normal file
5
docs/source/basestage.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Base Microscope Stage
|
||||
=====================
|
||||
|
||||
.. automodule:: openflexure_microscope.stage.base
|
||||
:members:
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
Camera Class
|
||||
=======================================================
|
||||
============
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
|
@ -7,3 +7,29 @@ Camera Class
|
|||
picamera.rst
|
||||
basecamera.rst
|
||||
capture.rst
|
||||
|
||||
Capturing from a camera object
|
||||
------------------------------
|
||||
|
||||
In the cases of both a Raspberry Pi Streaming Camera, and a Mock Camera (attached if no real camera can be found), the camera's ``capture`` method takes as it's first positional argument either a string describing a file path to save to, or any Python file-like object.
|
||||
|
||||
The :class:`openflexure_microscope.camera.capture.CaptureObject` class works by providing a file path string, but adds additional functionality around storing and retreiving EXIF metadata in compatible files.
|
||||
|
||||
If, for your application, you do not require this functionality, you can pass a simple string or file-like object. For example, to take an image that will be stored in-memory, processed rapidly, and then discarded, you could use a BytesIO stream:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import io
|
||||
from PIL import Image
|
||||
...
|
||||
|
||||
with microscope.camera.lock, io.BytesIO() as stream:
|
||||
|
||||
microscope.camera.capture(
|
||||
stream,
|
||||
use_video_port=True,
|
||||
bayer=False,
|
||||
)
|
||||
|
||||
stream.seek(0)
|
||||
image = Image.open(stream)
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
Capture Object
|
||||
=======================================================
|
||||
|
||||
By default, all image and video capture data are stored to instances of :py:class:`openflexure_microscope.camera.capture.CaptureObject`. This class mostly wraps up complexity associated with moving data between disk and memory.
|
||||
By default, all image and video capture data are stored to instances of :py:class:`openflexure_microscope.captures.CaptureObject`. This class mostly wraps up complexity associated with moving data between disk and memory.
|
||||
|
||||
The class also includes some convenience features such as automatically cleaning up captures marked as temporary, handling metadata tags and file names, and generating image thumbnails. Often, the most recent capture wants to be stored in memory, for applications that involve immediately processing the capture data in some way. Rather than manually keep track of the location of the capture data, you can use the CaptureObject ``data`` attribute, which automatically returns capture data from the fastest available location. Additionally, the class handles storing capture metadata to Exif tags in supported formats.
|
||||
The class also includes some convenience features such as handling metadata tags and file names, and generating image thumbnails. Additionally, the class handles storing capture metadata to Exif tags in supported formats.
|
||||
|
||||
Below are details of available methods and attributes.
|
||||
|
||||
.. automodule:: openflexure_microscope.camera.capture
|
||||
.. automodule:: openflexure_microscope.captures.capture
|
||||
:members:
|
||||
|
|
@ -17,32 +17,34 @@ import sys
|
|||
|
||||
# Load module from relative imports
|
||||
|
||||
module_path = os.path.abspath('../..')
|
||||
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']
|
||||
|
||||
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'
|
||||
project = "OpenFlexure Microscope Software"
|
||||
copyright = "2018, Bath Open Instrumentation Group"
|
||||
author = "Bath Open Instrumentation Group"
|
||||
|
||||
# The short X.Y version
|
||||
version = ''
|
||||
version = ""
|
||||
# The full version, including alpha/beta/rc tags
|
||||
release = ''
|
||||
release = ""
|
||||
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
|
@ -55,32 +57,32 @@ release = ''
|
|||
# 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'
|
||||
"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'
|
||||
autodoc_member_order = "bysource"
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
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'
|
||||
source_suffix = ".rst"
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
master_doc = "index"
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
|
|
@ -114,7 +116,7 @@ html_theme = "sphinx_rtd_theme"
|
|||
# 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']
|
||||
html_static_path = ["_static"]
|
||||
|
||||
# Custom sidebar templates, must be a dictionary that maps document names
|
||||
# to template names.
|
||||
|
|
@ -130,7 +132,7 @@ html_static_path = ['_static']
|
|||
# -- Options for HTMLHelp output ---------------------------------------------
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'OpenFlexureMicroscopeSoftwaredoc'
|
||||
htmlhelp_basename = "OpenFlexureMicroscopeSoftwaredoc"
|
||||
|
||||
|
||||
# -- Options for LaTeX output ------------------------------------------------
|
||||
|
|
@ -139,15 +141,12 @@ 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',
|
||||
|
|
@ -157,8 +156,13 @@ latex_elements = {
|
|||
# (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'),
|
||||
(
|
||||
master_doc,
|
||||
"OpenFlexureMicroscopeSoftware.tex",
|
||||
"OpenFlexure Microscope Software Documentation",
|
||||
"Bath Open Instrumentation Group",
|
||||
"manual",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -167,8 +171,13 @@ latex_documents = [
|
|||
# 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)
|
||||
(
|
||||
master_doc,
|
||||
"openflexuremicroscopesoftware",
|
||||
"OpenFlexure Microscope Software Documentation",
|
||||
[author],
|
||||
1,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -178,9 +187,15 @@ man_pages = [
|
|||
# (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'),
|
||||
(
|
||||
master_doc,
|
||||
"OpenFlexureMicroscopeSoftware",
|
||||
"OpenFlexure Microscope Software Documentation",
|
||||
author,
|
||||
"OpenFlexureMicroscopeSoftware",
|
||||
"One line description of project.",
|
||||
"Miscellaneous",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -199,7 +214,7 @@ epub_title = project
|
|||
# epub_uid = ''
|
||||
|
||||
# A list of files that should not be packed into the epub file.
|
||||
epub_exclude_files = ['search.html']
|
||||
epub_exclude_files = ["search.html"]
|
||||
|
||||
|
||||
# -- Extension configuration -------------------------------------------------
|
||||
|
|
@ -208,9 +223,11 @@ epub_exclude_files = ['search.html']
|
|||
|
||||
# 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)
|
||||
}
|
||||
"openflexure_stage": ("https://openflexure-stage.readthedocs.io/en/latest/", None),
|
||||
"picamera": ("https://picamera.readthedocs.io/en/release-1.13/", None),
|
||||
"marshmallow": ("https://marshmallow.readthedocs.io/en/stable/", None),
|
||||
"webargs": ("https://webargs.readthedocs.io/en/latest/", None),
|
||||
}
|
||||
|
||||
# -- Options for todo extension ----------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
Microscope configuration
|
||||
========================
|
||||
Microscope settings
|
||||
===================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
|
@ -7,65 +7,15 @@ Microscope configuration
|
|||
|
||||
.. _MicroscopeRC:
|
||||
|
||||
Microscope RC file
|
||||
------------------
|
||||
Microscope settings file
|
||||
------------------------
|
||||
|
||||
Microscope configuration is made persistent via a microscope runtime-config (RC) file. By default, this
|
||||
file exists at ``~/.openflexure/microscope_settings.yaml``, and contains basic parameters to set up the microscope.
|
||||
Microscope settings are made persistent via a microscope settings file. By default, this
|
||||
file exists at ``~/.openflexure/microscope_settings.json``.
|
||||
|
||||
Additionally, by default, configurations for specific pieces of hardware (i.e. the stage and camera) are located
|
||||
in separate "auxillary" config files, and are linked together by adding the config file's path to your main
|
||||
microscope RC file.
|
||||
The class :class:`openflexure_microscope.config.OpenflexureSettingsFile` provides functionality for loading a JSON-format settings file as a Python dictionary, and merging changed settings back into the file.
|
||||
|
||||
The default settings are loaded by the :attr:`openflexure_microscope.config.user_settings`, which can be imported anywhere in the microscope server application to allow reading and writing of persistent settings.
|
||||
|
||||
Loading the runtime-config
|
||||
++++++++++++++++++++++++++
|
||||
|
||||
By default, a microscope object will load the a runtime-config from the default location.
|
||||
This RC can then be passed to any hardware attached to the microscope. This can be particularly
|
||||
useful when creating a dummy microscope object before attaching hardware. For example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from openflexure_microscope import Microscope
|
||||
from openflexure_microscope.camera.pi import StreamingCamera
|
||||
from openflexure_stage import OpenFlexureStage
|
||||
|
||||
# Create an empty microscope
|
||||
api_microscope = Microscope(None, None)
|
||||
|
||||
# Create a picamera StreamingCamera, using our config
|
||||
camera_obj = StreamingCamera(config=api_microscope.rc.read())
|
||||
|
||||
# Create an OpenFlexure Stage attached to /dev/ttyUSB0
|
||||
stage_obj = OpenFlexureStage("/dev/ttyUSB0")
|
||||
|
||||
# Attach devices to the Microscope object
|
||||
api_microscope.attach(camera_obj, stage_obj)
|
||||
|
||||
Alternatively, a config can be loaded prior to creating a microscope, allowing the microscope
|
||||
hardware to be created and attached in a single line. For example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from openflexure_microscope.config import OpenflexureConfig
|
||||
|
||||
from openflexure_microscope import Microscope
|
||||
from openflexure_microscope.camera.pi import StreamingCamera
|
||||
from openflexure_stage import OpenFlexureStage
|
||||
|
||||
# Create a runtime-config from the default location
|
||||
rc = OpenflexureConfig(expand=True)
|
||||
|
||||
# Create a populated microscope
|
||||
api_microscope = Microscope(
|
||||
StreamingCamera(config=rc),
|
||||
OpenFlexureStage("/dev/ttyUSB0"),
|
||||
config=rc
|
||||
)
|
||||
|
||||
|
||||
Config module and OpenflexureConfig class
|
||||
-----------------------------------------
|
||||
.. automodule:: openflexure_microscope.config
|
||||
:members:
|
||||
54
docs/source/extensions/actions.rst
Normal file
54
docs/source/extensions/actions.rst
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
Thing Actions
|
||||
=============
|
||||
|
||||
Introduction
|
||||
------------
|
||||
|
||||
As well as properties, the OpenFlexure Microscope Server also supports Thing Actions.
|
||||
Thing Actions "invoke a function of the Thing, which manipulates state (e.g., toggling a lamp on or off) or triggers a process on the Thing (e.g., dim a lamp over time)." For the microscope, this would include moving the stage or taking a capture. Both of these require internal logic, and cannot be performed by changing a simple property.
|
||||
|
||||
Actions should be *triggered* with POST requests *only*. Ideally, a view corresponding to an action should only support POST requests.
|
||||
|
||||
Like properties, we use a special view class to identify a view as an action: ``ActionView``. For example, a view to perform a "quick-capture" action may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class QuickCaptureAPI(ActionView):
|
||||
"""
|
||||
Take an image capture and return it without saving
|
||||
"""
|
||||
# Expect a "use_video_port" boolean, which defaults to True if none is given
|
||||
args = {"use_video_port": fields.Boolean(missing=True)}
|
||||
|
||||
# Our success response (200) returns an image (image/jpeg mimetype)
|
||||
responses = {
|
||||
200: {"content_type": "image/jpeg"}
|
||||
}
|
||||
|
||||
def post(self, args):
|
||||
"""
|
||||
Take a non-persistant image capture.
|
||||
"""
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Open a BytesIO stream to be destroyed once request has returned
|
||||
with io.BytesIO() as stream:
|
||||
|
||||
# Capture to our stream object
|
||||
microscope.camera.capture(stream, use_video_port=args.get("use_video_port"))
|
||||
|
||||
# Rewind the stream
|
||||
stream.seek(0)
|
||||
|
||||
# Return our image data using Flasks send_file function
|
||||
return send_file(io.BytesIO(stream.read()), mimetype="image/jpeg")
|
||||
|
||||
In this example, we are also making use of the ``responses`` attribute, to document that our successful response (HTTP code 200) will return data with a mimetype ``image/jpeg``, as well as ``args`` to accept optional parameters with POST requests.
|
||||
|
||||
Complete example
|
||||
----------------
|
||||
|
||||
Adding this new view into our example extension, we now have:
|
||||
|
||||
.. literalinclude:: ./example_extension/05_actions.py
|
||||
|
|
@ -1,53 +1,45 @@
|
|||
Adding a GUI
|
||||
=====================
|
||||
OpenFlexure eV GUI
|
||||
==================
|
||||
|
||||
Introduction
|
||||
------------
|
||||
|
||||
In order to bind user-interface elements to your plugin, an ``api_schema`` variable must be included in your microscope plugin, similar to your ``api_views``.
|
||||
The main client application for the OpenFlexure Microscope, OpenFlexure eV, can render simple GUIs (graphical user interfaces) for extensions.
|
||||
|
||||
This variable must be in the form of a dictionary, with all data able to be parsed into JSON. Because of this requirement, it is suggested that you create your API schema as a JSON file, and load that file as a dictionary into your plugin. For example:
|
||||
We define our user interface by making use of the extensions general metadata, added using the ``add_meta`` function. This function adds arbitrary additional data to your extensions web API description, for example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import json
|
||||
# Create your extension object
|
||||
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")
|
||||
|
||||
HERE = os.path.dirname(os.path.realpath(__file__)) # Find the full path of your plugin Python file
|
||||
SCHEMA_PATH = os.path.join(HERE, "schema.json") # Find the full path of the adjascent JSON file
|
||||
...
|
||||
|
||||
class MyPluginClass(MicroscopePlugin):
|
||||
my_extension.add_meta("myKey", "My metadata value")
|
||||
|
||||
with open(SCHEMA_PATH, 'r') as sc: # Open the JSON file
|
||||
api_schema = json.load(sc) # Load the JSON file into the api_schema dictionary
|
||||
|
||||
Throughout this documentation, all example of ``api_schema`` sections will be in JSON format. Keep in mind however that it is possible to directly define your ``api_schema`` as a dictionary, without loading an external file.
|
||||
|
||||
|
||||
Forms from ``api_schema``
|
||||
-------------------------
|
||||
|
||||
The ``api_schema`` object essentially describes HTML forms, which it is up to the client to render. The form is constructed by specifying a set of components, and their values. A form can update it's values by sending a GET request to the API route bound to that form, and can send it's current values via a POST request to *this same API route*.
|
||||
OpenFlexure eV will recognise the ``gui`` metadata key, and render properly structured descriptions of a GUI in the format described below. The ``gui`` data essentially describes HTML forms, which it is up to the client to render. The form is constructed by specifying a set of components, and their values.
|
||||
|
||||
Each component in the form has a ``name`` property, which must match up to a property your API route expects in JSON POST requests, and returns in JSON GET requests.
|
||||
|
||||
Structure of ``api_schema``
|
||||
|
||||
Structure of ``gui``
|
||||
---------------------------
|
||||
|
||||
Root level
|
||||
++++++++++
|
||||
|
||||
The root of your ``api_schema`` expects 3 properties:
|
||||
|
||||
``id`` - A unique ID to give your client-side plugin
|
||||
The root of your ``gui`` dictionary expects 2 properties:
|
||||
|
||||
``icon`` - The name of a Material Design icon to use for your plugin
|
||||
|
||||
``viewPanel`` *(optional)* - Content to display to the right of the extension form. Either ``stream`` (default), ``gallery``, or ``settings``.
|
||||
|
||||
``forms`` - An array of forms as described below
|
||||
|
||||
Form level
|
||||
++++++++++
|
||||
|
||||
Your plugin can contain multiple forms. For example, if your plugin creates several API routes, you will need a separate form for each route.
|
||||
Your extension can contain multiple forms. For example, if your extension creates several API routes, you will need a separate form for each route.
|
||||
|
||||
Each form is described by a JSON object, with the following properties:
|
||||
|
||||
|
|
@ -55,23 +47,23 @@ Each form is described by a JSON object, with the following properties:
|
|||
|
||||
``route`` - String of the corresponding API route. *Must* match a route defined in your ``api_views`` dictionary
|
||||
|
||||
``selfUpdate`` *(optional)* - Whether the client should automatically update form component values with a GET request to your API route
|
||||
|
||||
``isTask`` *(optional)* - Whether the client should treat your API route as a long-running task
|
||||
|
||||
``isCollapsible`` *(optional)* - Whether the form can be collapsed into an accordion
|
||||
|
||||
``submitLabel`` *(optional)* - String to place inside of the form's submit button
|
||||
|
||||
``schema`` - An array of form components as described below
|
||||
``schema`` - List of dictionaries. Each dictionary element describes a form component.
|
||||
|
||||
``emitOnResponse`` *(optional)* - OpenFlexure eV event to emit when a response is recieved from the extension (generally avoid unless you know you need this.)
|
||||
|
||||
Component level
|
||||
+++++++++++++++
|
||||
|
||||
Each form can (and probably should) contain multiple components. For example, if your API route expects several properties in POST requests, each property can be bound to a form component.
|
||||
Each form can (and probably should) contain multiple components. For example, if your API route expects several parameters in a POST requests, each parameter can be bound to a form component.
|
||||
|
||||
Upon form submission, the form data will be converted into a JSON object of key-value pairs, where the key is the components ``name``, and the value is it's current value.
|
||||
|
||||
On load, the form will make a GET request to it's API route, an expects a JSON object of key-value pairs in the same format (keys will be matched to component names).
|
||||
|
||||
An overview of available components, and their properties, can be found below.
|
||||
|
||||
Arranging components
|
||||
|
|
@ -179,14 +171,53 @@ Overview of components
|
|||
|
||||
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/textInput.png
|
||||
|
||||
JSON form example
|
||||
-----------------
|
||||
**Note:** Basic input types (``textInput``, ``numberInput``) can also include additional attributes for HTML input elements inputs (e.g. ``placeholder``, ``required``, ``min``, ``max``). These additional attributes will be forwarded to the rendered HTML elements.
|
||||
|
||||
.. literalinclude:: schema_example.json
|
||||
:language: JSON
|
||||
Building the GUI
|
||||
----------------
|
||||
|
||||
Once you have a dictionary describing your GUI, use the :py:meth:`openflexure_microscope.api.utilities.gui.build_gui` function to fill in and expand any information required to have it properly function. This function expands your ``route`` values to include your extensions full URI, and handles returning dynamic GUIs.
|
||||
|
||||
For example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
my_gui = {...}
|
||||
|
||||
# Create your extension object
|
||||
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")
|
||||
|
||||
...
|
||||
|
||||
my_extension.add_meta("gui", build_gui(my_gui, my_extension))
|
||||
|
||||
Dynamic GUIs
|
||||
------------
|
||||
|
||||
Instead of passing a static dictionary to :py:meth:`openflexure_microscope.api.utilities.gui.build_gui`, you can instead pass a callable function which returns a dictionary. This function is then called every time a client requests a description of active extensions.
|
||||
|
||||
Using a callable has the advantage of allowing your extensions GUI to be updated as it is used. This could be as simple as changing ``value`` parameters of components (to show up-to-date default form values), but could be used to entirely change the GUI form as it is used, for example dynamically changing options in select boxes.
|
||||
|
||||
For example, this could take the form:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
def create_dynamic_form():
|
||||
...
|
||||
generated_form_dict = {...}
|
||||
return generated_form_dict
|
||||
|
||||
# Create your extension object
|
||||
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")
|
||||
|
||||
...
|
||||
|
||||
my_extension.add_meta("gui", build_gui(create_dynamic_form, my_extension))
|
||||
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
Complete example
|
||||
----------------
|
||||
|
||||
./schema/json_schema.rst
|
||||
Adding a GUI to our previous timelapse example extension becomes:
|
||||
|
||||
.. literalinclude:: ./example_extension/07_ev_gui.py
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
from labthings.extensions import BaseExtension
|
||||
from labthings import find_component
|
||||
|
||||
|
||||
def identify():
|
||||
"""
|
||||
Demonstrate access to Microscope.camera, and Microscope.stage
|
||||
"""
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
response = (
|
||||
f"My name is {microscope.name}. "
|
||||
f"My parent camera is {microscope.camera}, "
|
||||
f"and my parent stage is {microscope.stage}."
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def rename(new_name):
|
||||
"""
|
||||
Rename the microscope
|
||||
"""
|
||||
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
microscope.name = new_name
|
||||
microscope.save_settings()
|
||||
|
||||
|
||||
# Create your extension object
|
||||
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")
|
||||
|
||||
# Add methods to your extension
|
||||
my_extension.add_method(identify, "identify")
|
||||
my_extension.add_method(rename, "rename")
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
from labthings.extensions import BaseExtension
|
||||
from labthings import find_component
|
||||
|
||||
|
||||
# Create the extension class
|
||||
class MyExtension(BaseExtension):
|
||||
def __init__(self):
|
||||
|
||||
# Create some instance variable
|
||||
self.state_variable = "An example of a persistant instance variable"
|
||||
|
||||
# Superclass init function
|
||||
super().__init__("com.myname.myextension", version="0.0.0")
|
||||
|
||||
def identify(self):
|
||||
"""
|
||||
Demonstrate access to Microscope.camera, and Microscope.stage
|
||||
"""
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
response = (
|
||||
f"My name is {microscope.name}. "
|
||||
f"My parent camera is {microscope.camera}, "
|
||||
f"and my parent stage is {microscope.stage}."
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def rename(self, new_name):
|
||||
"""
|
||||
Rename the microscope
|
||||
"""
|
||||
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
microscope.name = new_name
|
||||
microscope.save_settings()
|
||||
|
||||
|
||||
# Create your extension object
|
||||
my_extension = MyExtension()
|
||||
73
docs/source/extensions/example_extension/02_adding_views.py
Normal file
73
docs/source/extensions/example_extension/02_adding_views.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
from labthings.extensions import BaseExtension
|
||||
from labthings import find_component, fields
|
||||
from labthings.views import View
|
||||
|
||||
## Extension methods
|
||||
|
||||
|
||||
def identify(microscope):
|
||||
"""
|
||||
Demonstrate access to Microscope.camera, and Microscope.stage
|
||||
"""
|
||||
|
||||
response = (
|
||||
f"My name is {microscope.name}. "
|
||||
f"My parent camera is {microscope.camera}, "
|
||||
f"and my parent stage is {microscope.stage}."
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def rename(microscope, new_name):
|
||||
"""
|
||||
Rename the microscope
|
||||
"""
|
||||
|
||||
microscope.name = new_name
|
||||
microscope.save_settings()
|
||||
|
||||
|
||||
## Extension views
|
||||
|
||||
|
||||
class ExampleIdentifyView(View):
|
||||
def get(self):
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Return our identify function's output
|
||||
return identify(microscope)
|
||||
|
||||
|
||||
class ExampleRenameView(View):
|
||||
# Expect a request parameter called "name", which is a string.
|
||||
# Passed to the argument "args".
|
||||
args = fields.String(required=True, example="My Example Microscope")
|
||||
|
||||
def post(self, args):
|
||||
# Look for our new name in the request body
|
||||
new_name = args
|
||||
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Pass microscope and new name to our rename function
|
||||
rename(microscope, new_name)
|
||||
|
||||
# Return our identify function's output
|
||||
return identify(microscope)
|
||||
|
||||
|
||||
## Create extension
|
||||
|
||||
# Create your extension object
|
||||
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")
|
||||
|
||||
# Add methods to your extension
|
||||
my_extension.add_method(identify, "identify")
|
||||
my_extension.add_method(rename, "rename")
|
||||
|
||||
# Add API views to your extension
|
||||
my_extension.add_view(ExampleIdentifyView, "/identify")
|
||||
my_extension.add_view(ExampleRenameView, "/rename")
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
from labthings.extensions import BaseExtension
|
||||
from labthings import find_component, Schema, fields
|
||||
from labthings.views import View
|
||||
|
||||
## Extension methods
|
||||
|
||||
|
||||
# Define which properties of a Microscope object we care about,
|
||||
# and what types they should be converted to
|
||||
class MicroscopeIdentifySchema(Schema):
|
||||
name = fields.String() # Microscopes name
|
||||
id = fields.UUID() # Microscopes unique ID
|
||||
state = fields.Dict() # Status dictionary
|
||||
camera = fields.String() # Camera object (represented as a string)
|
||||
stage = fields.String() # Stage object (represented as a string)
|
||||
|
||||
|
||||
def rename(microscope, new_name):
|
||||
"""
|
||||
Rename the microscope
|
||||
"""
|
||||
|
||||
microscope.name = new_name
|
||||
microscope.save_settings()
|
||||
|
||||
|
||||
## Extension views
|
||||
|
||||
class ExampleIdentifyView(View):
|
||||
# Format our returned object using MicroscopeIdentifySchema
|
||||
schema = MicroscopeIdentifySchema()
|
||||
|
||||
def get(self):
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Return our microscope object,
|
||||
# let schema handle formatting the output
|
||||
return microscope
|
||||
|
||||
|
||||
class ExampleRenameView(View):
|
||||
# Format our returned object using MicroscopeIdentifySchema
|
||||
schema = MicroscopeIdentifySchema()
|
||||
# Expect a request parameter called "name", which is a string. Pass to argument "args".
|
||||
args = {"name": fields.String(required=True, example="My Example Microscope")}
|
||||
|
||||
def post(self, args):
|
||||
# Look for our "name" parameter in the request arguments
|
||||
new_name = args.get("name")
|
||||
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Pass microscope and new name to our rename function
|
||||
rename(microscope, new_name)
|
||||
|
||||
# Return our microscope object,
|
||||
# let schema handle formatting the output
|
||||
return microscope
|
||||
|
||||
|
||||
## Create extension
|
||||
|
||||
# Create your extension object
|
||||
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")
|
||||
|
||||
# Add methods to your extension
|
||||
my_extension.add_method(rename, "rename")
|
||||
|
||||
# Add API views to your extension
|
||||
my_extension.add_view(ExampleIdentifyView, "/identify")
|
||||
my_extension.add_view(ExampleRenameView, "/rename")
|
||||
88
docs/source/extensions/example_extension/04_properties.py
Normal file
88
docs/source/extensions/example_extension/04_properties.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from labthings.extensions import BaseExtension
|
||||
from labthings import find_component, Schema, fields
|
||||
from labthings.views import View, PropertyView
|
||||
|
||||
## Extension methods
|
||||
|
||||
|
||||
# Define which properties of a Microscope object we care about,
|
||||
# and what types they should be converted to
|
||||
class MicroscopeIdentifySchema(Schema):
|
||||
name = fields.String() # Microscopes name
|
||||
id = fields.UUID() # Microscopes unique ID
|
||||
state = fields.Dict() # Status dictionary
|
||||
camera = fields.String() # Camera object (represented as a string)
|
||||
stage = fields.String() # Stage object (represented as a string)
|
||||
|
||||
|
||||
def rename(microscope, new_name):
|
||||
"""
|
||||
Rename the microscope
|
||||
"""
|
||||
|
||||
microscope.name = new_name
|
||||
microscope.save_settings()
|
||||
|
||||
|
||||
## Extension views
|
||||
|
||||
# Since we only have a GET method here, it'll register as a read-only property
|
||||
class ExampleIdentifyView(PropertyView):
|
||||
# Format our returned object using MicroscopeIdentifySchema
|
||||
schema = MicroscopeIdentifySchema()
|
||||
|
||||
def get(self):
|
||||
"""
|
||||
Show identifying information about the current microscope object
|
||||
"""
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Return our microscope object,
|
||||
# let schema handle formatting the output
|
||||
return microscope
|
||||
|
||||
|
||||
# We can use a single schema as the input and output will be formatted identically
|
||||
# Eg. We always expect a "name" string argument, and always return a "name" string attribute
|
||||
class ExampleRenameView(PropertyView):
|
||||
schema = {"name": fields.String(required=True, example="My Example Microscope")}
|
||||
|
||||
def get(self):
|
||||
"""
|
||||
Show the current microscope name
|
||||
"""
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
return microscope
|
||||
|
||||
def post(self, args):
|
||||
"""
|
||||
Change the current microscope name
|
||||
"""
|
||||
# Look for our "name" parameter in the request arguments
|
||||
new_name = args.get("name")
|
||||
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Pass microscope and new name to our rename function
|
||||
rename(microscope, new_name)
|
||||
|
||||
# Return our microscope object,
|
||||
# let schema handle formatting the output
|
||||
return microscope
|
||||
|
||||
|
||||
## Create extension
|
||||
|
||||
# Create your extension object
|
||||
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")
|
||||
|
||||
# Add methods to your extension
|
||||
my_extension.add_method(rename, "rename")
|
||||
|
||||
# Add API views to your extension
|
||||
my_extension.add_view(ExampleIdentifyView, "/identify")
|
||||
my_extension.add_view(ExampleRenameView, "/rename")
|
||||
125
docs/source/extensions/example_extension/05_actions.py
Normal file
125
docs/source/extensions/example_extension/05_actions.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from labthings.extensions import BaseExtension
|
||||
from labthings import find_component, Schema, fields
|
||||
from labthings.views import View, ActionView, PropertyView
|
||||
|
||||
from flask import send_file # Used to send images from our server
|
||||
import io # Used in our capture action
|
||||
|
||||
## Extension methods
|
||||
|
||||
|
||||
# Define which properties of a Microscope object we care about,
|
||||
# and what types they should be converted to
|
||||
class MicroscopeIdentifySchema(Schema):
|
||||
name = fields.String() # Microscopes name
|
||||
id = fields.UUID() # Microscopes unique ID
|
||||
state = fields.Dict() # Status dictionary
|
||||
camera = fields.String() # Camera object (represented as a string)
|
||||
stage = fields.String() # Stage object (represented as a string)
|
||||
|
||||
|
||||
def rename(microscope, new_name):
|
||||
"""
|
||||
Rename the microscope
|
||||
"""
|
||||
|
||||
microscope.name = new_name
|
||||
microscope.save_settings()
|
||||
|
||||
|
||||
## Extension views
|
||||
|
||||
# Since we only have a GET method here, it'll register as a read-only property
|
||||
class ExampleIdentifyView(PropertyView):
|
||||
# Format our returned object using MicroscopeIdentifySchema
|
||||
schema = MicroscopeIdentifySchema()
|
||||
|
||||
def get(self):
|
||||
"""
|
||||
Show identifying information about the current microscope object
|
||||
"""
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Return our microscope object,
|
||||
# let schema handle formatting the output
|
||||
return microscope
|
||||
|
||||
|
||||
|
||||
# We can use a single schema as the input and output will be formatted identically
|
||||
# Eg. We always expect a "name" string argument, and always return a "name" string attribute
|
||||
class ExampleRenameView(PropertyView):
|
||||
schema = {"name": fields.String(required=True, example="My Example Microscope")}
|
||||
|
||||
def get(self):
|
||||
"""
|
||||
Show the current microscope name
|
||||
"""
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
return microscope
|
||||
|
||||
def post(self, args):
|
||||
"""
|
||||
Change the current microscope name
|
||||
"""
|
||||
# Look for our "name" parameter in the request arguments
|
||||
new_name = args.get("name")
|
||||
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Pass microscope and new name to our rename function
|
||||
rename(microscope, new_name)
|
||||
|
||||
# Return our microscope object,
|
||||
# let schema handle formatting the output
|
||||
return microscope
|
||||
|
||||
|
||||
class QuickCaptureAPI(ActionView):
|
||||
"""
|
||||
Take an image capture and return it without saving
|
||||
"""
|
||||
# Expect a "use_video_port" boolean, which defaults to True if none is given
|
||||
args = {"use_video_port": fields.Boolean(missing=True)}
|
||||
|
||||
# Our success response (200) returns an image (image/jpeg mimetype)
|
||||
responses = {
|
||||
200: {"content_type": "image/jpeg"}
|
||||
}
|
||||
|
||||
def post(self, args):
|
||||
"""
|
||||
Take a non-persistant image capture.
|
||||
"""
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Open a BytesIO stream to be destroyed once request has returned
|
||||
with io.BytesIO() as stream:
|
||||
|
||||
# Capture to our stream object
|
||||
microscope.camera.capture(stream, use_video_port=args.get("use_video_port"))
|
||||
|
||||
# Rewind the stream
|
||||
stream.seek(0)
|
||||
|
||||
# Return our image data using Flasks send_file function
|
||||
return send_file(io.BytesIO(stream.read()), mimetype="image/jpeg")
|
||||
|
||||
|
||||
## Create extension
|
||||
|
||||
# Create your extension object
|
||||
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")
|
||||
|
||||
# Add methods to your extension
|
||||
my_extension.add_method(rename, "rename")
|
||||
|
||||
# Add API views to your extension
|
||||
my_extension.add_view(ExampleIdentifyView, "/identify")
|
||||
my_extension.add_view(ExampleRenameView, "/rename")
|
||||
my_extension.add_view(QuickCaptureAPI, "/quick-capture")
|
||||
87
docs/source/extensions/example_extension/06_tasks_locks.py
Normal file
87
docs/source/extensions/example_extension/06_tasks_locks.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
from labthings.extensions import BaseExtension
|
||||
from labthings import find_component, Schema, fields, update_action_progress
|
||||
from labthings.views import View, ActionView, PropertyView
|
||||
|
||||
from flask import send_file # Used to send images from our server
|
||||
import io # Used in our capture action
|
||||
import time # Used in our timelapse function
|
||||
|
||||
# Used in our timelapse function
|
||||
from openflexure_microscope.captures.capture_manager import generate_basename
|
||||
|
||||
|
||||
## Extension methods
|
||||
|
||||
|
||||
def timelapse(microscope, n_images, t_between):
|
||||
"""
|
||||
Save a set of images in a timelapse
|
||||
|
||||
Args:
|
||||
microscope: Microscope object
|
||||
n_images (int): Number of images to take
|
||||
t_between (int/float): Time, in seconds, between sequential captures
|
||||
"""
|
||||
base_file_name = generate_basename()
|
||||
folder = "TIMELAPSE_{}".format(base_file_name)
|
||||
|
||||
# Take exclusive control over both the camera and stage
|
||||
with microscope.camera.lock, microscope.stage.lock:
|
||||
for n in range(n_images):
|
||||
# Generate a filename
|
||||
filename = f"{base_file_name}_image{n}"
|
||||
# Create a file to save the image to
|
||||
output = microscope.camera.new_image(
|
||||
filename=filename, folder=folder, temporary=False
|
||||
)
|
||||
|
||||
# Capture
|
||||
microscope.camera.capture(output)
|
||||
|
||||
# Add system metadata
|
||||
output.put_metadata(microscope.metadata, system=True)
|
||||
|
||||
# Update task progress (only does anyting if the function is running in a LabThings task)
|
||||
progress_pct = ((n + 1) / n_images) * 100 # Progress, in percent
|
||||
update_action_progress(progress_pct)
|
||||
|
||||
# Wait for the specified time
|
||||
time.sleep(t_between)
|
||||
|
||||
|
||||
## Extension views
|
||||
|
||||
|
||||
class TimelapseAPI(ActionView):
|
||||
"""
|
||||
Take a series of images in a timelapse
|
||||
"""
|
||||
args = {
|
||||
"n_images": fields.Integer(
|
||||
required=True, example=5, description="Number of images"
|
||||
),
|
||||
"t_between": fields.Number(
|
||||
missing=1, example=1, description="Time (seconds) between images"
|
||||
),
|
||||
}
|
||||
|
||||
def post(self, args):
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Start "timelapse"
|
||||
return timelapse(
|
||||
microscope, args.get("n_images"), args.get("t_between")
|
||||
)
|
||||
|
||||
|
||||
## Create extension
|
||||
|
||||
# Create your extension object
|
||||
my_extension = BaseExtension("com.myname.timelapse-extension", version="0.0.0")
|
||||
|
||||
# Add methods to your extension
|
||||
my_extension.add_method(timelapse, "timelapse")
|
||||
|
||||
# Add API views to your extension
|
||||
my_extension.add_view(TimelapseAPI, "/timelapse")
|
||||
125
docs/source/extensions/example_extension/07_ev_gui.py
Normal file
125
docs/source/extensions/example_extension/07_ev_gui.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from labthings.extensions import BaseExtension
|
||||
from labthings import find_component, Schema, fields, update_action_progress
|
||||
from labthings.views import View, ActionView, PropertyView
|
||||
|
||||
from flask import send_file # Used to send images from our server
|
||||
import io # Used in our capture action
|
||||
import time # Used in our timelapse function
|
||||
|
||||
# Used in our timelapse function
|
||||
from openflexure_microscope.captures.capture_manager import generate_basename
|
||||
|
||||
# Used to convert our GUI dictionary into a complete eV extension GUI
|
||||
from openflexure_microscope.api.utilities.gui import build_gui
|
||||
|
||||
## Extension methods
|
||||
|
||||
|
||||
def timelapse(microscope, n_images, t_between):
|
||||
"""
|
||||
Save a set of images in a timelapse
|
||||
|
||||
Args:
|
||||
microscope: Microscope object
|
||||
n_images (int): Number of images to take
|
||||
t_between (int/float): Time, in seconds, between sequential captures
|
||||
"""
|
||||
base_file_name = generate_basename()
|
||||
folder = "TIMELAPSE_{}".format(base_file_name)
|
||||
|
||||
# Take exclusive control over both the camera and stage
|
||||
with microscope.camera.lock, microscope.stage.lock:
|
||||
for n in range(n_images):
|
||||
# Generate a filename
|
||||
filename = f"{base_file_name}_image{n}"
|
||||
# Create a file to save the image to
|
||||
output = microscope.camera.new_image(
|
||||
filename=filename, folder=folder, temporary=False
|
||||
)
|
||||
|
||||
# Capture
|
||||
microscope.camera.capture(output)
|
||||
|
||||
# Add system metadata
|
||||
output.put_metadata(microscope.metadata, system=True)
|
||||
|
||||
# Update task progress (only does anyting if the function is running in a LabThings task)
|
||||
progress_pct = ((n + 1) / n_images) * 100 # Progress, in percent
|
||||
update_action_progress(progress_pct)
|
||||
|
||||
# Wait for the specified time
|
||||
time.sleep(t_between)
|
||||
|
||||
|
||||
## Extension views
|
||||
|
||||
|
||||
class TimelapseAPI(ActionView):
|
||||
"""
|
||||
Take a series of images in a timelapse, running as a background task
|
||||
"""
|
||||
args = {
|
||||
"n_images": fields.Integer(
|
||||
required=True, example=5, description="Number of images"
|
||||
),
|
||||
"t_between": fields.Number(
|
||||
missing=1, example=1, description="Time (seconds) between images"
|
||||
),
|
||||
}
|
||||
|
||||
def post(self, args):
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Create and start "timelapse", running in a background task
|
||||
return timelapse(
|
||||
microscope, args.get("n_images"), args.get("t_between")
|
||||
)
|
||||
|
||||
|
||||
## Extension GUI (OpenFlexure eV)
|
||||
# Alternate form without any dynamic parts
|
||||
extension_gui = {
|
||||
"icon": "timelapse", # Name of an icon from https://material.io/resources/icons/
|
||||
"forms": [ # List of forms. Each form is a collapsible accordion panel
|
||||
{
|
||||
"name": "Start a timelapse", # Form title
|
||||
"route": "/timelapse", # The URL rule (as given by "add_view") of your submission view
|
||||
"isTask": True, # This forms submission starts a background task
|
||||
"isCollapsible": False, # This form cannot be collapsed into an accordion
|
||||
"submitLabel": "Start", # Label for the form submit button
|
||||
"schema": [ # List of dictionaries. Each element is a form component.
|
||||
{
|
||||
"fieldType": "numberInput",
|
||||
"name": "n_images", # Name of the view arg this value corresponds to
|
||||
"label": "Number of images",
|
||||
"min": 1, # HTML number input attribute
|
||||
"default": 5, # HTML number input attribute
|
||||
},
|
||||
{
|
||||
"fieldType": "numberInput",
|
||||
"name": "t_between",
|
||||
"label": "Time (seconds) between images",
|
||||
"min": 0.1, # HTML number input attribute
|
||||
"step": 0.1, # HTML number input attribute
|
||||
"default": 1, # HTML number input attribute
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
## Create extension
|
||||
|
||||
# Create your extension object
|
||||
my_extension = BaseExtension("com.myname.timelapse-extension", version="0.0.0")
|
||||
|
||||
# Add methods to your extension
|
||||
my_extension.add_method(timelapse, "timelapse")
|
||||
|
||||
# Add API views to your extension
|
||||
my_extension.add_view(TimelapseAPI, "/timelapse")
|
||||
|
||||
# Add OpenFlexure eV GUI to your extension
|
||||
my_extension.add_meta("gui", build_gui(extension_gui, my_extension))
|
||||
21
docs/source/extensions/introduction.rst
Normal file
21
docs/source/extensions/introduction.rst
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
Introduction
|
||||
============
|
||||
|
||||
Extensions allow functionality to be added to the OpenFlexure Microscope web API without having to modify the base code.
|
||||
They have full access to the :py:class:`openflexure_microscope.Microscope` object,
|
||||
including direct access to any attached :py:class:`openflexure_microscope.camera.base.BaseCamera` and :py:class:`openflexure_stage.stage.OpenFlexureStage` objects.
|
||||
This also allows access to the :py:class:`picamera.PiCamera` object.
|
||||
|
||||
Extensions can either be loaded from a single Python file, or as a Python package installed to the environment being used.
|
||||
|
||||
Single-file extensions
|
||||
----------------------
|
||||
For adding simple functionality, such as a few basic functions and API routes, a single Python file can be loaded as a extension. This Python file must contain all of your extension objects, and be located in the applications extensions directory (by default ``/var/openflexure/extensions/microscope_extensions``).
|
||||
|
||||
Package extensions
|
||||
------------------
|
||||
Generally, for adding anything other than very simple functionality, extensions should be written as `package distributions <https://packaging.python.org/tutorials/packaging-projects/>`_. This has the advantage of allowing relative imports, so functionality can be easily split over several files. For example, class definitions associated with API routes can be separated from class definitions associated with the microscope extension.
|
||||
|
||||
Your module must be a folder within the extensions folder (by default ``/var/openflexure/extensions/microscope_extensions``), and include a top-level ``__init__.py`` file which includes (or imports) all of your extension objects.
|
||||
|
||||
In order to enable a globally installed, packaged extension, create a file in the applications extensions directory (by default ``/var/openflexure/extensions/microscope_extensions``) which imports your extension object(s) from your module.
|
||||
53
docs/source/extensions/lifecycle_hooks.rst
Normal file
53
docs/source/extensions/lifecycle_hooks.rst
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
Lifecycle Hooks
|
||||
===============
|
||||
|
||||
Introduction
|
||||
------------
|
||||
In some cases it is useful to have functions triggered by events in an extensions lifecycle. Currently two such lifecycle events can be used, ``on_register``, and ``on_component``.
|
||||
|
||||
``on_register``
|
||||
---------------
|
||||
|
||||
The ``on_register`` method can be used to have a function call as soon as the extension has been successfully registered to the microscope. For example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyExtension(BaseExtension):
|
||||
def __init__(self):
|
||||
|
||||
# Track if the extension has been registered
|
||||
self.registered = False
|
||||
|
||||
# Add lifecycle hooks
|
||||
self.on_register(self.on_register_handler, args=(), kwargs={})
|
||||
|
||||
# Superclass init function
|
||||
super().__init__("com.myname.myextension", version="0.0.0")
|
||||
|
||||
def on_register_handler(self, *args, **kwargs):
|
||||
self.registered = True
|
||||
print("Extension has been registered!")
|
||||
|
||||
|
||||
``on_component``
|
||||
----------------
|
||||
|
||||
The ``on_component`` method can be used to have a function call as soon as a particular LabThings component has been added. This can be used, for example, to get information about the microscope instance as soon as it is available. For example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyExtension(BaseExtension):
|
||||
def __init__(self):
|
||||
|
||||
# Hold a reference to the microscope object as soon as it is available
|
||||
self.microscope = None
|
||||
|
||||
# Add lifecycle hooks
|
||||
self.on_component("com.myname.myextension", self.on_microscope_handler)
|
||||
|
||||
# Superclass init function
|
||||
super().__init__("org.openflexure.microscope", version="0.0.0")
|
||||
|
||||
def on_microscope_handler(self, microscope_object):
|
||||
print("Microscope object has been found!")
|
||||
self.microscope = microscope_object
|
||||
171
docs/source/extensions/marshaling.rst
Normal file
171
docs/source/extensions/marshaling.rst
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
Marshaling data
|
||||
===============
|
||||
|
||||
Introduction
|
||||
------------
|
||||
|
||||
The OpenFlexure Microscope Server makes use of the `Marshmallow library <https://github.com/marshmallow-code/marshmallow/>`_ for both response and argument marshaling. From the Marshmallow documentation:
|
||||
|
||||
**marshmallow** is an ORM/ODM/framework-agnostic library for converting complex datatypes, such as objects, to and from native Python datatypes.
|
||||
|
||||
In short, marshmallow schemas can be used to:
|
||||
|
||||
- **Validate** input data.
|
||||
- **Deserialize** input data to app-level objects.
|
||||
- **Serialize** app-level objects to primitive Python types. The serialized objects can then be rendered to standard formats such as JSON for use in an HTTP API.
|
||||
|
||||
When developing extensions, you are encouraged to make use of your View ``schema`` and ``args`` class attributes to handle serialisation of your API responses, and parsing of request parameters respectively.
|
||||
|
||||
Schemas and fields
|
||||
++++++++++++++++++
|
||||
|
||||
A **field** describes the data type of a single parameter, as well as any other properties of that parameter for use in parsing, and documentation. For example, a String-type field, with a default value in case no actual value is passed, and extra documentation, may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
fields.String(required=False, missing="Default value", example="Example value")
|
||||
|
||||
A **schema** is a collection of keys and fields describing how an object should be serialized/deserialized. Schemas can be created in several ways, either by creating a ``Schema`` class, or by passing a dictionary of key-field pairs. Both methods will be discussed in the following examples.
|
||||
|
||||
|
||||
Argument parsing
|
||||
++++++++++++++++
|
||||
|
||||
In the previous section we saw how to use fields and ``args`` to get simple arguments from requests, in which a single parameter is required. By making use of Marshmallow schemas, and the `Webargs library <https://github.com/marshmallow-code/webargs>`_, we can allow for more complex requests containing many parameters of different types. The parsed request parameters are then passed to the view function as a positional argument (as before), in the form of a dictionary.
|
||||
|
||||
For example, if you are creating an API route, in which you expect parameters ``name``, ``age``, and optionally, ``job``, your schema class may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from labthings.server.schema import Schema
|
||||
from labthings.server import fields
|
||||
|
||||
class UserSchema(Schema):
|
||||
name = fields.String(required=True)
|
||||
age = fields.Integer(required=True)
|
||||
job = fields.String(required=False, missing="Unknown")
|
||||
|
||||
To inform your POST method to expect these arguments, use the ``args`` class attribute:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyView(View):
|
||||
args = UserSchema()
|
||||
|
||||
def post(self, args):
|
||||
..
|
||||
|
||||
Alternatively, if your schema is only used in a single location, it may be simpler to create a dictionary schema only where it is used, for example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyView(View):
|
||||
args = {
|
||||
"name": fields.String(required=True),
|
||||
"age": fields.Integer(required=True),
|
||||
"job": fields.String(required=False, missing="Unknown")
|
||||
}
|
||||
|
||||
def post(self, args):
|
||||
...
|
||||
|
||||
A compatible request body, in JSON format, may look like:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"name": "John Doe",
|
||||
"age": 45,
|
||||
"job": "Python developer"
|
||||
}
|
||||
|
||||
|
||||
This JSON data is the parsed, converted into a Python dictionary, and passed as an argument. Retreiving the data from within your view function may therefore look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyView(View):
|
||||
args = {
|
||||
"name": fields.String(required=True),
|
||||
"age": fields.Integer(required=True),
|
||||
"job": fields.String(required=False, missing="Unknown")
|
||||
}
|
||||
|
||||
def post(self, args):
|
||||
name = args.get("name") # Returns "John Doe", type str
|
||||
age = args.get("age") # Returns 45, type int
|
||||
job = args.get("job") # Returns "Python developer", type str
|
||||
|
||||
|
||||
Object serialization
|
||||
++++++++++++++++++++
|
||||
|
||||
Schemas can also be used to format our data so that it is suitable for an API response. Our API expects JSON formatted data both in, and out. It is therefore important that your API views respond with valid JSON where possible.
|
||||
|
||||
Continuing with our example in the previous pages, we will enhance our ``identify`` method to provide more, better formatted information about our current microscope.
|
||||
|
||||
We start by creating a schema to describe how to serialise a :py:class:`openflexure_microscope.Microscope` object.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Define which properties of a Microscope object we care about,
|
||||
# and what types they should be converted to
|
||||
class MicroscopeIdentifySchema(Schema):
|
||||
name = fields.String() # Microscopes name
|
||||
id = fields.UUID() # Microscopes unique ID
|
||||
state = fields.Dict() # Status dictionary
|
||||
camera = fields.String() # Camera object (represented as a string)
|
||||
stage = fields.String() # Stage object (represented as a string)
|
||||
|
||||
|
||||
We use this new schema in our ``identify`` view like so:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class ExampleIdentifyView(View):
|
||||
# Format our returned object using MicroscopeIdentifySchema
|
||||
schema = MicroscopeIdentifySchema()
|
||||
|
||||
def get(self):
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Return our microscope object,
|
||||
# let schema handle formatting the output
|
||||
return microscope
|
||||
|
||||
Note that our ``get`` method now returns the :py:class:`openflexure_microscope.Microscope` object itself. No formatting is done by the function, it is entirely handled by the view class, and its `schema` attribute. Additionally, since we defined our schema as a class, it can be re-used elsewhere.
|
||||
|
||||
For our ``rename`` view, we will use a simpler schema for our input arguments, defined by a dictionary (since we are only expecting a single parameter in, and it will likely not be re-used elsewhere). Our response, however, will use our ``MicroscopeIdentifySchema`` class. This means that the *response* of our ``identify`` and ``rename`` views will be identically formatted.
|
||||
|
||||
Our ``rename`` view class may now look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class ExampleRenameView(View):
|
||||
# Format our returned object using MicroscopeIdentifySchema
|
||||
schema = MicroscopeIdentifySchema()
|
||||
# Expect a request parameter called "name", which is a string. Pass to argument "args".
|
||||
args = {"name": fields.String(required=True, example="My Example Microscope")}
|
||||
|
||||
def post(self, args):
|
||||
# Look for our "name" parameter in the request arguments
|
||||
new_name = args.get("name")
|
||||
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Pass microscope and new name to our rename function
|
||||
rename(microscope, new_name)
|
||||
|
||||
# Return our microscope object,
|
||||
# let schema handle formatting the output
|
||||
return microscope
|
||||
|
||||
|
||||
Complete example
|
||||
++++++++++++++++
|
||||
|
||||
Combining both of these into our example extension, we now have:
|
||||
|
||||
.. literalinclude:: ./example_extension/03_marshaling_data.py
|
||||
114
docs/source/extensions/properties.rst
Normal file
114
docs/source/extensions/properties.rst
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
Thing Properties
|
||||
================
|
||||
|
||||
Introduction
|
||||
------------
|
||||
|
||||
As well as generating Swagger documentation, the server will generate a draft `W3C Thing Description <https://www.w3.org/TR/wot-thing-description/>`_ . This description allows the microscope's features to be understood in a common "Web of Things" language.
|
||||
|
||||
Thing Properties "expose state of the Thing. This state can then be retrieved (read) and optionally updated (write)." For the microscope, this includes the current read-only state, such as if the microscope has real camera or stage hardware attached, as well as read-write states like camera settings, and the microscope name.
|
||||
|
||||
The property description for a view will be generated automatically from your available view methods, any schema decorators used, and any docstrings added to the view.
|
||||
|
||||
|
||||
Defining Thing Properties
|
||||
-------------------------
|
||||
|
||||
In order to register a view as a Thing property, we use the ``PropertyView`` class, like so:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Since we only have a GET method here, it'll register as a read-only property
|
||||
class ExampleIdentifyView(PropertyView):
|
||||
# Format our returned object using MicroscopeIdentifySchema
|
||||
schema = MicroscopeIdentifySchema()
|
||||
|
||||
def get(self):
|
||||
"""
|
||||
Show identifying information about the current microscope object
|
||||
"""
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Return our microscope object,
|
||||
# let schemah handle formatting the output
|
||||
return microscope
|
||||
|
||||
|
||||
Property schema
|
||||
---------------
|
||||
|
||||
For read-write properties, it is best practice for the expected request arguments, and the views responses, to follow the same format. In this way, by looking at the response of a GET request, one can know the type of data expected in by a PUT request.
|
||||
|
||||
For example, if your GET request returns the JSON:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"name": "John Doe",
|
||||
"age": 45,
|
||||
"job": "Python developer"
|
||||
}
|
||||
|
||||
and your property supports PUT requests (for updating data), then a valid PUT request could contain the data:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"age": 46,
|
||||
"job": "Landscape gardener"
|
||||
}
|
||||
|
||||
This request would update the property, such that a GET request would *now* return:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"name": "John Doe",
|
||||
"age": 46,
|
||||
"job": "Landscape gardener"
|
||||
}
|
||||
|
||||
In Property Views the ``schema`` class attribute acts as the schema for both marshalling responses *and* parsing arguments. This is because property requests and responses should be identically formatted.
|
||||
|
||||
We will implement the ``schema`` attribute in our ``ExampleRenameView`` view from our previous example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# We can use a single schema as the input and output will be formatted identically
|
||||
# Eg. We always expect a "name" string argument, and always return a "name" string attribute
|
||||
class ExampleRenameView(PropertyView):
|
||||
schema = {"name": fields.String(required=True, example="My Example Microscope")}
|
||||
|
||||
def get(self):
|
||||
"""
|
||||
Show the current microscope name
|
||||
"""
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
return microscope
|
||||
|
||||
def post(self, args):
|
||||
"""
|
||||
Change the current microscope name
|
||||
"""
|
||||
# Look for our "name" parameter in the request arguments
|
||||
new_name = args.get("name")
|
||||
|
||||
# Find our microscope component
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Pass microscope and new name to our rename function
|
||||
rename(microscope, new_name)
|
||||
|
||||
# Return our microscope object,
|
||||
# let schema handle formatting the output
|
||||
return microscope
|
||||
|
||||
Complete example
|
||||
----------------
|
||||
|
||||
Combining these into our example extension, we now have:
|
||||
|
||||
.. literalinclude:: ./example_extension/04_properties.py
|
||||
36
docs/source/extensions/structure.rst
Normal file
36
docs/source/extensions/structure.rst
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
Basic extension structure
|
||||
=========================
|
||||
|
||||
An extension starts as a simple instance of :py:class:`labthings.server.extensions.BaseExtension`.
|
||||
Each extension is described by a single ``BaseExtension`` instance, containing any number of methods, API views, and additional hardware components.
|
||||
|
||||
In order to access the currently running microscope object, use the :py:func:`labthings.server.find.find_component` function, with the argument ``"org.openflexure.microscope"``. Likewise, any new components attached by other extensions can be found using their full name, as above.
|
||||
|
||||
A simple extension file, with no API views but application-available methods may look like:
|
||||
|
||||
.. literalinclude:: ./example_extension/01_basic_structure.py
|
||||
|
||||
|
||||
Once this extension is loaded, any other extensions will have access to your methods:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from labthings.server.find import find_extension
|
||||
|
||||
def test_extension_method():
|
||||
# Find your extension. Returns None if it hasn't been found.
|
||||
my_found_extension = find_extension("com.myname.myextension")
|
||||
|
||||
# Call a function from your extension
|
||||
if my_found_extension:
|
||||
my_found_extension.identify()
|
||||
|
||||
|
||||
Subclassing ``BaseExtension``
|
||||
-------------------------------
|
||||
|
||||
The syntax used above allows novice programmers to easily start building extensions, without having to deal with subclassing. However, for more complex extensions which require persistent state, subclassing :py:class:`labthings.server.extensions.BaseExtension` is recommended.
|
||||
|
||||
The same simple extension as seen above can be written using subclassing:
|
||||
|
||||
.. literalinclude:: ./example_extension/01b_basic_structure_subclass.py
|
||||
85
docs/source/extensions/tasks_locks.rst
Normal file
85
docs/source/extensions/tasks_locks.rst
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
Threads and Locks
|
||||
=================
|
||||
|
||||
Introduction
|
||||
------------
|
||||
|
||||
Some actions in your extension may perform tasks that take a long time (compared to the expected response time of a web request). For example, if you were to implement a timelapse feature, this inherently runs over a long time.
|
||||
|
||||
This introduces a couple of problems. Firstly, a request that triggers a long function will, by default, block the Python interpreter for the duration of the function. This usually causes the connection to timeout, and the response will never be revieved.
|
||||
|
||||
Similarly, if your functionality takes a long time, it may be possible for other requests to interfere with your function. For example, in our hypothetical timelapse extension, while the timelapse is running, another user could open a connection and start moving the stage around, ruining the timelapse.
|
||||
|
||||
We get around these issues by making use of action threads, and component locks.
|
||||
|
||||
Action threads
|
||||
--------------
|
||||
|
||||
Action threads are introduced to manage long-running functions in a way that does not block HTTP requests. Any API Action will automatically run as a background thread.
|
||||
|
||||
Internally, the :class:`labthings.LabThing` object stores a list of all requested actions, and their states. This state stores the running status of the action (if itis idle, running, error, or success), information about the start and end times, a unique ID, and, upon completion, the return value of the long-running function.
|
||||
|
||||
By using threads, a function can be started in the background, and it's return value fetched at a later time once it has reported success. If a long-running action is started by some client, it should note the ID returned in the action state JSON, and use this to periodically check on the status of that particular action.
|
||||
|
||||
API routes have been created to allow checking the state of all actions (GET ``/actions``), a particular action by ID (GET ``/actions/<action_id>``), and terminating or removing individual actions (DELETE ``/actions/<action_id>``).
|
||||
|
||||
All actions will return a serialized representation of the action state when your POST request returns. If the action completes within a default timeout period (usually 1 second) then the completed action representation will be returned. If the action is still running after this timeout period, the "in-progress" action representation will be returned. The final output value can then be retrieved at a later time.
|
||||
|
||||
Most users will not need to create instances of this class. Instead, they will be created automatically when a function is started by an API Action view.
|
||||
|
||||
An example of a long running task may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
...
|
||||
from labthings.server.view import ActionView
|
||||
|
||||
class SlowAPI(ActionView):
|
||||
def post(self):
|
||||
# Return the task object.
|
||||
return long_running_function(function_argument_1, function_argument_2)
|
||||
|
||||
After some time, once the task has completed, it could be retreived using:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
...
|
||||
from labthings import current_labthing
|
||||
|
||||
def get_result(action_id):
|
||||
matching_action = current_labthing().actions.get(task_id)
|
||||
return matching_action.state
|
||||
|
||||
or by making GET requests to the ``http://microscope.local/api/v2/tasks/<task_id>`` view.
|
||||
|
||||
|
||||
Updating action progress
|
||||
++++++++++++++++++++++++
|
||||
|
||||
Some applications such as OpenFlexure eV are able to display progress bars showing the progress of an action thread. Implementing progress updates in your extension is made easy with the :py:meth:`labthings.update_action_progress` function. This function takes a single argument, which is the action progress as an integer percent (0 - 100).
|
||||
|
||||
If your long running function was started within a background thread, this function will update the state of the corresponding action thread object. If your function is called outside of a long-running task (e.g. by another extension, directly), then this function will silently do nothing.
|
||||
|
||||
An example of task progress is included in the example later on this page.
|
||||
|
||||
|
||||
Component Locks
|
||||
---------------
|
||||
|
||||
Locks have been implemented to solve a distinct issue, most obvious when considering long-running actions. During a long action such as a tile-scan or autofocus, it is absolutely necesarry to block any completing interaction with the microscope hardware. For example, even if the stage is not actively moving (for example during a capture phase within a tile scan), another user should not be able to move the microscope, interrupting the action. Thread locks act to prevent this.
|
||||
|
||||
The camera and stage both contain an instance of :py:class:`labthings.lock.StrictLock`, named ``lock``. Built-in functions such as capture and move will always acquire this lock for the duration of the function. This ensures that, for example, simultaneous attemps to move do not occur.
|
||||
|
||||
More importantly, however, threads can hold on to these locks for longer periods of time, blocking any other calls to the hardware.
|
||||
|
||||
Locks are acquired using context managers, i.e. ``with component.lock: ...``
|
||||
|
||||
|
||||
Complete example
|
||||
----------------
|
||||
|
||||
Implementing both action threads and locks in a new timelapse extension may look like:
|
||||
|
||||
.. literalinclude:: ./example_extension/06_tasks_locks.py
|
||||
|
||||
Notice that even though we never use the stage here, our ``timelapse`` function still acquires the stage lock. This means that during the timelapse, no other user is able to move the stage, or take separate captures. Control of the microscope is handed exclusively to the thread that obtains the lock, which in this case is the thread spawned when handling the POST request.
|
||||
34
docs/source/extensions/views.rst
Normal file
34
docs/source/extensions/views.rst
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
Adding web API views
|
||||
====================
|
||||
|
||||
Introduction
|
||||
------------
|
||||
Extensions can create views to expose extension functionality via the web API. Creating API views for your extension is strongly recommended, as this is the primary way we encourage interaction with the microscope device.
|
||||
|
||||
As with most HTTP APIs, we make use of basic HTTP request methods. GET requests return data without modifying any state. POST requests completely replace data with data passed as request arguments. PUT requests update data with new data passed as request arguments. DELETE requests delete a particular object from the server. Your API views need not implement all of these methods.
|
||||
|
||||
Continuing our example on the previous page, and discussed below, adding API views may look like:
|
||||
|
||||
.. literalinclude:: ./example_extension/02_adding_views.py
|
||||
|
||||
Note that we are now passing our microscope object as an argument to our API methods. Finding the microscope component is performed by the API view at request-time, and passed onto the functions.
|
||||
|
||||
In this case, our extension will have two new API views at `/identify` and `/rename`. The `/identify` view only accepts GET requests, and the `/rename` view only accepts POST requests.
|
||||
|
||||
Request arguments
|
||||
+++++++++++++++++
|
||||
|
||||
For POST and PUT requests, data usually needs to be provided to the view in order to perform its function. In this example, our ``rename`` view requires a new microscope name to be passed. We make use of the ``args`` class attribute to provide this functionality.
|
||||
|
||||
``args`` defines the type of data expected in the request body. In this example, we use ``String`` type data. The arguments of ``fields.String`` allow us to provide additional information, such as the parameter being required, and example values to appear in API documentation.
|
||||
|
||||
Adding additional fields, and the meaning of the field types, will be discussed further in the next section.
|
||||
|
||||
When a POST request is made to our API view, the server converts the body of the request into a ``String``, and passes it as a positional argument to our ``post`` function.
|
||||
|
||||
Swagger documentation
|
||||
+++++++++++++++++++++
|
||||
|
||||
At this point, it is useful to introduce the automatically generated Swagger documentation. From any web browser, go to ``http://microscope.local/api/v2/docs/swagger-ui`` (or replace ``microscope.local`` with your microscope's IP address if ``microscope.local`` doesn't work for your system).
|
||||
|
||||
This page uses `SwaggerUI <https://swagger.io/tools/swagger-ui/>`_ to provide visual, interactive API documentation. Find your extensions URL in the documentation under the ``extensions`` group. Basic documentation about the parameters required for your POST method should be visible, as well as an interactive example filled out with the example request given in the view ``schema``.
|
||||
|
|
@ -9,6 +9,7 @@ Welcome to OpenFlexure Microscope Software's documentation!
|
|||
config.rst
|
||||
microscope.rst
|
||||
camera.rst
|
||||
stage.rst
|
||||
plugins.rst
|
||||
api.rst
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
Microscope class
|
||||
=======================================================
|
||||
|
||||
The main microscope class handles microscope settings, passing these between the settings file and their appropriate components (camera, stage), basic metadata about the current device status, and interfacing with the separate camera and stage components.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
.. automodule:: openflexure_microscope
|
||||
:members:
|
||||
.. automodule:: openflexure_microscope.microscope
|
||||
:members:
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
Developing Plugins
|
||||
=======================================================
|
||||
Developing API Extensions
|
||||
=========================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
./plugins/introduction.rst
|
||||
./plugins/structure.rst
|
||||
./plugins/routes.rst
|
||||
./plugins/hardware.rst
|
||||
./plugins/schema.rst
|
||||
./plugins/example.rst
|
||||
./plugins/class.rst
|
||||
./extensions/introduction.rst
|
||||
./extensions/structure.rst
|
||||
./extensions/views.rst
|
||||
./extensions/marshaling.rst
|
||||
./extensions/properties.rst
|
||||
./extensions/actions.rst
|
||||
./extensions/tasks_locks.rst
|
||||
./extensions/ev_gui.rst
|
||||
./extensions/lifecycle_hooks.rst
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
Classes and Modules
|
||||
===================
|
||||
|
||||
Plugin class
|
||||
------------
|
||||
.. autoclass:: openflexure_microscope.plugins.MicroscopePlugin
|
||||
:members:
|
||||
|
||||
.. autoclass:: openflexure_microscope.api.v1.views.MicroscopeViewPlugin
|
||||
:members:
|
||||
|
||||
Task module
|
||||
-----------
|
||||
.. automodule:: openflexure_microscope.task
|
||||
:members:
|
||||
|
||||
Lock module
|
||||
-----------
|
||||
.. automodule:: openflexure_microscope.lock
|
||||
:members:
|
||||
|
||||
Default plugins
|
||||
---------------
|
||||
|
||||
Autofocus
|
||||
+++++++++
|
||||
.. automodule:: openflexure_microscope.plugins.default.autofocus
|
||||
:members:
|
||||
|
||||
Camera calibration
|
||||
++++++++++++++++++
|
||||
.. automodule:: openflexure_microscope.plugins.default.camera_calibration
|
||||
:members:
|
||||
|
||||
Scan and Stack
|
||||
++++++++++++++
|
||||
.. automodule:: openflexure_microscope.plugins.default.scan
|
||||
:members:
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
Example plugin
|
||||
--------------
|
||||
|
||||
plugin.py
|
||||
+++++++++
|
||||
|
||||
.. literalinclude:: example/plugin.py
|
||||
:language: python
|
||||
|
||||
schema.json
|
||||
+++++++++++
|
||||
|
||||
.. literalinclude:: example/schema.json
|
||||
:language: JSON
|
||||
|
||||
Notes
|
||||
+++++
|
||||
|
||||
In this example, if the package or file were named ``my_plugin``, the three microscope plugin methods would be accessible from ``<microscope_object>.plugin.my_plugin.identify()``, ``<microscope_object>.plugin.my_plugin.timelapse()``, and ``<microscope_object>.plugin.my_plugin.hello_world()``.
|
||||
|
||||
Web API routes would automatically be set up at ``/api/v1/plugin/my_plugin/identify`` (GET), ``/api/v1/plugin/my_plugin/timelapse`` (POST), and ``/api/v1/plugin/my_plugin/hello`` (GET, POST).
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
from openflexure_microscope.plugins import MicroscopePlugin
|
||||
from openflexure_microscope.api.v1.views import MicroscopeViewPlugin
|
||||
from openflexure_microscope.api.utilities import JsonPayload
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
|
||||
from flask import request, Response, escape, jsonify
|
||||
|
||||
HERE = os.path.dirname(os.path.realpath(__file__))
|
||||
SCHEMA_PATH = os.path.join(HERE, "schema.json")
|
||||
|
||||
### MICROSCOPE PLUGIN ###
|
||||
|
||||
class MyPluginClass(MicroscopePlugin):
|
||||
"""
|
||||
A set of default plugins
|
||||
"""
|
||||
|
||||
global SCHEMA_PATH
|
||||
|
||||
with open(SCHEMA_PATH, 'r') as sc:
|
||||
api_schema = json.load(sc)
|
||||
|
||||
api_views = {
|
||||
'/identify': IdentifyAPI,
|
||||
'/hello': HelloWorldAPI,
|
||||
'/timelapse': TimelapseAPI,
|
||||
}
|
||||
|
||||
def identify(self):
|
||||
"""
|
||||
Demonstrate access to Microscope.camera, and Microscope.stage
|
||||
"""
|
||||
|
||||
response = "My parent camera is {}, and my parent stage is {}.".format(self.microscope.camera,
|
||||
self.microscope.stage)
|
||||
return response
|
||||
|
||||
def hello_world(self):
|
||||
"""
|
||||
Demonstrate passive method
|
||||
"""
|
||||
|
||||
return "Hello world!"
|
||||
|
||||
def timelapse(self, n_images):
|
||||
"""
|
||||
Demonstrate a long-running method that requires microscope hardware
|
||||
"""
|
||||
print("Starting timelapse...")
|
||||
capture_array = [] # Empty list to store captures in
|
||||
|
||||
# Acquire locks. Exception is raised if lock is in use by another thread.
|
||||
with self.microscope.camera.lock, self.microscope.stage.lock:
|
||||
for _ in range(n_images):
|
||||
|
||||
# Create a data stream to capture to
|
||||
capture_data = self.microscope.camera.new_image(
|
||||
write_to_file=True,
|
||||
temporary=False)
|
||||
|
||||
# Capture a still image from the Pi camera, into the data stream
|
||||
self.microscope.camera.capture(
|
||||
capture_data,
|
||||
use_video_port=True)
|
||||
|
||||
# Append the capture data to our list
|
||||
capture_array.append(capture_data)
|
||||
|
||||
# Wait for 1 minute
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
### API VIEWS ###
|
||||
|
||||
class IdentifyAPI(MicroscopeViewPlugin):
|
||||
"""
|
||||
A simple example API plugin, attached through the main microscope plugin.
|
||||
"""
|
||||
def get(self):
|
||||
"""
|
||||
Method to call when an HTTP GET request is made.
|
||||
"""
|
||||
# Call a method from our plugin, using the MicroscopeViewPlugin.plugin shortcut
|
||||
data = self.plugin.identify()
|
||||
return Response(escape(data))
|
||||
|
||||
|
||||
class HelloWorldAPI(MicroscopeViewPlugin):
|
||||
"""
|
||||
A method to create, set, and return a new microscope parameter.
|
||||
"""
|
||||
|
||||
def get(self):
|
||||
"""
|
||||
Method to call when an HTTP GET request is made.
|
||||
"""
|
||||
|
||||
# If the microscope does not already contain our plugin_string attribute
|
||||
if not hasattr(self.microscope, 'plugin_string'):
|
||||
# Make a string, using the MicroscopeViewPlugin.plugin shortcut
|
||||
self.microscope.plugin_string = self.plugin.hello_world()
|
||||
|
||||
json_response = jsonify({
|
||||
'plugin_string': self.microscope.plugin_string
|
||||
})
|
||||
|
||||
return Response(json_response)
|
||||
|
||||
def post(self):
|
||||
"""
|
||||
Method to call when an HTTP POST request is made.
|
||||
Assumes request will include a JSON payload.
|
||||
"""
|
||||
# Get payload JSON
|
||||
payload = JsonPayload(request)
|
||||
|
||||
# Extract a value from the JSON key 'plugin_string', and convert to a string. If no value is given, default to empty.
|
||||
new_plugin_string = payload.param('plugin_string', default='', convert=str)
|
||||
|
||||
if new_plugin_string: # If not None or empty
|
||||
# Set microscope attribute to the specified string
|
||||
self.microscope.plugin_string = new_plugin_string
|
||||
|
||||
json_response = jsonify({
|
||||
'plugin_string': self.microscope.plugin_string
|
||||
})
|
||||
|
||||
return Response(json_response)
|
||||
|
||||
class TimelapseAPI(MicroscopeViewPlugin):
|
||||
def post(self):
|
||||
|
||||
# Get any JSON data in the body of the POST request
|
||||
payload = JsonPayload(request)
|
||||
|
||||
# Extract the "n_images" parameter if it was passed. Otherwise, default to 10.
|
||||
n_images = payload.param('n_images', default=10, convert=int)
|
||||
|
||||
# Attach the long-running method as a microscope task
|
||||
self.timelapse_task = self.microscope.task.start(self.plugin.timelapse, n_images)
|
||||
|
||||
# Return the state of the task (will show ID, start time, and status before the task has finished)
|
||||
return jsonify(self.timelapse_task.state), 202
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"id": "example-plugin",
|
||||
"icon": "pets",
|
||||
"forms": [
|
||||
{
|
||||
"name": "Hello world",
|
||||
"isTask": false,
|
||||
"selfUpdate": true,
|
||||
"route": "/hello",
|
||||
"schema": [
|
||||
{
|
||||
"fieldType": "textInput",
|
||||
"placeholder": "Enter a string",
|
||||
"label": "Plugin string",
|
||||
"name": "plugin_string"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Timelapse",
|
||||
"isTask": true,
|
||||
"route": "/timelapse",
|
||||
"submitLabel": "Start timelapse",
|
||||
"schema": [
|
||||
{
|
||||
"fieldType": "numberInput",
|
||||
"name": "n_images",
|
||||
"label": "Number of images"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
Accessing the microscope
|
||||
========================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
Introduction
|
||||
------------
|
||||
All instances of :py:class:`openflexure_microscope.api.v1.views.MicroscopeViewPlugin` must be attached via a microscope plugin. As a result of this, the ``MicroscopeViewPlugin`` class has a ``self.microscope`` attribute, allowing direct access to the :py:class:`openflexure_microscope.Microscope` object. This means that web API plugins can simply chain together basic microscope functions and expose new API routes. No additional microscope functionality is required. However, in most cases web API plugins will serve to provide API routes to new microscope functionality defined in a microscope plugin.
|
||||
|
||||
|
||||
Tasks and Locks
|
||||
---------------
|
||||
Two principles have been implemented to regulate and synchronise access to the microscope hardware: tasks, and locks.
|
||||
Especially when writing plugins with API routes, careful use of both tasks and locks should be implemented.
|
||||
|
||||
Tasks
|
||||
+++++
|
||||
Tasks are introduced to manage long-running functions in a way that does not block HTTP requests. Without
|
||||
the use of tasks, long-running functions would block an HTTP response until the function returns, often
|
||||
resulting in a timeout.
|
||||
|
||||
To prevent this, any function can be offloaded to a background task. This is done through the microscope's ``task`` object,
|
||||
by calling ``<microscope>.task.start(<long_running_function>, *args, **kwargs)``. Internally, the ``task`` object stores a list
|
||||
of all requested tasks, which themselves contain a ``state`` dictionary. This dictionary stores the status of the task (if it
|
||||
is idle, running, error, or success), information about the start and end times, a unique task ID, and the return value of
|
||||
the long-running function.
|
||||
|
||||
By using tasks, a function can be started in the background, and it's return value fetched at a later time once it has reported
|
||||
success. If a long-running task is started by some client, it should note the ID returned in the task state JSON, and use this to
|
||||
periodically check on the status of that particular task. API routes have been created to allow checking the state of all tasks
|
||||
(GET `/task`), a particular task by ID (GET `/task/<task_id>`), removing individual tasks (DELETE `/task/<task_id>`), and pruning
|
||||
the task list of any no-longer-running tasks (DELETE `/task`).
|
||||
|
||||
An example of a long running task may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from openflexure_microscope.plugins import MicroscopeViewPlugin
|
||||
from openflexure_microscope.exceptions import TaskDeniedException
|
||||
|
||||
class MyPlugin(MicroscopeViewPlugin):
|
||||
def post(self):
|
||||
# Attach the long-running method as a microscope task
|
||||
try:
|
||||
self.my_task = self.microscope.task.start(self.plugin.long_running_function)
|
||||
return jsonify(self.my_task.state), 202
|
||||
|
||||
except TaskDeniedException:
|
||||
return abort(409)
|
||||
|
||||
After some time, once the task has completed, it could be retreived using:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
...
|
||||
def get_result(self):
|
||||
self.my_task.state['status'] == "success":
|
||||
return self.my_task.state['return']
|
||||
|
||||
Locks
|
||||
+++++
|
||||
Locks have been implemented to solve a distinct issue, most obvious when considering long-running tasks. During
|
||||
a long task such as a tile-scan or autofocus, it is absolutely necesarry to block any completing interaction with
|
||||
the microscope hardware. For example, even if the stage is not actively moving (for example during a capture phase
|
||||
within a tile scan), another user should not be able to move the microscope, interrupting the task. Thread locks act
|
||||
to prevent this.
|
||||
|
||||
The camera and stage both contain an instance of :py:class:`openflexure_microscope.lock.StrictLock`, named ``lock``.
|
||||
Built-in functions such as capture and move will always acquire this lock for the duration of the function. This ensures
|
||||
that, for example, simultaneous attemps to move do not occur.
|
||||
|
||||
More importantly, however, tasks can hold on to these locks for longer periods of time, blocking any other calls to the hardware.
|
||||
|
||||
For example, a timelapse plugin may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from openflexure_microscope.plugins import MicroscopePlugin
|
||||
from openflexure_microscope.exceptions import TaskDeniedException
|
||||
from openflexure_microscope.api.v1.views import MicroscopeViewPlugin
|
||||
from openflexure_microscope.api.utilities import JsonPayload
|
||||
|
||||
|
||||
### MICROSCOPE PLUGIN ###
|
||||
|
||||
class MyPluginClass(MicroscopePlugin):
|
||||
|
||||
api_views = {
|
||||
'/timelapse': TimelapseAPI,
|
||||
}
|
||||
|
||||
def timelapse(self, n_images):
|
||||
"""
|
||||
Demonstrate a long-running method that requires microscope hardware
|
||||
"""
|
||||
print("Starting timelapse...")
|
||||
capture_array = [] # Empty list to store captures in
|
||||
|
||||
# Acquire locks. Exception is raised if lock is in use by another thread.
|
||||
with self.microscope.camera.lock, self.microscope.stage.lock:
|
||||
for _ in range(n_images):
|
||||
|
||||
# Create a data stream to capture to
|
||||
capture_data = self.microscope.camera.new_image(
|
||||
write_to_file=True,
|
||||
temporary=False)
|
||||
|
||||
# Capture a still image from the Pi camera, into the data stream
|
||||
self.microscope.camera.capture(
|
||||
capture_data,
|
||||
use_video_port=True)
|
||||
|
||||
# Append the capture data to our list
|
||||
capture_array.append(capture_data)
|
||||
|
||||
# Wait for 1 minute
|
||||
time.sleep(60)
|
||||
|
||||
return capture_array
|
||||
|
||||
|
||||
### API ROUTES ###
|
||||
|
||||
class TimelapseAPI(MicroscopeViewPlugin):
|
||||
def post(self):
|
||||
|
||||
# Get any JSON data in the body of the POST request
|
||||
payload = JsonPayload(request)
|
||||
|
||||
# Extract the "n_images" parameter if it was passed. Otherwise, default to 10.
|
||||
n_images = payload.param('n_images', default=10, convert=int)
|
||||
|
||||
# Attach the long-running method as a microscope task
|
||||
self.timelapse_task = self.microscope.task.start(self.plugin.timelapse, n_images)
|
||||
|
||||
# Return the state of the task (will show ID, start time, and status before the task has finished)
|
||||
return jsonify(self.timelapse_task.state), 202
|
||||
|
||||
|
||||
Notice that even though we never use the stage here, we still acquire the lock. This means that during the timelapse,
|
||||
no other user is able to move the stage, or take separate captures. Control of the microscope is handed exclusively
|
||||
to the thread that obtains the lock, which in this case is the thread spawned when handling a POST request:
|
||||
``self.microscope.task.start(self.plugin.timelapse, n_images)``.
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
Introduction
|
||||
============
|
||||
|
||||
Plugins allow functionality to be added to the OpenFlexure Microscope without having to modify the base code.
|
||||
They have full access to the :py:class:`openflexure_microscope.Microscope` object,
|
||||
including direct access to the attached :py:class:`openflexure_microscope.camera.pi.StreamingCamera` and :py:class:`openflexure_stage.stage.OpenFlexureStage` objects.
|
||||
This also allows direct access to the :py:class:`picamera.PiCamera` object.
|
||||
|
||||
All microscope plugins must subclass :py:class:`openflexure_microscope.plugins.MicroscopePlugin` in order to be loaded by the microscopes :py:class:`openflexure_microscope.plugins.PluginMount`.
|
||||
|
||||
Once attached, all methods defined within the plugin class will be accessible from ``<microscope_object>.plugin.<plugin_namespace>.<plugin_method(args)>``.
|
||||
Here, ``<microscope_object>`` is an instance of :py:class:`openflexure_microscope.Microscope`. The ``<plugin_method>`` is the method defined within your plugin class. Finally, ``<plugin_namespace>`` is determined differently depending on the type of plugin.
|
||||
|
||||
Plugins can either be loaded as a single Python file located anywhere on disk, or as a Python package installed to the environment being used. If loaded from a single file, the namespace is set to the file name (excluding .py extension) of the plugin file. If loaded from a package, the namespace is set to the name of the top-level module in the package. For example, if your plugin class definition resides within ``my_openflexure_plugins.microscope.mypluginpackage``, the namespace will be set to ``mypluginpackage``. Where possible, try to use descriptive, unique package names for this reason. For example, rather than name your plugin package ``autofocus``, which would like cause namespace clashes, instead name it ``yourname_autofocus``, or similar.
|
||||
|
||||
Module (single-file) plugins
|
||||
----------------------------
|
||||
For adding simple functionality, such as a few basic functions and API routes, a single Python file can be loaded as a plugin.
|
||||
|
||||
This Python file must contain all of your plugin classes. Relative imports will not work. External modules and packages can be used with absolute imports, however for more complex plugins, it is often worth instead making use of an installable package plugin.
|
||||
|
||||
Package plugins
|
||||
---------------
|
||||
Generally, for adding anything other than very simple functionality, plugins should be written as `package distributions <https://packaging.python.org/tutorials/packaging-projects/>`_. This has the advantage of allowing relative imports, so functionality can be easily split over several files. For example, class definitions associated with API routes can be separated from class definitions associated with the microscope plugin.
|
||||
|
||||
The main restriction is that the plugin package must be importable using an absolute import from within the Python environment being used to load your microscope.
|
||||
|
||||
Loading plugins with microscope_settings.yaml
|
||||
---------------------------------------------
|
||||
Both types of plugin are loaded by specifying the plugin class in your :ref:`MicroscopeRC`. In the case of a single-file plugin, specify the path to the plugin file, followed by the name of your :py:class:`openflexure_microscope.plugins.MicroscopePlugin` child class, separated by a colon. For packaged plugins, specify the absolute module name in place of the path.
|
||||
|
||||
For example, the plugins section of your microscope_settings.yaml file may look like:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
...
|
||||
plugins:
|
||||
- openflexure_microscope.plugins.default:Plugin
|
||||
- ~/my_plugins/my_plugin_file.py:MyPluginClass
|
||||
- my_openflexure_plugins.microscope.mypluginpackage:MyPluginClass
|
||||
...
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
Adding web API routes
|
||||
=====================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
Introduction
|
||||
------------
|
||||
Plugins can automatically create routes to expose plugin functionality via the web API. Creating API routes for your plugin is strongly recommended, as this is the primary way we encourage interaction with the microscope device.
|
||||
|
||||
To create API routes, add a dictionary to your plugin class, named ``api_views``. Within this dictionary, each key should be a string defining the route URL, whose value is a class, subclassing :py:class:`openflexure_microscope.api.v1.views.MicroscopeViewPlugin`.
|
||||
|
||||
For example, your ``api_views`` dictionary may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyPluginClass(MicroscopePlugin):
|
||||
|
||||
api_views = {
|
||||
'/myplugin': MyRouteAPI,
|
||||
}
|
||||
|
||||
Here, ``MyRouteAPI`` is a web API plugin class, subclassing :py:class:`openflexure_microscope.api.v1.views.MicroscopeViewPlugin`. If your plugin package were named ``myplugins.package``, an API route would be automatically added at ``/api/v1/plugin/myplugins/package/myplugin``.
|
||||
|
||||
The full URL that your plugin will attach to is essentially identical to it's full module path. That is, if your plugin is loaded from ``my_microscope_plugins.mypluginpackage:MyPluginClass``, then your plugin routes will appear at ``<microscope_url>/plugin/my_microscope_plugins/mypluginpackage/<route>``. While this means that plugin routes can get long very quickly, they will generally only ever be accessed by client applications, and so this generally should not be a problem.
|
||||
|
||||
The MicroscopeViewPlugin class
|
||||
------------------------------
|
||||
|
||||
All API plugin classes must subclass :py:class:`openflexure_microscope.api.v1.views.MicroscopeViewPlugin`, which is itself a subclass of `Flask's MethodView <http://flask.pocoo.org/docs/1.0/api/#flask.views.MethodView>`_. This greatly simplifies defining different functionality associated with different HTTP methods at a single URL route.
|
||||
It is best practice to clearly separate out types of functionality by HTTP method. For example, a GET request should never change the state of the microscope. For this, POST or PUT requests are acceptable. Parameters should be passed to POST and PUT requests as JSON payloads, and your methods should include fallback code for cases where parameters are not passed, or are passed in an invalid format. The DELETE method should only be used in situations where your plugin creates additional URL routes for newly created objects, and should serve only to delete these objects and routes.
|
||||
|
||||
Each HTTP method maps to a function with the same name, in lowercase. For example, your :py:class:`openflexure_microscope.api.v1.views.MicroscopeViewPlugin` may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from flask import jsonify
|
||||
...
|
||||
class MyRouteAPI(MicroscopeViewPlugin):
|
||||
|
||||
def get(self):
|
||||
# Retrieve some information, without changing the state of the microscope
|
||||
...
|
||||
|
||||
def post(self):
|
||||
# Change the state of the microscope based on passed parameters
|
||||
...
|
||||
|
||||
Sometimes you will need to create variable API routes. For example, the built-in routes for managing capture data use capture IDs in the request URL to specify which capture data should be returned. Plugins can also access this functionality. This is done using `Flask variable rules <http://flask.pocoo.org/docs/1.0/quickstart/#variable-rules>`_. Here, variables are added to the URL route string by marking them with ``<variable_name>``, which then passes ``variable_name`` to your request function as a keyword argument.
|
||||
|
||||
For example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyPluginClass(MicroscopePlugin):
|
||||
|
||||
api_views = {
|
||||
'/myplugin/<object_id>': MyRouteAPI,
|
||||
}
|
||||
...
|
||||
|
||||
class MyRouteAPI(MicroscopeViewPlugin):
|
||||
|
||||
def get(self, object_id):
|
||||
# Retrieve some information about object_id
|
||||
this_object = object_dictionary[object_id]
|
||||
...
|
||||
|
||||
Calling plugin methods from routes
|
||||
++++++++++++++++++++++++++++++++++
|
||||
|
||||
Instances of MicroscopeViewPlugin have direct access to their associated microscope plugin methods, without needing to know the plugin namespace in advance. As described earlier in this section, all plugins get attached to the microscope in their own namespace, based on the plugins name. This means there are two equivalent ways to access your plugin methods from a web API plugin:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
...
|
||||
# Call a method from our plugin, using the MicroscopeViewPlugin.plugin shortcut
|
||||
self.plugin.my_plugin_method()
|
||||
|
||||
# Call a method from our plugin, using the full route
|
||||
self.microscope.my_plugin_name.my_plugin_method()
|
||||
...
|
||||
|
||||
Building responses
|
||||
++++++++++++++++++
|
||||
|
||||
Since we are using Flask as our web framework, you're able to return any type of `Flask response <http://flask.pocoo.org/docs/1.0/quickstart/#about-responses>`_ to be passed back to the client. However, since the server is designed to be accessed by clients as a simple API, it is **strongly** recommended that responses are JSON formatted. Both GET and POST methods should return a JSON object describing the current state of the microscope relevant to the action performed.
|
||||
|
||||
Fortunately, Flask includes a ``jsonify`` method to convert any Python dictionary into a valid JSON response. See the `Flask documentation <http://flask.pocoo.org/docs/1.0/api/#flask.json.jsonify>`_ for more information.
|
||||
|
||||
It is also possible to create HTTP errors using the `Flask abort method <http://flask.pocoo.org/docs/1.0/api/#flask.abort>`_. This is useful particularly if your route handles multiple resources such as captures. In the case that a client tries to modify or get the state of a nonexistent resource, you can return (for example) ``abort(404)``, or some other `more relevant HTTP error code <https://en.wikipedia.org/wiki/List_of_HTTP_status_codes>`_.
|
||||
|
||||
An example web route with simple responses may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from flask import jsonify
|
||||
...
|
||||
|
||||
class MyPluginClass(MicroscopePlugin):
|
||||
|
||||
api_views = {
|
||||
'/myplugin/<object_id>': MyRouteAPI,
|
||||
}
|
||||
...
|
||||
|
||||
class MyRouteAPI(MicroscopeViewPlugin):
|
||||
|
||||
def get(self, object_id):
|
||||
|
||||
# If the requested object doesn't exist
|
||||
if not object_id in object_dictionary:
|
||||
# Abort with error 404. This prevents raising a nonspecific exception later
|
||||
abort(404)
|
||||
|
||||
# Retrieve some information, now we're sure the reqested object exists
|
||||
this_object = object_dictionary[object_id]
|
||||
|
||||
# Make a dictionary of the data to return
|
||||
data = {
|
||||
'id': this_object.id,
|
||||
'name': this_object.name
|
||||
'object_value', this_object.value
|
||||
}
|
||||
|
||||
# Return the JSON representation of our data dictionary
|
||||
return jsonify(data)
|
||||
...
|
||||
|
||||
Parsing JSON from HTTP POST requests
|
||||
------------------------------------
|
||||
To ease obtaining values from a JSON payload attached to an HTTP POST request, you can use the :py:class:`openflexure_microscope.api.utilities.JsonPayload` class. This allows parameters to be extracted by their key, with a default value supplied to avoid errors associated with nonexistent keys. Additionally, a converter function may be passed, used in the following examples to convert the type of a value. In principle, however, you can pass any single-argument function and it will apply that function to the value, if it exists.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
...
|
||||
class MyRouteAPI(MicroscopeViewPlugin):
|
||||
|
||||
def post(self):
|
||||
# Get payload JSON from request
|
||||
payload = JsonPayload(request)
|
||||
|
||||
# Try to find value associated with 'my_string' key.
|
||||
# If that key doesn't exist in the payload, return '' instead.
|
||||
# If a value does exist, convert it to a string, regardless of its original type.
|
||||
new_plugin_string = payload.param('my_string', default='', convert=str)
|
||||
...
|
||||
|
||||
.. autoclass:: openflexure_microscope.api.utilities.JsonPayload
|
||||
:members:
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Full JSON Schema
|
||||
================
|
||||
|
||||
.. literalinclude:: schema.json
|
||||
:language: JSON
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
{
|
||||
"definitions": {},
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "http://example.com/root.json",
|
||||
"type": "object",
|
||||
"title": "The Root Schema",
|
||||
"required": [
|
||||
"id",
|
||||
"icon",
|
||||
"forms"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$id": "#/properties/id",
|
||||
"type": "string",
|
||||
"title": "Unique ID",
|
||||
"description": "A unique name for the plugin UI",
|
||||
"default": "",
|
||||
"examples": [
|
||||
"test-plugin"
|
||||
],
|
||||
"pattern": "^(.*)$"
|
||||
},
|
||||
"icon": {
|
||||
"$id": "#/properties/icon",
|
||||
"type": "string",
|
||||
"title": "Icon name",
|
||||
"description": "Name of the Material Design icon to use for the plugin tab",
|
||||
"default": "",
|
||||
"examples": [
|
||||
"pets"
|
||||
],
|
||||
"pattern": "^(.*)$"
|
||||
},
|
||||
"forms": {
|
||||
"$id": "#/properties/forms",
|
||||
"type": "array",
|
||||
"title": "Forms array",
|
||||
"description": "Array of form descriptions to be displayed",
|
||||
"default": null,
|
||||
"items": {
|
||||
"$id": "#/properties/forms/items",
|
||||
"type": "object",
|
||||
"title": "The Items Schema",
|
||||
"required": [
|
||||
"name",
|
||||
"route",
|
||||
"schema"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"$id": "#/properties/forms/items/properties/name",
|
||||
"type": "string",
|
||||
"title": "Form name",
|
||||
"description": "Human-readable form name",
|
||||
"default": "",
|
||||
"examples": [
|
||||
"Simple request"
|
||||
],
|
||||
"pattern": "^(.*)$"
|
||||
},
|
||||
"isCollapsible": {
|
||||
"$id": "#/properties/forms/items/properties/isCollapsible",
|
||||
"type": "boolean",
|
||||
"title": "The isCollapsible Schema",
|
||||
"description": "Should the form be rendered as a collapsible item",
|
||||
"default": false,
|
||||
"examples": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"isTask": {
|
||||
"$id": "#/properties/forms/items/properties/isTask",
|
||||
"type": "boolean",
|
||||
"title": "The isTask Schema",
|
||||
"description": "Should the form's submit function be treated as a long-running task",
|
||||
"default": false,
|
||||
"examples": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"selfUpdate": {
|
||||
"$id": "#/properties/forms/items/properties/selfUpdate",
|
||||
"type": "boolean",
|
||||
"title": "The selfUpdate Schema",
|
||||
"description": "Should the form's component values be automatically updated with a GET request",
|
||||
"default": false,
|
||||
"examples": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"route": {
|
||||
"$id": "#/properties/forms/items/properties/route",
|
||||
"type": "string",
|
||||
"title": "The Route Schema",
|
||||
"description": "Form submit POST request's corresponding api_views route",
|
||||
"default": "",
|
||||
"examples": [
|
||||
"/do"
|
||||
],
|
||||
"pattern": "^(.*)$"
|
||||
},
|
||||
"submitLabel": {
|
||||
"$id": "#/properties/forms/items/properties/submitLabel",
|
||||
"type": "string",
|
||||
"title": "The submitLabel Schema",
|
||||
"description": "String to place in submit button",
|
||||
"default": "Submit",
|
||||
"examples": [
|
||||
"Do my function"
|
||||
],
|
||||
"pattern": "^(.*)$"
|
||||
},
|
||||
"schema": {
|
||||
"$id": "#/properties/forms/items/properties/schema",
|
||||
"type": "array",
|
||||
"title": "Form component array",
|
||||
"items": {
|
||||
"$id": "#/properties/forms/items/properties/schema/items",
|
||||
"type": "object",
|
||||
"title": "A form component",
|
||||
"required": [
|
||||
"fieldType",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"fieldType": {
|
||||
"$id": "#/properties/forms/items/properties/schema/items/properties/fieldType",
|
||||
"type": "string",
|
||||
"title": "Component type",
|
||||
"default": "",
|
||||
"examples": [
|
||||
"numberInput",
|
||||
"textInput",
|
||||
"radioList",
|
||||
"checkList",
|
||||
"htmlBlock",
|
||||
"selectList",
|
||||
"textInput",
|
||||
"keyvalList",
|
||||
"taglList"
|
||||
],
|
||||
"pattern": "^(.*)$"
|
||||
},
|
||||
"placeholder": {
|
||||
"$id": "#/properties/forms/items/properties/schema/items/properties/placeholder",
|
||||
"type": "string",
|
||||
"title": "Component placeholder value",
|
||||
"default": "",
|
||||
"examples": [
|
||||
"Enter your name"
|
||||
],
|
||||
"pattern": "^(.*)$"
|
||||
},
|
||||
"name": {
|
||||
"$id": "#/properties/forms/items/properties/schema/items/properties/name",
|
||||
"type": "string",
|
||||
"title": "Unique component name",
|
||||
"examples": [
|
||||
"my_form_component_1"
|
||||
],
|
||||
"pattern": "^(.*)$"
|
||||
},
|
||||
"label": {
|
||||
"$id": "#/properties/forms/items/properties/schema/items/properties/label",
|
||||
"type": "string",
|
||||
"title": "Component label (if applicable)",
|
||||
"default": "",
|
||||
"examples": [
|
||||
"My form component"
|
||||
],
|
||||
"pattern": "^(.*)$"
|
||||
},
|
||||
"value": {
|
||||
"$id": "#/properties/forms/items/properties/schema/items/properties/value",
|
||||
"type": ["array", "boolean", "integer", "number", "object", "string"],
|
||||
"title": "Component value",
|
||||
"pattern": "^(.*)$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
{
|
||||
"id": "test-plugin",
|
||||
"icon": "pets",
|
||||
"forms": [
|
||||
{
|
||||
"name": "Simple request",
|
||||
"isCollapsible": false,
|
||||
"isTask": false,
|
||||
"selfUpdate": true,
|
||||
"route": "/do",
|
||||
"submitLabel": "Do things",
|
||||
"schema": [
|
||||
{
|
||||
"fieldType": "numberInput",
|
||||
"placeholder": "Some integer",
|
||||
"name": "val_int",
|
||||
"label": "Number value"
|
||||
},
|
||||
{
|
||||
"fieldType": "textInput",
|
||||
"placeholder": "Some string",
|
||||
"label": "String value",
|
||||
"name": "val_str"
|
||||
},
|
||||
{
|
||||
"fieldType": "radioList",
|
||||
"name": "val_radio",
|
||||
"label": "Radio value",
|
||||
"options": ["First", "Second", "Third"]
|
||||
},
|
||||
{
|
||||
"fieldType": "checkList",
|
||||
"name": "val_check",
|
||||
"label": "Checklist values",
|
||||
"options": ["Foo", "Bar", "Baz"]
|
||||
},
|
||||
{
|
||||
"fieldType": "htmlBlock",
|
||||
"name": "html_block",
|
||||
"content": "<i>This is a block of HTML in a plugin!</i>"
|
||||
},
|
||||
{
|
||||
"fieldType": "selectList",
|
||||
"name": "val_select",
|
||||
"multi": false,
|
||||
"label": "Some selection",
|
||||
"options": ["Most", "Average", "Least"]
|
||||
},
|
||||
{
|
||||
"fieldType": "textInput",
|
||||
"placeholder": "Some string",
|
||||
"label": "Non-persistent string",
|
||||
"name": "val_disposable"
|
||||
},
|
||||
{
|
||||
"fieldType": "keyvalList",
|
||||
"label": "A key-val object",
|
||||
"name": "val_keyval",
|
||||
"value": {
|
||||
"key1": "foo",
|
||||
"key2": "bar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldType": "tagList",
|
||||
"label": "A tag list",
|
||||
"name": "val_tags",
|
||||
"value": ["tag2", "tag2", "squidward"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Task form",
|
||||
"isTask": true,
|
||||
"selfUpdate": true,
|
||||
"route": "/task",
|
||||
"submitLabel": "Start task",
|
||||
"schema": [
|
||||
{
|
||||
"fieldType": "numberInput",
|
||||
"name": "run_time",
|
||||
"label": "Run time (seconds)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
Basic plugin structure
|
||||
===========================
|
||||
|
||||
As described earlier, a plugin must subclass :py:class:`openflexure_microscope.plugins.MicroscopePlugin`. Each plugin in described by a single class, containing any number of methods. The methods defined within your plugin class will be attached to the microscope. By subclassing :py:class:`openflexure_microscope.plugins.MicroscopePlugin`, your methods automatically get access to the microscope object your plugin is attached to, through ``self.microscope``. This is an instance of :py:class:`openflexure_microscope.Microscope`, and gives unrestricted access to the attached camera and stage hardware.
|
||||
|
||||
For example, a simple plugin file named ``myplugin.py``, may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from openflexure_microscope.plugins import MicroscopePlugin
|
||||
|
||||
class MyPlugin(MicroscopePlugin):
|
||||
|
||||
def identify(self):
|
||||
"""
|
||||
Demonstrate access to Microscope.camera, and Microscope.stage
|
||||
"""
|
||||
|
||||
parent_camera = self.microscope.camera
|
||||
parent_stage = self.microscope.stage
|
||||
|
||||
response = "My parent camera is {}, and my parent stage is {}.".format(parent_camera,
|
||||
parent_stage)
|
||||
return response
|
||||
|
||||
def hello_world(self):
|
||||
"""
|
||||
Demonstrate passive method
|
||||
"""
|
||||
|
||||
return "Hello world!"
|
||||
|
||||
When this plugin is loaded and attached to a microscope object named ``microscope``, the plugin methods will be available at ``microscope.plugin.myplugin.identify()`` and ``microscope.plugin.myplugin.hello_world()``.
|
||||
|
|
@ -5,22 +5,15 @@ Install
|
|||
-------
|
||||
|
||||
Stable installation
|
||||
+++++++++++++++
|
||||
For most users, this is the reccommended installation method.
|
||||
+++++++++++++++++++
|
||||
For most users, the OpenFlexure Microscope software should be installed using our `pre-built Raspbian SD card image. <https://openflexure.org/projects/microscope/install>`_
|
||||
|
||||
- Run ``curl -LSs get.openflexure.org/microscope |sudo bash``
|
||||
- See the `GitLab repo <https://gitlab.com/openflexure/openflexure-microscope-cli>`_ for details.
|
||||
- Follow on-screen prompts
|
||||
|
||||
Developer and non-interactive installation
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
The installer script can pull the latest development package from our git repository, and use install into a developer environment using Poetry.
|
||||
Options also exist to run the installer without any user prompts.
|
||||
|
||||
See the `installer script GitLab repo <https://gitlab.com/openflexure/openflexure-microscope-cli>`_ for details.
|
||||
|
||||
Manual installation
|
||||
+++++++++++++++++++
|
||||
|
||||
For offline (i.e. no real microscope connected) development, a basic development server can run on any system.
|
||||
|
||||
- (Recommended) create and activate a virtual environment
|
||||
- Install non-python dependencies with ``sudo apt-get install libatlas-base-dev libjasper-dev libjpeg-dev``
|
||||
- Install `Poetry <https://github.com/sdispater/poetry>`_, clone this repo, and ``poetry install`` from inside the repo.
|
||||
|
|
@ -28,6 +21,6 @@ Manual installation
|
|||
Managing the server
|
||||
-------------------
|
||||
|
||||
Managing the server through the installer script's CLI is documented `on our website <https://openflexure.gitlab.io/projects/microscope/#managing-the-microscope-server>`_.
|
||||
Managing the server through the installer script's CLI is documented `on our website <https://openflexure.org/projects/microscope/install#managing-the-microscope-server>`_.
|
||||
|
||||
This includes starting the server as a background service, as well as starting a development server with real-time debug logging.
|
||||
5
docs/source/sangastage.rst
Normal file
5
docs/source/sangastage.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Sangaboard Microscope Stage
|
||||
===========================
|
||||
|
||||
.. automodule:: openflexure_microscope.stage.sanga
|
||||
:members:
|
||||
8
docs/source/stage.rst
Normal file
8
docs/source/stage.rst
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
Stage Class
|
||||
===========
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
sangastage.rst
|
||||
basestage.rst
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
__all__ = ['Microscope', 'config', 'task', 'lock', 'utilities']
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from .microscope import Microscope
|
||||
from . import config
|
||||
from . import utilities
|
||||
from . import task
|
||||
from . import lock
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
__all__ = ['utilities']
|
||||
|
||||
from . import utilities
|
||||
|
|
@ -1,207 +1,190 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
import sys
|
||||
import time
|
||||
import atexit
|
||||
import logging
|
||||
import sys
|
||||
import logging, logging.handlers
|
||||
|
||||
# Look for debug flag
|
||||
if "-d" in sys.argv or "--debug" in sys.argv:
|
||||
log_level = logging.DEBUG
|
||||
else:
|
||||
log_level = logging.INFO
|
||||
|
||||
|
||||
# Set root logger level
|
||||
root_log = logging.getLogger()
|
||||
root_log.setLevel(log_level)
|
||||
|
||||
|
||||
import os
|
||||
import pkg_resources
|
||||
|
||||
from flask import (
|
||||
Flask, jsonify, send_file)
|
||||
from flask import Flask, send_file, abort
|
||||
|
||||
from serial import SerialException
|
||||
from datetime import datetime
|
||||
|
||||
from flask_cors import CORS
|
||||
from flask_cors import CORS, cross_origin
|
||||
|
||||
from openflexure_microscope.api.exceptions import JSONExceptionHandler
|
||||
from openflexure_microscope.api.utilities import list_routes
|
||||
from openflexure_microscope.api.utilities import list_routes, init_default_extensions
|
||||
|
||||
from openflexure_microscope import Microscope
|
||||
from openflexure_microscope.config import JSONEncoder
|
||||
from openflexure_microscope.paths import (
|
||||
OPENFLEXURE_VAR_PATH,
|
||||
OPENFLEXURE_EXTENSIONS_PATH,
|
||||
settings_file_path,
|
||||
logs_file_path,
|
||||
)
|
||||
|
||||
from openflexure_microscope.camera.capture import build_captures_from_exif
|
||||
from openflexure_microscope.config import USER_CONFIG_DIR
|
||||
from openflexure_microscope.api.v1 import blueprints
|
||||
from labthings import create_app
|
||||
from labthings.extensions import find_extensions
|
||||
|
||||
# Import device modules
|
||||
# NB this will eventually be handled by the RC file, so you can choose what device
|
||||
# class should be attached.
|
||||
try:
|
||||
from openflexure_microscope.camera.pi import PiCameraStreamer
|
||||
except ImportError:
|
||||
logging.warning("Unable to import PiCameraStreamer")
|
||||
from openflexure_microscope.camera.mock import MockStreamer
|
||||
|
||||
from openflexure_microscope.stage.sanga import SangaStage, SangaDeltaStage
|
||||
from openflexure_microscope.stage.mock import MockStage
|
||||
from openflexure_microscope.api.microscope import default_microscope as api_microscope
|
||||
|
||||
from openflexure_microscope.api.v2 import views
|
||||
|
||||
# Handle logging
|
||||
is_gunicorn = "gunicorn" in os.environ.get("SERVER_SOFTWARE", "")
|
||||
access_log = logging.getLogger('werkzeug')
|
||||
# Block the access logs from propagating up to the root logger
|
||||
access_log.propagate = False
|
||||
|
||||
DEFAULT_LOGFILE = os.path.join(USER_CONFIG_DIR, 'openflexure_microscope.log')
|
||||
ROOT_LOGFILE = logs_file_path("openflexure_microscope.log")
|
||||
ACCESS_LOGFILE = logs_file_path("openflexure_microscope.access.log")
|
||||
|
||||
if (__name__ == "__main__") or (not is_gunicorn):
|
||||
# If imported, but not by gunicorn
|
||||
print("Letting sys handle logs")
|
||||
logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
|
||||
else:
|
||||
# Direct standard Python logging to file and console
|
||||
root = logging.getLogger()
|
||||
error_formatter = logging.Formatter("[%(asctime)s] [%(threadName)s] [%(levelname)s] %(message)s")
|
||||
|
||||
rotating_logfile = logging.handlers.RotatingFileHandler(
|
||||
DEFAULT_LOGFILE,
|
||||
maxBytes=1000000,
|
||||
backupCount=7
|
||||
)
|
||||
|
||||
error_handlers = [
|
||||
rotating_logfile,
|
||||
logging.StreamHandler()
|
||||
]
|
||||
|
||||
for handler in error_handlers:
|
||||
handler.setFormatter(error_formatter)
|
||||
root.addHandler(handler)
|
||||
|
||||
root.setLevel(logging.getLogger("gunicorn.error").level)
|
||||
|
||||
# Create a dummy microscope object, with no hardware attachments
|
||||
api_microscope = Microscope()
|
||||
|
||||
# Rebuild the capture list
|
||||
# TODO: Offload to a thread?
|
||||
stored_image_list = build_captures_from_exif()
|
||||
# Basic log format
|
||||
formatter = logging.Formatter(
|
||||
"[%(asctime)s] [%(threadName)s] [%(levelname)s] %(message)s"
|
||||
)
|
||||
|
||||
|
||||
# Generate API URI based on version from filename
|
||||
def uri(suffix, api_version, base=None):
|
||||
if not base:
|
||||
base = "/api/{}".format(api_version)
|
||||
return_uri = base + suffix
|
||||
logging.debug("Created app route: {}".format(return_uri))
|
||||
return return_uri
|
||||
# Create file handler
|
||||
fh = logging.handlers.RotatingFileHandler(
|
||||
ROOT_LOGFILE, maxBytes=1_000_000, backupCount=5
|
||||
)
|
||||
fh.setFormatter(formatter)
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fh.propagate = False
|
||||
|
||||
# Create access log file handler
|
||||
afh = logging.handlers.RotatingFileHandler(
|
||||
ACCESS_LOGFILE, maxBytes=1_000_000, backupCount=5
|
||||
)
|
||||
afh.setFormatter(formatter)
|
||||
afh.setLevel(logging.DEBUG)
|
||||
afh.propagate = False
|
||||
|
||||
# Add file handler to root logger
|
||||
root_log.addHandler(fh)
|
||||
access_log.addHandler(afh)
|
||||
|
||||
# Log server paths being used
|
||||
logging.info(f"Running with data path {OPENFLEXURE_VAR_PATH}")
|
||||
|
||||
logging.info("Creating app")
|
||||
# Create flask app
|
||||
app = Flask(__name__)
|
||||
app.url_map.strict_slashes = False
|
||||
app, labthing = create_app(
|
||||
__name__,
|
||||
prefix="/api/v2",
|
||||
title=f"OpenFlexure Microscope {api_microscope.name}",
|
||||
description="Test LabThing-based API for OpenFlexure Microscope",
|
||||
types=["org.openflexure.microscope"],
|
||||
version=pkg_resources.get_distribution("openflexure-microscope-server").version,
|
||||
flask_kwargs={"static_url_path": "", "static_folder": "static/dist"},
|
||||
)
|
||||
|
||||
CORS(app, resources=r'/api/*')
|
||||
# Enable CORS for some routes outside of LabThings
|
||||
cors = CORS(app)
|
||||
|
||||
# Make errors more API friendly
|
||||
handler = JSONExceptionHandler(app)
|
||||
# Use custom JSON encoder
|
||||
labthing.json_encoder = JSONEncoder
|
||||
app.json_encoder = JSONEncoder
|
||||
|
||||
# Attach lab devices
|
||||
labthing.add_component(api_microscope, "org.openflexure.microscope")
|
||||
|
||||
# Attach extensions
|
||||
if not os.path.isfile(OPENFLEXURE_EXTENSIONS_PATH):
|
||||
init_default_extensions(OPENFLEXURE_EXTENSIONS_PATH)
|
||||
for extension in find_extensions(OPENFLEXURE_EXTENSIONS_PATH):
|
||||
labthing.register_extension(extension)
|
||||
|
||||
# Attach captures resources
|
||||
labthing.add_view(views.CaptureList, f"/captures")
|
||||
labthing.add_root_link(views.CaptureList, "captures")
|
||||
|
||||
labthing.add_view(views.CaptureView, f"/captures/<id>")
|
||||
labthing.add_view(views.CaptureDownload, f"/captures/<id>/download/<filename>")
|
||||
labthing.add_view(views.CaptureTags, f"/captures/<id>/tags")
|
||||
labthing.add_view(views.CaptureAnnotations, f"/captures/<id>/annotations")
|
||||
|
||||
# Attach settings and state resources
|
||||
labthing.add_view(views.SettingsProperty, f"/instrument/settings")
|
||||
labthing.add_root_link(views.SettingsProperty, "instrumentSettings")
|
||||
labthing.add_view(views.NestedSettingsProperty, "/instrument/settings/<path:route>")
|
||||
labthing.add_view(views.StateProperty, "/instrument/state")
|
||||
labthing.add_view(views.NestedStateProperty, "/instrument/state/<path:route>")
|
||||
labthing.add_root_link(views.StateProperty, "instrumentState")
|
||||
labthing.add_view(views.ConfigurationProperty, "/instrument/configuration")
|
||||
labthing.add_view(
|
||||
views.NestedConfigurationProperty, "/instrument/configuration/<path:route>"
|
||||
)
|
||||
labthing.add_root_link(views.ConfigurationProperty, "instrumentConfiguration")
|
||||
|
||||
|
||||
# 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, stored_image_list
|
||||
logging.debug("First request made. Populating microscope with hardware...")
|
||||
# Attach streams resources
|
||||
labthing.add_view(views.MjpegStream, f"/streams/mjpeg")
|
||||
labthing.add_view(views.SnapshotStream, f"/streams/snapshot")
|
||||
|
||||
# Initialise camera
|
||||
logging.debug("Creating camera object...")
|
||||
try:
|
||||
api_camera = PiCameraStreamer()
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
logging.warning("No valid camera hardware found. Falling back to mock camera!")
|
||||
api_camera = MockStreamer()
|
||||
|
||||
# Initialise stage
|
||||
logging.debug("Creating stage object...")
|
||||
try:
|
||||
api_stage = SangaDeltaStage()
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
logging.warning("No valid stage hardware found. Falling back to mock stage!")
|
||||
api_stage = MockStage()
|
||||
|
||||
# Attach devices to microscope
|
||||
logging.debug("Attaching devices to microscope...")
|
||||
api_microscope.attach(
|
||||
api_camera,
|
||||
api_stage
|
||||
)
|
||||
|
||||
# Restore loaded capture array to camera object
|
||||
logging.debug("Restoring captures...")
|
||||
if stored_image_list:
|
||||
api_microscope.camera.images = stored_image_list
|
||||
|
||||
logging.debug("Microscope successfully attached!")
|
||||
# Attach microscope action resources
|
||||
labthing.add_view(views.actions.ActionsView, "/actions")
|
||||
labthing.add_root_link(views.actions.ActionsView, "actions")
|
||||
for name, action in views.enabled_root_actions().items():
|
||||
view_class = action["view_class"]
|
||||
rule = action["rule"]
|
||||
labthing.add_view(view_class, f"/actions{rule}")
|
||||
|
||||
|
||||
# WEBAPP ROUTES
|
||||
|
||||
# API ROUTES
|
||||
|
||||
# Base routes
|
||||
base_blueprint = blueprints.base.construct_blueprint(api_microscope)
|
||||
app.register_blueprint(base_blueprint, url_prefix=uri('', 'v1'))
|
||||
|
||||
# Stage routes
|
||||
stage_blueprint = blueprints.stage.construct_blueprint(api_microscope)
|
||||
app.register_blueprint(stage_blueprint, url_prefix=uri('/stage', 'v1'))
|
||||
|
||||
# Camera routes
|
||||
camera_blueprint = blueprints.camera.construct_blueprint(api_microscope)
|
||||
app.register_blueprint(camera_blueprint, url_prefix=uri('/camera', 'v1'))
|
||||
|
||||
# Plugin routes
|
||||
plugin_blueprint = blueprints.plugins.construct_blueprint(api_microscope)
|
||||
app.register_blueprint(plugin_blueprint, url_prefix=uri('/plugin', 'v1'))
|
||||
|
||||
# Task routes
|
||||
task_blueprint = blueprints.task.construct_blueprint(api_microscope)
|
||||
app.register_blueprint(task_blueprint, url_prefix=uri('/task', 'v1'))
|
||||
@app.route("/")
|
||||
def openflexure_ev():
|
||||
return app.send_static_file("index.html")
|
||||
|
||||
|
||||
@app.route('/routes')
|
||||
@app.route("/routes")
|
||||
@cross_origin()
|
||||
def routes():
|
||||
"""
|
||||
List of all connected API routes
|
||||
|
||||
.. :quickref: System; Routes
|
||||
|
||||
:>header Accept: application/json
|
||||
:>header Content-Type: application/json
|
||||
:status 200: stream active
|
||||
"""
|
||||
return jsonify(list_routes(app))
|
||||
return list_routes(app)
|
||||
|
||||
|
||||
@app.route('/log')
|
||||
@app.route("/log")
|
||||
def err_log():
|
||||
"""
|
||||
Most recent 1mb of log output
|
||||
|
||||
.. :quickref: System; Log
|
||||
|
||||
:>header Accept: application/json
|
||||
:>header Content-Type: application/json
|
||||
:status 200: stream active
|
||||
"""
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
return send_file(
|
||||
DEFAULT_LOGFILE,
|
||||
ROOT_LOGFILE,
|
||||
as_attachment=True,
|
||||
attachment_filename='openflexure_microscope_{}.log'.format(timestamp)
|
||||
attachment_filename="openflexure_microscope_{}.log".format(timestamp),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/v1/", defaults={"path": ""})
|
||||
@app.route("/api/v1/<path:path>")
|
||||
def api_v1_catch_all(path):
|
||||
abort(410, "API v1 is no longer in use. Please upgrade your client.")
|
||||
|
||||
|
||||
# Automatically clean up microscope at exit
|
||||
def cleanup():
|
||||
global api_microscope
|
||||
logging.debug("App teardown started...")
|
||||
logging.debug("Settling...")
|
||||
time.sleep(0.5)
|
||||
|
||||
# Save config
|
||||
logging.debug("Saving config for teardown...")
|
||||
api_microscope.save_config(backup=True)
|
||||
api_microscope.save_settings()
|
||||
|
||||
logging.debug("Settling...")
|
||||
time.sleep(0.5)
|
||||
|
|
@ -218,5 +201,9 @@ def cleanup():
|
|||
|
||||
atexit.register(cleanup)
|
||||
|
||||
# Start the app
|
||||
if __name__ == "__main__":
|
||||
app.run(host='0.0.0.0', port="5000", threaded=True, debug=True, use_reloader=False)
|
||||
from labthings import Server
|
||||
logging.info("Starting OpenFlexure Microscope Server...")
|
||||
server = Server(app)
|
||||
server.run(host="::", port=5000, debug=False, zeroconf=True)
|
||||
|
|
|
|||
28
openflexure_microscope/api/default_extensions/__init__.py
Normal file
28
openflexure_microscope/api/default_extensions/__init__.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import logging
|
||||
import traceback
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
@contextmanager
|
||||
def handle_extension_error(extension_name):
|
||||
"""'gracefully' log an error if an extension fails to load."""
|
||||
try:
|
||||
yield
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Exception loading builtin extension {extension_name}: \n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
|
||||
with handle_extension_error("autofocus"):
|
||||
from .autofocus import autofocus_extension_v2
|
||||
with handle_extension_error("scan"):
|
||||
from .scan import scan_extension_v2
|
||||
with handle_extension_error("zip builder"):
|
||||
from .zip_builder import zip_extension_v2
|
||||
with handle_extension_error("autostorage"):
|
||||
from .autostorage import autostorage_extension_v2
|
||||
with handle_extension_error("camera stage mapping"):
|
||||
from camera_stage_mapping.ofm_extension import csm_extension
|
||||
with handle_extension_error("lens shading calibration"):
|
||||
from .picamera_autocalibrate import lst_extension_v2
|
||||
409
openflexure_microscope/api/default_extensions/autofocus.py
Normal file
409
openflexure_microscope/api/default_extensions/autofocus.py
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
from labthings import fields, find_component, current_action
|
||||
from labthings.extensions import BaseExtension
|
||||
from labthings.views import View, ActionView, PropertyView
|
||||
|
||||
from openflexure_microscope.devel import JsonResponse, request, abort
|
||||
from openflexure_microscope.utilities import set_properties
|
||||
|
||||
import time
|
||||
import numpy as np
|
||||
|
||||
import logging
|
||||
from scipy import ndimage
|
||||
from contextlib import contextmanager
|
||||
|
||||
from threading import Thread, Event
|
||||
|
||||
### Autofocus utilities
|
||||
|
||||
|
||||
class JPEGSharpnessMonitor:
|
||||
def __init__(self, microscope, timeout=60):
|
||||
self.microscope = microscope
|
||||
self.camera = microscope.camera
|
||||
self.stage = microscope.stage
|
||||
self.jpeg_sizes = []
|
||||
self.jpeg_times = []
|
||||
self.stage_positions = []
|
||||
self.stage_times = []
|
||||
self.stop_event = Event()
|
||||
self.timeout = timeout
|
||||
self.keep_alive()
|
||||
self.background_thread = None
|
||||
|
||||
def is_alive(self):
|
||||
if self.background_thread is None:
|
||||
return False
|
||||
else:
|
||||
return self.background_thread.is_alive()
|
||||
|
||||
def should_stop(self):
|
||||
import time
|
||||
|
||||
return time.time() - self.kept_alive > self.timeout
|
||||
|
||||
def keep_alive(self):
|
||||
import time
|
||||
|
||||
self.kept_alive = time.time()
|
||||
|
||||
def start(self):
|
||||
"Start monitoring sharpness by looking at JPEG size"
|
||||
if not self.camera.stream_active:
|
||||
logging.warn(
|
||||
"Autofocus sharpness monitor was started but the camera isn't streaming. Attempting to start the stream..."
|
||||
)
|
||||
self.camera.start_stream_recording()
|
||||
self.background_thread = Thread(target=self._measure_jpegs)
|
||||
self.background_thread.start()
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
"Stop the background thread"
|
||||
self.stop_event.set()
|
||||
logging.info("Joining JPEG thread")
|
||||
self.background_thread.join()
|
||||
|
||||
def _measure_jpegs(self):
|
||||
"Function that runs in a background thread to record sharpness"
|
||||
logging.debug("Starting sharpness measurement in background thread")
|
||||
self.keep_alive()
|
||||
logging.debug(f"_measure_jpegs stop_event: {self.stop_event.is_set()}")
|
||||
while not self.stop_event.is_set() and not self.should_stop():
|
||||
size_now = self.jpeg_size()
|
||||
time_now = time.time()
|
||||
self.jpeg_sizes.append(size_now)
|
||||
self.jpeg_times.append(time_now)
|
||||
logging.info("Exited JPEG measure loop")
|
||||
if self.stop_event.is_set():
|
||||
logging.debug("Cleanly stopped sharpness measurement in background thread")
|
||||
if self.should_stop():
|
||||
logging.debug("Sharpness measurement timed out and has stopped")
|
||||
|
||||
def jpeg_size(self):
|
||||
"""Return the size of a frame from the MJPEG stream"""
|
||||
return len(self.camera.get_frame())
|
||||
|
||||
def focus_rel(self, dz, backlash=False, **kwargs):
|
||||
self.keep_alive()
|
||||
self.stage_times.append(time.time())
|
||||
self.stage_positions.append(self.stage.position)
|
||||
self.stage.move_rel([0, 0, dz], backlash=backlash, **kwargs)
|
||||
self.stage_times.append(time.time())
|
||||
self.stage_positions.append(self.stage.position)
|
||||
i = len(self.stage_positions) - 2
|
||||
return i, self.stage_positions[-1][2]
|
||||
|
||||
def move_data(self, istart, istop=None):
|
||||
"Extract sharpness as a function of (interpolated) z"
|
||||
if istop is None:
|
||||
istop = istart + 2
|
||||
jpeg_times = np.array(self.jpeg_times)
|
||||
jpeg_sizes = np.array(self.jpeg_sizes)
|
||||
stage_times = np.array(self.stage_times)[istart:istop]
|
||||
stage_zs = np.array(self.stage_positions)[istart:istop, 2]
|
||||
try:
|
||||
start = np.argmax(jpeg_times > stage_times[0])
|
||||
stop = np.argmax(jpeg_times > stage_times[1])
|
||||
except ValueError as e:
|
||||
if np.sum(jpeg_times > stage_times[0]) == 0:
|
||||
raise ValueError(
|
||||
"No images were captured during the move of the stage. Perhaps the camera is not streaming images?"
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
if stop < 1:
|
||||
stop = len(jpeg_times)
|
||||
logging.debug("changing stop to {}".format(stop))
|
||||
jpeg_times = jpeg_times[start:stop]
|
||||
jpeg_zs = np.interp(jpeg_times, stage_times, stage_zs)
|
||||
return jpeg_times, jpeg_zs, jpeg_sizes[start:stop]
|
||||
|
||||
def sharpest_z_on_move(self, index):
|
||||
"""Return the z position of the sharpest image on a given move"""
|
||||
jt, jz, js = self.move_data(index)
|
||||
if len(js) == 0:
|
||||
raise ValueError(
|
||||
"No images were captured during the move of the stage. Perhaps the camera is not streaming images?"
|
||||
)
|
||||
return jz[np.argmax(js)]
|
||||
|
||||
def data_dict(self):
|
||||
"""Return the gathered data as a single convenient dictionary"""
|
||||
data = {}
|
||||
for k in ["jpeg_times", "jpeg_sizes", "stage_times", "stage_positions"]:
|
||||
data[k] = getattr(self, k)
|
||||
return data
|
||||
|
||||
|
||||
def decimate_to(shape, image):
|
||||
"""Decimate an image to reduce its size if it's too big."""
|
||||
decimation = np.max(
|
||||
np.ceil(np.array(image.shape, dtype=np.float)[: len(shape)] / np.array(shape))
|
||||
)
|
||||
return image[:: int(decimation), :: int(decimation), ...]
|
||||
|
||||
|
||||
def sharpness_sum_lap2(rgb_image):
|
||||
"""Return an image sharpness metric: sum(laplacian(image)**")"""
|
||||
# image_bw=np.mean(decimate_to((1000,1000), rgb_image),2)
|
||||
image_bw = np.mean(rgb_image, 2)
|
||||
image_lap = ndimage.filters.laplace(image_bw)
|
||||
return np.mean(image_lap.astype(np.float) ** 4)
|
||||
|
||||
|
||||
def sharpness_edge(image):
|
||||
"""Return a sharpness metric optimised for vertical lines"""
|
||||
gray = np.mean(image.astype(float), 2)
|
||||
n = 20
|
||||
edge = np.array([[-1] * n + [1] * n])
|
||||
return np.sum(
|
||||
[np.sum(ndimage.filters.convolve(gray, W) ** 2) for W in [edge, edge.T]]
|
||||
)
|
||||
|
||||
|
||||
### Autofocus extension
|
||||
|
||||
|
||||
def measure_sharpness(microscope, metric_fn=sharpness_sum_lap2):
|
||||
"""Measure the sharpness of the camera's current view."""
|
||||
if hasattr(microscope.camera, "array"):
|
||||
return metric_fn(microscope.camera.array(use_video_port=True))
|
||||
|
||||
|
||||
def autofocus(microscope, dz, settle=0.5, metric_fn=sharpness_sum_lap2):
|
||||
"""Perform a simple autofocus routine.
|
||||
The stage is moved to z positions (relative to current position) in dz,
|
||||
and at each position an image is captured and the sharpness function
|
||||
evaulated. We then move back to the position where the sharpness was
|
||||
highest. No interpolation is performed.
|
||||
dz is assumed to be in ascending order (starting at -ve values)
|
||||
"""
|
||||
camera = microscope.camera
|
||||
stage = microscope.stage
|
||||
|
||||
with set_properties(stage, backlash=256), stage.lock, camera.lock:
|
||||
sharpnesses = []
|
||||
positions = []
|
||||
camera.annotate_text = ""
|
||||
|
||||
for _ in stage.scan_z(dz, return_to_start=False):
|
||||
if current_action() and current_action().stopped:
|
||||
return
|
||||
positions.append(stage.position[2])
|
||||
time.sleep(settle)
|
||||
sharpnesses.append(measure_sharpness(microscope, metric_fn))
|
||||
|
||||
newposition = positions[np.argmax(sharpnesses)]
|
||||
stage.move_rel([0, 0, newposition - stage.position[2]])
|
||||
|
||||
return positions, sharpnesses
|
||||
|
||||
|
||||
@contextmanager
|
||||
def monitor_sharpness(microscope):
|
||||
m = JPEGSharpnessMonitor(microscope)
|
||||
m.start()
|
||||
try:
|
||||
yield m
|
||||
finally:
|
||||
m.stop()
|
||||
|
||||
|
||||
def move_and_find_focus(microscope, dz):
|
||||
"""Make a relative Z move and return the peak sharpness position"""
|
||||
with monitor_sharpness(microscope) as m:
|
||||
m.focus_rel(dz)
|
||||
return m.sharpest_z_on_move(0)
|
||||
|
||||
|
||||
def fast_autofocus(microscope, dz=2000, backlash=None):
|
||||
"""Perform a down-up-down-up autofocus"""
|
||||
with monitor_sharpness(
|
||||
microscope
|
||||
) as m, microscope.camera.lock, microscope.stage.lock:
|
||||
i, z = m.focus_rel(-dz / 2)
|
||||
i, z = m.focus_rel(dz)
|
||||
fz = m.sharpest_z_on_move(i)
|
||||
if backlash is None:
|
||||
i, z = m.focus_rel(-dz) # move all the way to the start so it's consistent
|
||||
else:
|
||||
i, z = m.focus_rel(fz - z - backlash)
|
||||
m.focus_rel(fz - z)
|
||||
return m.data_dict()
|
||||
|
||||
|
||||
def fast_up_down_up_autofocus(
|
||||
microscope, dz=2000, target_z=0, initial_move_up=True, mini_backlash=25
|
||||
):
|
||||
"""Autofocus by measuring on the way down, and moving back up with feedback.
|
||||
|
||||
This autofocus method is very efficient, as it only passes the peak once.
|
||||
The sequence of moves it performs is:
|
||||
|
||||
1. Move to the top of the range `dz/2` (can be disabled)
|
||||
|
||||
2. Move down by `dz` while monitoring JPEG size to find the focus.
|
||||
|
||||
3. Move back up to the `target_z` position, relative to the sharpest image.
|
||||
|
||||
4. Measure the sharpness, and compare against the curve recorded in (2) to \\
|
||||
estimate how much further we need to go. Make this move, to reach our \\
|
||||
target position.
|
||||
|
||||
Moving back to the target position in two steps allows us to correct for
|
||||
backlash, by using the sharpness-vs-z curve as a rough encoder for Z.
|
||||
|
||||
Parameters:
|
||||
dz: number of steps over which to scan (optional, default 2000)
|
||||
|
||||
target_z: we aim to finish at this position, relative to focus. This may
|
||||
be useful if, for example, you want to acquire a stack of images in Z.
|
||||
It is optional, and the default value of 0 will finish at the focus.
|
||||
|
||||
initial_move_up: (optional, default True) set this to `False` to move down
|
||||
from the starting position. Mostly useful if you're able to combine
|
||||
the initial move with something else, e.g. moving to the next scan point.
|
||||
|
||||
mini_backlash: (optional, default 25) is a small extra move made in step
|
||||
3 to help counteract backlash. It should be small enough that you
|
||||
would always expect there to be greater backlash than this. Too small
|
||||
might slightly hurt accuracy, but is unlikely to be a big issue. Too big
|
||||
may cause you to overshoot, which is a problem.
|
||||
"""
|
||||
with monitor_sharpness(
|
||||
microscope
|
||||
) as m, microscope.camera.lock, microscope.stage.lock:
|
||||
# Ensure the MJPEG stream has started
|
||||
microscope.camera.start_stream_recording()
|
||||
|
||||
df = dz # TODO: refactor so I actually use dz in the code below!
|
||||
logging.debug("Initial move")
|
||||
if initial_move_up:
|
||||
m.focus_rel(df / 2)
|
||||
# move down
|
||||
logging.debug("Move down")
|
||||
i, z = m.focus_rel(-df)
|
||||
# now inspect where the sharpest point is, and estimate the sharpness
|
||||
# (JPEG size) that we should find at the start of the Z stack
|
||||
logging.debug("Get target_s")
|
||||
jt, jz, js = m.move_data(i)
|
||||
best_z = jz[np.argmax(js)]
|
||||
target_s = np.interp(
|
||||
[best_z + target_z], jz[::-1], js[::-1]
|
||||
) # NB jz is decreasing
|
||||
|
||||
# now move to the start of the z stack
|
||||
logging.debug("Move to the start of the z stack")
|
||||
i, z = m.focus_rel(
|
||||
best_z + target_z - z + mini_backlash
|
||||
) # takes us to the start of the stack
|
||||
|
||||
# We've deliberately undershot - figure out how much further we should move based on the curve
|
||||
logging.debug("Calculate remining movement")
|
||||
current_js = m.jpeg_size()
|
||||
imax = np.argmax(js) # we want to crop out just the bit below the peak
|
||||
js = js[imax:] # NB z is in DECREASING order
|
||||
jz = jz[imax:]
|
||||
inow = np.argmax(
|
||||
js < current_js
|
||||
) # use the curve we recorded to estimate our position
|
||||
# TODO: fancy interpolation stuff
|
||||
|
||||
# So, the Z position corresponding to our current sharpness value is zs[inow]
|
||||
# That means we should move forwards, by best_z - zs[inow]
|
||||
logging.debug("Correction move")
|
||||
correction_move = best_z + target_z - jz[inow]
|
||||
logging.debug(
|
||||
"Fast autofocus scan: correcting backlash by moving {} steps".format(
|
||||
correction_move
|
||||
)
|
||||
)
|
||||
m.focus_rel(correction_move)
|
||||
return m.data_dict()
|
||||
|
||||
|
||||
class MeasureSharpnessAPI(View):
|
||||
def post(self):
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
if not microscope:
|
||||
abort(503, "No microscope connected. Unable to measure sharpness.")
|
||||
|
||||
return {"sharpness": measure_sharpness(microscope)}
|
||||
|
||||
|
||||
class AutofocusAPI(ActionView):
|
||||
"""
|
||||
Run a standard autofocus
|
||||
"""
|
||||
args = {"dz": fields.List(fields.Int())}
|
||||
|
||||
def post(self, args):
|
||||
payload = JsonResponse(request)
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
if not microscope:
|
||||
abort(503, "No microscope connected. Unable to autofocus.")
|
||||
|
||||
# Figure out the range of z values to use
|
||||
dz = np.array(args.get("dz", np.linspace(-300, 300, 7)))
|
||||
|
||||
if microscope.has_real_stage():
|
||||
logging.debug("Running autofocus...")
|
||||
|
||||
# return a handle on the autofocus task
|
||||
return autofocus(microscope, dz)
|
||||
|
||||
else:
|
||||
abort(503, "No stage connected. Unable to autofocus.")
|
||||
|
||||
|
||||
class FastAutofocusAPI(ActionView):
|
||||
"""
|
||||
Run a fast autofocus
|
||||
"""
|
||||
args = {
|
||||
"dz": fields.Int(missing=2000),
|
||||
"backlash": fields.Int(missing=25, minimum=0)
|
||||
}
|
||||
|
||||
def post(self, args):
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
if not microscope:
|
||||
abort(503, "No microscope connected. Unable to autofocus.")
|
||||
|
||||
# Figure out the parameters to use
|
||||
dz = args.get("dz")
|
||||
backlash = args.get("backlash")
|
||||
|
||||
if microscope.has_real_stage():
|
||||
logging.debug("Running autofocus...")
|
||||
|
||||
# Acquire microscope lock with 1s timeout
|
||||
with microscope.lock(timeout=1):
|
||||
# Run fast_up_down_up_autofocus
|
||||
return fast_up_down_up_autofocus(
|
||||
microscope, dz=dz, mini_backlash=backlash
|
||||
)
|
||||
|
||||
else:
|
||||
abort(503, "No stage connected. Unable to autofocus.")
|
||||
|
||||
|
||||
autofocus_extension_v2 = BaseExtension(
|
||||
"org.openflexure.autofocus",
|
||||
version="2.0.0",
|
||||
description="Actions to move the microscope in Z and pick the point with the sharpest image.",
|
||||
)
|
||||
|
||||
autofocus_extension_v2.add_method(fast_autofocus, "fast_autofocus")
|
||||
autofocus_extension_v2.add_method(
|
||||
fast_up_down_up_autofocus, "fast_up_down_up_autofocus"
|
||||
)
|
||||
autofocus_extension_v2.add_method(autofocus, "autofocus")
|
||||
|
||||
autofocus_extension_v2.add_view(MeasureSharpnessAPI, "/measure_sharpness", endpoint="measure_sharpness")
|
||||
autofocus_extension_v2.add_view(AutofocusAPI, "/autofocus", endpoint="autofocus")
|
||||
autofocus_extension_v2.add_view(FastAutofocusAPI, "/fast_autofocus", endpoint="fast_autofocus")
|
||||
272
openflexure_microscope/api/default_extensions/autostorage.py
Normal file
272
openflexure_microscope/api/default_extensions/autostorage.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
from labthings.extensions import BaseExtension
|
||||
from labthings.views import View, PropertyView
|
||||
from labthings import fields, find_component
|
||||
|
||||
from openflexure_microscope.paths import settings_file_path, check_rw
|
||||
from openflexure_microscope.config import OpenflexureSettingsFile
|
||||
from openflexure_microscope.captures.capture_manager import BASE_CAPTURE_PATH
|
||||
from openflexure_microscope.captures.capture import build_captures_from_exif
|
||||
|
||||
from openflexure_microscope.api.utilities.gui import build_gui
|
||||
|
||||
from flask import abort
|
||||
import logging
|
||||
import os
|
||||
import psutil
|
||||
|
||||
from sys import platform
|
||||
|
||||
|
||||
AS_SETTINGS_PATH = settings_file_path("autostorage_settings.json")
|
||||
|
||||
|
||||
def get_partitions():
|
||||
return [disk.mountpoint for disk in psutil.disk_partitions() if "rw" in disk.opts]
|
||||
|
||||
|
||||
def get_permissive_partitions():
|
||||
return [partition for partition in get_partitions() if check_rw(partition)]
|
||||
|
||||
|
||||
def get_permissive_locations():
|
||||
return [
|
||||
(partition, os.path.join(partition, "openflexure", "data", "micrographs"))
|
||||
for partition in get_permissive_partitions()
|
||||
]
|
||||
|
||||
|
||||
def get_current_location(capture_manager):
|
||||
return capture_manager.paths.get("default")
|
||||
|
||||
|
||||
def set_current_location(capture_manager, location: str):
|
||||
if not os.path.isdir(location):
|
||||
os.makedirs(location)
|
||||
logging.debug("Updating location...")
|
||||
capture_manager.paths.update({"default": location})
|
||||
logging.debug("Rebuilding captures...")
|
||||
capture_manager.rebuild_captures()
|
||||
logging.debug("Capture location changed successfully.")
|
||||
|
||||
|
||||
def get_default_location():
|
||||
return BASE_CAPTURE_PATH
|
||||
|
||||
|
||||
def get_all_locations():
|
||||
locations = {}
|
||||
# If default is not already listed (e.g. if it's currently set)
|
||||
if get_default_location() not in locations.values():
|
||||
locations["Default"] = get_default_location()
|
||||
|
||||
for ppartition, plocation in get_permissive_locations():
|
||||
pdrive = os.path.splitdrive(plocation)[0]
|
||||
if not (
|
||||
pdrive # If path actually has a drive (basically just Windows?)
|
||||
and any( # And shares a common drive with an existing location
|
||||
[
|
||||
pdrive == os.path.splitdrive(location)[0]
|
||||
for location in locations.values()
|
||||
]
|
||||
)
|
||||
):
|
||||
locations[ppartition] = plocation
|
||||
|
||||
# Strip out Nones
|
||||
return {k: v for k, v in locations.items() if v}
|
||||
|
||||
|
||||
class CaptureStorageLocation:
|
||||
def __init__(self, mountpoint):
|
||||
pass
|
||||
|
||||
|
||||
class AutostorageExtension(BaseExtension):
|
||||
def __init__(self):
|
||||
BaseExtension.__init__(
|
||||
self,
|
||||
"org.openflexure.autostorage",
|
||||
version="2.0.0",
|
||||
description="Handle switching capture storage devices",
|
||||
)
|
||||
|
||||
# We'll store a reference to a CaptureManager object, who's capture paths will be modified
|
||||
self.capture_manager = None
|
||||
|
||||
self.initial_location = get_default_location()
|
||||
|
||||
# Register the on_microscope function to run when the microscope is attached
|
||||
self.on_component("org.openflexure.microscope", self.on_microscope)
|
||||
|
||||
def on_microscope(self, microscope_obj):
|
||||
"""Function to automatically call when the parent LabThing has a microscope attached."""
|
||||
logging.debug(f"Autostorage extension found microscope {microscope_obj}")
|
||||
if hasattr(microscope_obj, "captures"):
|
||||
logging.debug(
|
||||
f"Autostorage extension bound to CaptureManager {self.capture_manager}"
|
||||
)
|
||||
|
||||
# Store a reference to the CaptureManager
|
||||
self.capture_manager = microscope_obj.captures
|
||||
# Store the initial storage location
|
||||
self.initial_location = get_current_location(self.capture_manager)
|
||||
|
||||
# If preferred path does not exist, or cannot be written to
|
||||
self.check_location(self.initial_location)
|
||||
|
||||
logging.debug(self.get_locations())
|
||||
|
||||
def check_location(self, location=None):
|
||||
if not location:
|
||||
location = get_current_location(self.capture_manager)
|
||||
# If preferred path does not exist, or cannot be written to
|
||||
if not (os.path.isdir(location) and check_rw(location)):
|
||||
logging.error(
|
||||
f"Preferred capture path {location} is missing or cannot be written to. Restoring defaults."
|
||||
)
|
||||
# Reset the storage location to default
|
||||
set_current_location(self.capture_manager, get_default_location())
|
||||
|
||||
def get_locations(self):
|
||||
if self.capture_manager:
|
||||
locations = get_all_locations()
|
||||
|
||||
current_location = get_current_location(self.capture_manager)
|
||||
if current_location not in locations.values():
|
||||
locations.update({"Custom": current_location})
|
||||
# Add location from the CaptureManager settings file
|
||||
return locations
|
||||
else:
|
||||
return {}
|
||||
|
||||
def get_preferred_key(self):
|
||||
current = get_current_location(self.capture_manager)
|
||||
locations = self.get_locations()
|
||||
|
||||
matches = [k for k, v in locations.items() if v == current]
|
||||
|
||||
if len(matches) > 1:
|
||||
logging.warning(
|
||||
"Multiple path matches found. Weird, but carrying on using zeroth."
|
||||
)
|
||||
|
||||
return matches[0]
|
||||
|
||||
def set_preferred_key(self, new_path_key: str):
|
||||
if not new_path_key in self.get_locations().keys():
|
||||
raise KeyError(f"No location named {new_path_key}")
|
||||
|
||||
location = self.get_locations().get(new_path_key)
|
||||
set_current_location(self.capture_manager, location)
|
||||
|
||||
def key_to_title(self, path_key: str):
|
||||
if not path_key in self.get_locations().keys():
|
||||
raise KeyError(f"No location named {path_key}")
|
||||
|
||||
return f"{path_key} ({self.get_locations().get(path_key)})"
|
||||
|
||||
def title_to_key(self, path_title: str):
|
||||
matches = []
|
||||
for loc_key in self.get_locations().keys():
|
||||
if path_title.startswith(loc_key):
|
||||
matches.append(loc_key)
|
||||
|
||||
if len(matches) > 1:
|
||||
logging.warning(
|
||||
"Multiple path matches found. Weird, but carrying on using zeroth."
|
||||
)
|
||||
|
||||
return matches[0]
|
||||
|
||||
def get_titles(self):
|
||||
return [self.key_to_title(key) for key in self.get_locations().keys()]
|
||||
|
||||
def get_preferred_title(self):
|
||||
return self.key_to_title(self.get_preferred_key())
|
||||
|
||||
|
||||
autostorage_extension_v2 = AutostorageExtension()
|
||||
|
||||
|
||||
class GetLocationsView(PropertyView):
|
||||
def get(self):
|
||||
global autostorage_extension_v2
|
||||
|
||||
autostorage_extension_v2.check_location()
|
||||
return autostorage_extension_v2.get_locations()
|
||||
|
||||
|
||||
class PreferredLocationView(PropertyView):
|
||||
schema = fields.String(required=True, example="Default")
|
||||
|
||||
def get(self):
|
||||
global autostorage_extension_v2
|
||||
|
||||
autostorage_extension_v2.check_location()
|
||||
return autostorage_extension_v2.get_preferred_key()
|
||||
|
||||
def post(self, new_path_key):
|
||||
global autostorage_extension_v2
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
if not microscope:
|
||||
abort(503, "No microscope connected. Unable to autofocus.")
|
||||
|
||||
autostorage_extension_v2.check_location()
|
||||
autostorage_extension_v2.set_preferred_key(new_path_key)
|
||||
microscope.save_settings()
|
||||
|
||||
|
||||
class PreferredLocationGUIView(View):
|
||||
args = {"new_path_title": fields.String(required=True)}
|
||||
|
||||
def post(self, args):
|
||||
global autostorage_extension_v2
|
||||
|
||||
new_path_title = args.get("new_path_title")
|
||||
logging.debug(new_path_title)
|
||||
|
||||
new_path_key = autostorage_extension_v2.title_to_key(new_path_title)
|
||||
logging.debug(f"{new_path_key}")
|
||||
|
||||
autostorage_extension_v2.check_location()
|
||||
autostorage_extension_v2.set_preferred_key(new_path_key)
|
||||
|
||||
return new_path_title
|
||||
|
||||
|
||||
def dynamic_form():
|
||||
global autostorage_extension_v2
|
||||
autostorage_extension_v2.check_location()
|
||||
return {
|
||||
"icon": "sd_storage",
|
||||
"title": "Storage",
|
||||
"viewPanel": "gallery",
|
||||
"forms": [
|
||||
{
|
||||
"name": "Autostorage",
|
||||
"isCollapsible": False,
|
||||
"isTask": False,
|
||||
"route": "/location-from-title",
|
||||
"emitOnResponse": "globalUpdateCaptures",
|
||||
"submitLabel": "Set path",
|
||||
"schema": [
|
||||
{
|
||||
"fieldType": "selectList",
|
||||
"name": "new_path_title",
|
||||
"label": "Capture storage path",
|
||||
"options": autostorage_extension_v2.get_titles(),
|
||||
"value": autostorage_extension_v2.get_preferred_title(),
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
autostorage_extension_v2.add_view(GetLocationsView, "/list-locations")
|
||||
autostorage_extension_v2.add_view(PreferredLocationView, "/location")
|
||||
autostorage_extension_v2.add_view(PreferredLocationGUIView, "/location-from-title")
|
||||
autostorage_extension_v2.add_meta(
|
||||
"gui", build_gui(dynamic_form, autostorage_extension_v2)
|
||||
)
|
||||
|
|
@ -0,0 +1 @@
|
|||
from .extension import lst_extension_v2
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
from labthings import find_component
|
||||
from labthings.views import View, ActionView
|
||||
from labthings.extensions import BaseExtension
|
||||
|
||||
from flask import abort
|
||||
|
||||
from contextlib import contextmanager
|
||||
import logging
|
||||
|
||||
# Type hinting
|
||||
from typing import Tuple
|
||||
|
||||
from .recalibrate_utils import (
|
||||
recalibrate_camera,
|
||||
auto_expose_and_freeze_settings,
|
||||
flat_lens_shading_table,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def pause_stream(scamera, resolution: Tuple[int, int] = None):
|
||||
"""This context manager locks a streaming camera, and pauses the stream.
|
||||
|
||||
The stream is re-enabled, with the original resolution, once the with
|
||||
block has finished.
|
||||
"""
|
||||
with scamera.lock:
|
||||
assert (
|
||||
not scamera.record_active
|
||||
), "We can't pause the camera's video stream while a recording is in progress."
|
||||
streaming = scamera.stream_active
|
||||
old_resolution = scamera.camera.resolution
|
||||
if streaming:
|
||||
logging.info("Stopping stream in pause_stream context manager")
|
||||
scamera.stop_stream_recording(resolution=resolution)
|
||||
try:
|
||||
yield scamera
|
||||
finally:
|
||||
scamera.camera.resolution = old_resolution
|
||||
if streaming:
|
||||
logging.info("Restarting stream in pause_stream context manager")
|
||||
scamera.start_stream_recording()
|
||||
|
||||
|
||||
def recalibrate(microscope):
|
||||
"""Reset the camera's settings.
|
||||
|
||||
This generates new gains, exposure time, and lens shading
|
||||
table such that the background is as uniform as possible
|
||||
with a gray level of 230. It takes a little while to run.
|
||||
"""
|
||||
with pause_stream(microscope.camera) as scamera:
|
||||
auto_expose_and_freeze_settings(
|
||||
scamera.camera
|
||||
) # scamera.camera is the PiCamera object
|
||||
recalibrate_camera(scamera.camera)
|
||||
microscope.save_settings()
|
||||
|
||||
|
||||
class RecalibrateView(ActionView):
|
||||
def post(self):
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
if not microscope:
|
||||
abort(503, "No microscope connected. Unable to recalibrate.")
|
||||
|
||||
logging.info("Starting microscope recalibration...")
|
||||
|
||||
return recalibrate(microscope)
|
||||
|
||||
|
||||
class FlattenLSTView(ActionView):
|
||||
def post(self):
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
if not microscope:
|
||||
abort(
|
||||
503,
|
||||
"No microscope connected. Unable to flatten the lens shading table.",
|
||||
)
|
||||
|
||||
try:
|
||||
with pause_stream(microscope.camera) as scamera:
|
||||
flat_lst = flat_lens_shading_table(scamera.camera)
|
||||
scamera.camera.lens_shading_table = flat_lst
|
||||
microscope.save_settings()
|
||||
except:
|
||||
logging.exception("Error flattening the lens shading table.")
|
||||
abort(
|
||||
503,
|
||||
"Couldn't flatten the lens shading table - do you have the forked PiCamera library installed?",
|
||||
)
|
||||
|
||||
|
||||
class DeleteLSTView(ActionView):
|
||||
def post(self):
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
if not microscope:
|
||||
abort(
|
||||
503,
|
||||
"No microscope connected. Unable to flatten the lens shading table.",
|
||||
)
|
||||
|
||||
try:
|
||||
with pause_stream(microscope.camera) as scamera:
|
||||
scamera.camera.lens_shading_table = None
|
||||
microscope.save_settings()
|
||||
except:
|
||||
logging.exception("Error deleting the lens shading table.")
|
||||
abort(
|
||||
503,
|
||||
"Couldn't flatten the lens shading table - do you have the forked PiCamera library installed?",
|
||||
)
|
||||
|
||||
|
||||
lst_extension_v2 = BaseExtension(
|
||||
"org.openflexure.calibration.picamera",
|
||||
version="2.0.0-beta.1",
|
||||
description="Routines to perform flat-field correction on the camera.",
|
||||
)
|
||||
|
||||
lst_extension_v2.add_method(
|
||||
recalibrate, "org.openflexure.calibration.picamera.recalibrate"
|
||||
)
|
||||
|
||||
lst_extension_v2.add_view(RecalibrateView, "/recalibrate", endpoint="recalibrate")
|
||||
lst_extension_v2.add_view(FlattenLSTView, "/flatten_lens_shading_table", endpoint="flatten_lens_shading_table")
|
||||
lst_extension_v2.add_view(DeleteLSTView, "/delete_lens_shading_table", endpoint="delete_lens_shading_table")
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
import numpy as np
|
||||
import time
|
||||
import logging
|
||||
|
||||
from picamera import PiCamera
|
||||
from picamera.array import PiRGBArray, PiBayerArray
|
||||
|
||||
|
||||
def rgb_image(camera, resize=None, **kwargs):
|
||||
"""Capture an image and return an RGB numpy array"""
|
||||
with PiRGBArray(camera, size=resize) as output:
|
||||
camera.capture(output, format='rgb', resize=resize, **kwargs)
|
||||
camera.capture(output, format="rgb", resize=resize, **kwargs)
|
||||
return output.array
|
||||
|
||||
|
||||
|
|
@ -19,7 +21,9 @@ def flat_lens_shading_table(camera):
|
|||
library (with lens shading table support) it will raise an error.
|
||||
"""
|
||||
if not hasattr(PiCamera, "lens_shading_table"):
|
||||
raise ImportError("This program requires the forked picamera library with lens shading support")
|
||||
raise ImportError(
|
||||
"This program requires the forked picamera library with lens shading support"
|
||||
)
|
||||
return np.zeros(camera._lens_shading_table_shape(), dtype=np.uint8) + 32
|
||||
|
||||
|
||||
|
|
@ -28,42 +32,55 @@ def adjust_exposure_to_setpoint(camera, setpoint):
|
|||
print("Adjusting shutter speed to hit setpoint {}".format(setpoint), end="")
|
||||
for i in range(3):
|
||||
print(".", end="")
|
||||
camera.shutter_speed = int(camera.shutter_speed * setpoint / np.max(rgb_image(camera)))
|
||||
camera.shutter_speed = int(
|
||||
camera.shutter_speed * setpoint / np.max(rgb_image(camera))
|
||||
)
|
||||
time.sleep(1)
|
||||
print("done")
|
||||
|
||||
|
||||
def auto_expose_and_freeze_settings(camera):
|
||||
"""Freeze the settings after auto-exposing to white illumination"""
|
||||
print("Allowing the camera to auto-expose")
|
||||
logging.info("Allowing the camera to auto-expose")
|
||||
camera.awb_mode = "auto"
|
||||
camera.exposure_mode = "auto"
|
||||
camera.iso = 0 # This is important, if it's on a fixed ISO, gain might not set properly.
|
||||
camera.iso = (
|
||||
0
|
||||
) # This is important, if it's on a fixed ISO, gain might not set properly.
|
||||
for i in range(6):
|
||||
print(".", end="")
|
||||
time.sleep(0.5)
|
||||
print("done")
|
||||
logging.info("done")
|
||||
|
||||
print("Freezing the camera settings...")
|
||||
logging.info("Freezing the camera settings...")
|
||||
camera.shutter_speed = camera.exposure_speed
|
||||
print("Shutter speed = {}".format(camera.shutter_speed))
|
||||
logging.info("Shutter speed = {}".format(camera.shutter_speed))
|
||||
camera.exposure_mode = "off"
|
||||
print("Auto exposure disabled")
|
||||
logging.info("Auto exposure disabled")
|
||||
g = camera.awb_gains
|
||||
camera.awb_mode = "off"
|
||||
camera.awb_gains = g
|
||||
print("Auto white balance disabled, gains are {}".format(g))
|
||||
print("Analogue gain: {}, Digital gain: {}".format(camera.analog_gain, camera.digital_gain))
|
||||
logging.info("Auto white balance disabled, gains are {}".format(g))
|
||||
logging.info(
|
||||
"Analogue gain: {}, Digital gain: {}".format(
|
||||
camera.analog_gain, camera.digital_gain
|
||||
)
|
||||
)
|
||||
adjust_exposure_to_setpoint(camera, 215)
|
||||
|
||||
|
||||
def channels_from_bayer_array(bayer_array):
|
||||
"""Given the 'array' from a PiBayerArray, return the 4 channels."""
|
||||
bayer_pattern = [(i//2, i % 2) for i in range(4)]
|
||||
channels = np.zeros((4, bayer_array.shape[0]//2, bayer_array.shape[1]//2), dtype=bayer_array.dtype)
|
||||
bayer_pattern = [(i // 2, i % 2) for i in range(4)]
|
||||
channels = np.zeros(
|
||||
(4, bayer_array.shape[0] // 2, bayer_array.shape[1] // 2),
|
||||
dtype=bayer_array.dtype,
|
||||
)
|
||||
for i, offset in enumerate(bayer_pattern):
|
||||
# We simplify life by dealing with only one channel at a time.
|
||||
channels[i, :, :] = np.sum(bayer_array[offset[0]::2, offset[1]::2, :], axis=2)
|
||||
channels[i, :, :] = np.sum(
|
||||
bayer_array[offset[0] :: 2, offset[1] :: 2, :], axis=2
|
||||
)
|
||||
|
||||
return channels
|
||||
|
||||
|
|
@ -74,7 +91,7 @@ def lst_from_channels(channels):
|
|||
# lst_resolution = list(np.ceil(full_resolution / 64.0).astype(int))
|
||||
lst_resolution = [(r // 64) + 1 for r in full_resolution]
|
||||
# NB the size of the LST is 1/64th of the image, but rounded UP.
|
||||
print("Generating a lens shading table at {}x{}".format(*lst_resolution))
|
||||
logging.info("Generating a lens shading table at {}x{}".format(*lst_resolution))
|
||||
lens_shading = np.zeros([channels.shape[0]] + lst_resolution, dtype=np.float)
|
||||
for i in range(lens_shading.shape[0]):
|
||||
image_channel = channels[i, :, :]
|
||||
|
|
@ -88,23 +105,25 @@ def lst_from_channels(channels):
|
|||
# half the size of the full image - remember the Bayer pattern... This
|
||||
# should give results very close to 6by9's solution, albeit considerably
|
||||
# less computationally efficient!
|
||||
padded_image_channel = np.pad(image_channel,
|
||||
[(0, lw*32 - iw), (0, lh*32 - ih)],
|
||||
mode="edge") # Pad image to the right and bottom
|
||||
print("Channel shape: {}x{}, shading table shape: {}x{}, after padding {}".format(iw,
|
||||
ih,
|
||||
lw*32,
|
||||
lh*32,
|
||||
padded_image_channel.shape))
|
||||
padded_image_channel = np.pad(
|
||||
image_channel, [(0, lw * 32 - iw), (0, lh * 32 - ih)], mode="edge"
|
||||
) # Pad image to the right and bottom
|
||||
logging.info(
|
||||
"Channel shape: {}x{}, shading table shape: {}x{}, after padding {}".format(
|
||||
iw, ih, lw * 32, lh * 32, padded_image_channel.shape
|
||||
)
|
||||
)
|
||||
# Next, fill the shading table (except edge pixels). Please excuse the
|
||||
# for loop - I know it's not fast but this code needn't be!
|
||||
box = 3 # We average together a square of this side length for each pixel.
|
||||
# NB this isn't quite what 6by9's program does - it averages 3 pixels
|
||||
# horizontally, but not vertically.
|
||||
for dx in np.arange(box) - box//2:
|
||||
for dy in np.arange(box) - box//2:
|
||||
ls_channel[:, :] += padded_image_channel[16+dx::32, 16+dy::32] - 64
|
||||
ls_channel /= box**2
|
||||
for dx in np.arange(box) - box // 2:
|
||||
for dy in np.arange(box) - box // 2:
|
||||
ls_channel[:, :] += (
|
||||
padded_image_channel[16 + dx :: 32, 16 + dy :: 32] - 64
|
||||
)
|
||||
ls_channel /= box ** 2
|
||||
# The original C code written by 6by9 normalises to the central 64 pixels in each channel.
|
||||
# ls_channel /= np.mean(image_channel[iw//2-4:iw//2+4, ih//2-4:ih//2+4])
|
||||
# I have had better results just normalising to the maximum:
|
||||
|
|
@ -117,7 +136,7 @@ def lst_from_channels(channels):
|
|||
|
||||
# What we actually want to calculate is the gains needed to compensate for the
|
||||
# lens shading - that's 1/lens_shading_table_float as we currently have it.
|
||||
gains = 32.0/lens_shading # 32 is unity gain
|
||||
gains = 32.0 / lens_shading # 32 is unity gain
|
||||
gains[gains > 255] = 255 # clip at 255, maximum gain is 255/32
|
||||
gains[gains < 32] = 32 # clip at 32, minimum gain is 1 (is this necessary?)
|
||||
lens_shading_table = gains.astype(np.uint8)
|
||||
|
|
@ -154,8 +173,10 @@ def recalibrate_camera(camera):
|
|||
# Fix the AWB gains so the image is neutral
|
||||
channel_means = np.mean(np.mean(rgb_image(camera), axis=0, dtype=np.float), axis=0)
|
||||
old_gains = camera.awb_gains
|
||||
camera.awb_gains = (channel_means[1]/channel_means[0] * old_gains[0],
|
||||
channel_means[1]/channel_means[2]*old_gains[1])
|
||||
camera.awb_gains = (
|
||||
channel_means[1] / channel_means[0] * old_gains[0],
|
||||
channel_means[1] / channel_means[2] * old_gains[1],
|
||||
)
|
||||
time.sleep(1)
|
||||
# Ensure the background is bright but not saturated
|
||||
adjust_exposure_to_setpoint(camera, 230)
|
||||
|
|
@ -165,7 +186,7 @@ if __name__ == "__main__":
|
|||
with PiCamera() as camera:
|
||||
camera.start_preview()
|
||||
time.sleep(3)
|
||||
print("Recalibrating...")
|
||||
logging.info("Recalibrating...")
|
||||
recalibrate_camera(camera)
|
||||
print("Done.")
|
||||
logging.info("Done.")
|
||||
time.sleep(2)
|
||||
354
openflexure_microscope/api/default_extensions/scan.py
Normal file
354
openflexure_microscope/api/default_extensions/scan.py
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import itertools
|
||||
import logging
|
||||
import uuid
|
||||
import datetime
|
||||
from typing import Tuple
|
||||
from functools import reduce
|
||||
|
||||
from openflexure_microscope.captures.capture_manager import generate_basename
|
||||
|
||||
from labthings import fields, find_component, find_extension, current_action, update_action_progress
|
||||
from labthings.views import View, ActionView
|
||||
from labthings.extensions import BaseExtension
|
||||
|
||||
from openflexure_microscope.devel import abort
|
||||
|
||||
import time
|
||||
|
||||
|
||||
### Grid construction
|
||||
|
||||
|
||||
def construct_grid(initial, step_sizes, n_steps, style="raster"):
|
||||
"""
|
||||
Given an initial position, step sizes, and number of steps,
|
||||
construct a 2-dimensional list of scan x-y positions.
|
||||
"""
|
||||
arr = []
|
||||
|
||||
for i in range(n_steps[0]): # x axis
|
||||
arr.append([])
|
||||
for j in range(n_steps[1]): # y axis
|
||||
# Create a coordinate array
|
||||
coord = [initial[ax] + [i, j][ax] * step_sizes[ax] for ax in range(2)]
|
||||
# Append coordinate array to position grid
|
||||
arr[i].append(tuple(coord))
|
||||
|
||||
# Style modifiers
|
||||
if style == "snake":
|
||||
for i, line in enumerate(arr):
|
||||
if i % 2 != 0:
|
||||
line.reverse()
|
||||
|
||||
return arr
|
||||
|
||||
|
||||
def flatten_grid(grid):
|
||||
"""
|
||||
Convert a 3D list of scan positions into a flat list
|
||||
of sequential positions.
|
||||
"""
|
||||
|
||||
grid = list(itertools.chain(*grid))
|
||||
return grid
|
||||
|
||||
|
||||
### Capturing
|
||||
|
||||
|
||||
def capture(
|
||||
microscope,
|
||||
basename,
|
||||
temporary: bool = False,
|
||||
use_video_port: bool = False,
|
||||
resize: Tuple[int, int] = None,
|
||||
bayer: bool = False,
|
||||
metadata: dict = {},
|
||||
annotations: dict = {},
|
||||
tags: list = [],
|
||||
):
|
||||
|
||||
# Construct a tile filename
|
||||
filename = "{}_{}_{}_{}".format(basename, *microscope.stage.position)
|
||||
folder = "SCAN_{}".format(basename)
|
||||
|
||||
# Do capture
|
||||
return microscope.capture(
|
||||
filename=filename,
|
||||
folder=folder,
|
||||
temporary=temporary,
|
||||
use_video_port=use_video_port,
|
||||
resize=resize,
|
||||
bayer=bayer,
|
||||
annotations=annotations,
|
||||
tags=tags,
|
||||
metadata=metadata,
|
||||
cache_key=folder
|
||||
)
|
||||
|
||||
|
||||
class ScanExtension(BaseExtension):
|
||||
def __init__(self):
|
||||
self._images_to_be_captured: int = 1
|
||||
self._images_captured_so_far: int = 0
|
||||
BaseExtension.__init__(self, "org.openflexure.scan", version="2.0.0")
|
||||
|
||||
def progress(self):
|
||||
progress = (self._images_captured_so_far / self._images_to_be_captured) * 100
|
||||
logging.info(progress)
|
||||
return progress
|
||||
|
||||
### Scanning
|
||||
def tile(
|
||||
self,
|
||||
microscope,
|
||||
basename: str = None,
|
||||
temporary: bool = False,
|
||||
stride_size: int = [2000, 1500, 100],
|
||||
grid: list = [3, 3, 5],
|
||||
style="raster",
|
||||
autofocus_dz: int = 50,
|
||||
use_video_port: bool = False,
|
||||
resize: Tuple[int, int] = None,
|
||||
bayer: bool = False,
|
||||
fast_autofocus=False,
|
||||
metadata: dict = {},
|
||||
annotations: dict = {},
|
||||
tags: list = [],
|
||||
):
|
||||
start = time.time()
|
||||
|
||||
# Keep task progress
|
||||
self._images_to_be_captured = reduce((lambda x, y: x * y), grid)
|
||||
self._images_captured_so_far = 0
|
||||
|
||||
# Generate a basename if none given
|
||||
if not basename:
|
||||
basename = generate_basename()
|
||||
|
||||
# Store initial position
|
||||
initial_position = microscope.stage.position
|
||||
|
||||
# Add dataset metadata
|
||||
dataset_d = {
|
||||
"dataset": {
|
||||
"id": uuid.uuid4(),
|
||||
"type": "xyzScan",
|
||||
"name": basename,
|
||||
"acquisitionDate": datetime.datetime.now().isoformat(),
|
||||
"strideSize": stride_size,
|
||||
"grid": grid,
|
||||
"style": style,
|
||||
"autofocusDz": autofocus_dz,
|
||||
}
|
||||
}
|
||||
|
||||
# Check if autofocus is enabled
|
||||
autofocus_extension = find_extension("org.openflexure.autofocus")
|
||||
if (
|
||||
autofocus_dz
|
||||
and autofocus_extension
|
||||
and microscope.has_real_stage()
|
||||
and microscope.has_real_camera()
|
||||
):
|
||||
autofocus_enabled = True
|
||||
else:
|
||||
autofocus_enabled = False
|
||||
|
||||
# Construct an x-y grid (worry about z later)
|
||||
x_y_grid = construct_grid(initial_position, stride_size[:2], grid[:2], style=style)
|
||||
|
||||
# Keep the initial Z position the same as our current position
|
||||
initial_z = initial_position[2]
|
||||
next_z = initial_z # Save this value for use in raster scans
|
||||
|
||||
# Now step through each point in the x-y coordinate array
|
||||
for line in x_y_grid:
|
||||
# If rastering, rather than snake (or eventually spiral)
|
||||
# Return focus to initial position
|
||||
if style == "raster":
|
||||
next_z = initial_z # Reset z position at start of each new row
|
||||
logging.debug("Returning to initial z position")
|
||||
microscope.stage.move_abs(
|
||||
[line[0][0], line[0][1], next_z]
|
||||
) # RWB: I think this line is redundant
|
||||
|
||||
for x_y in line:
|
||||
# Move to new grid position without changing z
|
||||
logging.debug("Moving to step {}".format([x_y[0], x_y[1], next_z]))
|
||||
microscope.stage.move_abs([x_y[0], x_y[1], next_z])
|
||||
# Refocus
|
||||
if autofocus_enabled:
|
||||
if fast_autofocus:
|
||||
# Run fast autofocus. Client should provide dz ~ 2000
|
||||
autofocus_extension.fast_up_down_up_autofocus(
|
||||
microscope, dz=autofocus_dz
|
||||
)
|
||||
else:
|
||||
# Run slow autofocus. Client should provide dz ~ 50
|
||||
autofocus_extension.autofocus(
|
||||
microscope,
|
||||
range(-3 * autofocus_dz, 4 * autofocus_dz, autofocus_dz),
|
||||
)
|
||||
logging.debug("Finished autofocus")
|
||||
time.sleep(1)
|
||||
|
||||
# If we're not doing a z-stack, just capture
|
||||
if grid[2] <= 1:
|
||||
capture(
|
||||
microscope,
|
||||
basename,
|
||||
temporary=temporary,
|
||||
use_video_port=use_video_port,
|
||||
resize=resize,
|
||||
bayer=bayer,
|
||||
metadata=dataset_d,
|
||||
annotations=annotations,
|
||||
tags=tags,
|
||||
)
|
||||
# Update task progress
|
||||
self._images_captured_so_far += 1
|
||||
update_action_progress(self.progress())
|
||||
else:
|
||||
logging.debug("Entering z-stack")
|
||||
self.stack(
|
||||
microscope=microscope,
|
||||
basename=basename,
|
||||
temporary=temporary,
|
||||
step_size=stride_size[2],
|
||||
steps=grid[2],
|
||||
use_video_port=use_video_port,
|
||||
resize=resize,
|
||||
bayer=bayer,
|
||||
metadata=dataset_d,
|
||||
annotations=annotations,
|
||||
tags=tags,
|
||||
)
|
||||
if current_action() and current_action().stopped:
|
||||
return
|
||||
# Make sure we use our current best estimate of focus (i.e. the current position) next point
|
||||
next_z = microscope.stage.position[2]
|
||||
|
||||
logging.debug("Returning to {}".format(initial_position))
|
||||
microscope.stage.move_abs(initial_position)
|
||||
|
||||
end = time.time()
|
||||
logging.info(f"Scan took {end - start} seconds")
|
||||
|
||||
|
||||
def stack(
|
||||
self,
|
||||
microscope,
|
||||
basename: str = None,
|
||||
temporary: bool = False,
|
||||
step_size: int = 100,
|
||||
steps: int = 5,
|
||||
return_to_start: bool = True,
|
||||
use_video_port: bool = False,
|
||||
resize: Tuple[int, int] = None,
|
||||
bayer: bool = False,
|
||||
metadata: dict = {},
|
||||
annotations: dict = {},
|
||||
tags: list = [],
|
||||
):
|
||||
|
||||
# Store initial position
|
||||
initial_position = microscope.stage.position
|
||||
logging.debug(f"Starting z-stack from position {microscope.stage.position}")
|
||||
|
||||
with microscope.lock:
|
||||
# Move to center scan
|
||||
logging.debug("Moving to z-stack starting position")
|
||||
microscope.stage.move_rel([0, 0, int((-step_size * steps) / 2)])
|
||||
logging.debug(f"Starting scan from position {microscope.stage.position}")
|
||||
|
||||
for i in range(steps):
|
||||
time.sleep(0.1)
|
||||
logging.debug(f"Capturing from position {microscope.stage.position}")
|
||||
capture(
|
||||
microscope,
|
||||
basename,
|
||||
temporary=temporary,
|
||||
use_video_port=use_video_port,
|
||||
resize=resize,
|
||||
bayer=bayer,
|
||||
metadata=metadata,
|
||||
annotations=annotations,
|
||||
tags=tags,
|
||||
)
|
||||
# Update task progress
|
||||
self._images_captured_so_far += 1
|
||||
update_action_progress(self.progress())
|
||||
if current_action() and current_action().stopped:
|
||||
return
|
||||
|
||||
if i != steps - 1:
|
||||
logging.debug("Moving z by {}".format(step_size))
|
||||
microscope.stage.move_rel([0, 0, step_size])
|
||||
if return_to_start:
|
||||
logging.debug("Returning to {}".format(initial_position))
|
||||
microscope.stage.move_abs(initial_position)
|
||||
|
||||
|
||||
scan_extension_v2 = ScanExtension()
|
||||
|
||||
class TileScanAPI(ActionView):
|
||||
args = {
|
||||
"filename": fields.String(missing=None, example=None),
|
||||
"temporary": fields.Boolean(missing=False),
|
||||
"stride_size": fields.List(
|
||||
fields.Integer, missing=[2000, 1500, 100], example=[2000, 1500, 100]
|
||||
),
|
||||
"grid": fields.List(fields.Integer, missing=[3, 3, 3], example=[3, 3, 3]),
|
||||
"style": fields.String(missing="raster"),
|
||||
"autofocus_dz": fields.Integer(missing=50),
|
||||
"fast_autofocus": fields.Boolean(missing=False),
|
||||
"use_video_port": fields.Boolean(missing=False),
|
||||
"bayer": fields.Boolean(missing=False),
|
||||
"annotations": fields.Dict(missing={}, example={"Foo": "Bar"}),
|
||||
"tags": fields.List(fields.String, missing=[]),
|
||||
"resize": fields.Dict(missing=None), # TODO: Validate keys
|
||||
}
|
||||
|
||||
# Allow 10 seconds to stop upon DELETE request
|
||||
# Gives fast-autofocus time to finish if it's running
|
||||
default_stop_timeout = 10
|
||||
|
||||
def post(self, args):
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
if not microscope:
|
||||
abort(503, "No microscope connected. Unable to autofocus.")
|
||||
|
||||
resize = args.get("resize", None)
|
||||
if resize:
|
||||
if ("width" in resize) and ("height" in resize):
|
||||
resize = (
|
||||
int(resize["width"]),
|
||||
int(resize["height"]),
|
||||
) # Convert dict to tuple
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
logging.info("Running tile scan...")
|
||||
|
||||
# Acquire microscope lock with 1s timeout
|
||||
with microscope.lock(timeout=1):
|
||||
# Run scan_extension_v2
|
||||
return scan_extension_v2.tile(
|
||||
microscope,
|
||||
basename=args.get("filename"),
|
||||
temporary=args.get("temporary"),
|
||||
stride_size=args.get("stride_size"),
|
||||
grid=args.get("grid"),
|
||||
style=args.get("style"),
|
||||
autofocus_dz=args.get("autofocus_dz"),
|
||||
use_video_port=args.get("use_video_port"),
|
||||
resize=resize,
|
||||
bayer=args.get("bayer"),
|
||||
fast_autofocus=args.get("fast_autofocus"),
|
||||
annotations=args.get("annotations"),
|
||||
tags=args.get("tags"),
|
||||
)
|
||||
|
||||
scan_extension_v2.add_view(TileScanAPI, "/tile", endpoint="tile")
|
||||
196
openflexure_microscope/api/default_extensions/zip_builder.py
Normal file
196
openflexure_microscope/api/default_extensions/zip_builder.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
from openflexure_microscope.devel import (
|
||||
JsonResponse,
|
||||
request,
|
||||
)
|
||||
|
||||
from flask import send_file, abort, url_for
|
||||
|
||||
import uuid
|
||||
import os
|
||||
import zipfile
|
||||
import tempfile
|
||||
import logging
|
||||
|
||||
from labthings import fields, find_component, update_action_progress
|
||||
from labthings.views import View, ActionView, PropertyView
|
||||
from labthings.schema import Schema, pre_dump
|
||||
from labthings.extensions import BaseExtension
|
||||
from labthings.utilities import description_from_view
|
||||
|
||||
|
||||
class ZipObjectSchema(Schema):
|
||||
id = fields.String()
|
||||
data_size = fields.Number()
|
||||
zip_size = fields.Number()
|
||||
links = fields.Dict()
|
||||
|
||||
@pre_dump
|
||||
def generate_links(self, data, **kwargs):
|
||||
data.links = {
|
||||
"download": {
|
||||
"href": url_for(
|
||||
ZipGetterAPIView.endpoint, session_id=data.id, _external=True
|
||||
),
|
||||
**description_from_view(ZipGetterAPIView),
|
||||
}
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
class ZipObjectDescription:
|
||||
def __init__(self, id, file_pointer, data_size=None):
|
||||
self.id = id
|
||||
self.fp = file_pointer
|
||||
self.data_size = data_size
|
||||
self.zip_size = os.path.getsize(self.fp.name) * 1e-6
|
||||
|
||||
def close(self):
|
||||
logging.debug(self.fp.name)
|
||||
self.fp.close()
|
||||
os.unlink(self.fp.name)
|
||||
|
||||
assert not os.path.exists(self.fp.name)
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
|
||||
class ZipManager:
|
||||
"""
|
||||
ZIP-builder manager
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.session_zips = {}
|
||||
|
||||
def build_zip_from_capture_ids(self, microscope, capture_id_list):
|
||||
logging.debug(capture_id_list)
|
||||
|
||||
# Get array of captures from IDs
|
||||
capture_list = [
|
||||
microscope.captures.images.get(capture_id) for capture_id in capture_id_list
|
||||
]
|
||||
# Remove Nones from list (missing/invalid captures)
|
||||
capture_list = [capture for capture in capture_list if capture]
|
||||
|
||||
# Get size (in bytes) of each capture
|
||||
capture_sizes = [
|
||||
os.path.getsize(capture_obj.file) for capture_obj in capture_list
|
||||
]
|
||||
# Calculate size of input data in megabytes
|
||||
data_size_megabytes = sum(capture_sizes) * 1e-6
|
||||
|
||||
# If more than 1GB
|
||||
if data_size_megabytes > 1000:
|
||||
# Throw exception
|
||||
raise Exception(
|
||||
"Zip data cannot exceed 1GB. Please transfer data manually."
|
||||
)
|
||||
|
||||
# Number of files to add (used for task progress)
|
||||
n_files = len(capture_id_list)
|
||||
|
||||
# Create temporary file
|
||||
fp = tempfile.NamedTemporaryFile(delete=False)
|
||||
|
||||
# Open temp file as a ZIP file
|
||||
with zipfile.ZipFile(fp, "w") as zipObj:
|
||||
for index, capture_obj in enumerate(capture_list):
|
||||
# Add to ZIP file if it exists
|
||||
file_path = capture_obj.file
|
||||
rel_path = os.path.relpath(
|
||||
file_path, microscope.captures.paths["default"]
|
||||
)
|
||||
zipObj.write(file_path, arcname=rel_path)
|
||||
# Update task progress
|
||||
update_action_progress(int((index / n_files) * 100))
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
session_description = ZipObjectDescription(
|
||||
session_id, fp, data_size=data_size_megabytes
|
||||
)
|
||||
self.session_zips[session_id] = session_description
|
||||
|
||||
return self.session_zips[session_id]
|
||||
|
||||
def marshaled_build_zip_from_capture_ids(self, *args, **kwargs):
|
||||
return ZipObjectSchema().dump(self.build_zip_from_capture_ids(*args, **kwargs))
|
||||
|
||||
def zip_fp_from_id(self, session_id):
|
||||
return self.session_zips[session_id].fp
|
||||
|
||||
def __del__(self):
|
||||
for zd in self.session_zips.values():
|
||||
zd.close()
|
||||
|
||||
|
||||
# Create a global ZIP manager
|
||||
default_zip_manager = ZipManager()
|
||||
|
||||
|
||||
class ZipBuilderAPIView(ActionView):
|
||||
def post(self):
|
||||
ids = list(JsonResponse(request).json)
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
# Return a handle on the autofocus task
|
||||
return default_zip_manager.marshaled_build_zip_from_capture_ids(
|
||||
microscope, ids
|
||||
)
|
||||
|
||||
|
||||
class ZipListAPIView(PropertyView):
|
||||
schema = ZipObjectSchema(many=True)
|
||||
|
||||
def get(self):
|
||||
return default_zip_manager.session_zips.values()
|
||||
|
||||
|
||||
class ZipGetterAPIView(View):
|
||||
"""
|
||||
Download or delete a particular capture collection ZIP file
|
||||
"""
|
||||
|
||||
def get(self, session_id):
|
||||
"""
|
||||
Download a particular capture collection ZIP file
|
||||
"""
|
||||
if not session_id in default_zip_manager.session_zips:
|
||||
return abort(404) # 404 Not Found
|
||||
|
||||
logging.info(f"Session ID: {session_id}")
|
||||
|
||||
return send_file(
|
||||
default_zip_manager.zip_fp_from_id(session_id).name,
|
||||
mimetype="application/zip",
|
||||
as_attachment=True,
|
||||
attachment_filename=f"{session_id}.zip",
|
||||
)
|
||||
|
||||
def delete(self, session_id):
|
||||
"""
|
||||
Close and delete a particular capture collection ZIP file
|
||||
"""
|
||||
if not session_id in default_zip_manager.session_zips:
|
||||
return abort(404) # 404 Not Found
|
||||
|
||||
# Close the file
|
||||
default_zip_manager.session_zips[session_id].close()
|
||||
# Delete the file reference
|
||||
del default_zip_manager.session_zips[session_id]
|
||||
|
||||
return {"return": session_id}
|
||||
|
||||
|
||||
zip_extension_v2 = BaseExtension(
|
||||
"org.openflexure.zipbuilder",
|
||||
version="2.0.0",
|
||||
description="Build and download capture collections as ZIP files",
|
||||
)
|
||||
|
||||
zip_extension_v2.add_view(ZipGetterAPIView, "/get/<string:session_id>", endpoint="get_id")
|
||||
zip_extension_v2.add_view(ZipListAPIView, "/get", endpoint="get")
|
||||
|
||||
zip_extension_v2.add_view(ZipBuilderAPIView, "/build", endpoint="build")
|
||||
1
openflexure_microscope/api/dev_extensions/__init__.py
Normal file
1
openflexure_microscope/api/dev_extensions/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .tools import devtools_extension_v2
|
||||
32
openflexure_microscope/api/dev_extensions/tools.py
Normal file
32
openflexure_microscope/api/dev_extensions/tools.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from labthings import fields
|
||||
from labthings.extensions import BaseExtension
|
||||
from labthings.views import ActionView
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
class RaiseException(ActionView):
|
||||
def post(self):
|
||||
raise Exception("The developer raised an exception")
|
||||
|
||||
class SleepFor(ActionView):
|
||||
schema = {"TimeAsleep": fields.Float()}
|
||||
args = {"time": fields.Float(description="Time to sleep, in seconds", example=0.5)}
|
||||
|
||||
def post(self, args):
|
||||
sleep_time = args.get("time")
|
||||
logging.info(f"Going to sleep for {sleep_time}...")
|
||||
start = time.time()
|
||||
time.sleep(sleep_time)
|
||||
end = time.time()
|
||||
logging.info("Waking up!")
|
||||
return {"TimeAsleep": (end - start)}
|
||||
|
||||
devtools_extension_v2 = BaseExtension(
|
||||
"org.openflexure.dev.tools",
|
||||
version="0.1.0",
|
||||
description="Actions to cause various traumatic events in the microscope, used for testing.",
|
||||
)
|
||||
|
||||
devtools_extension_v2.add_view(RaiseException, "/raise")
|
||||
devtools_extension_v2.add_view(SleepFor, "/sleep")
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
from flask import jsonify, escape
|
||||
from werkzeug.exceptions import default_exceptions
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
|
||||
class JSONExceptionHandler(object):
|
||||
|
||||
def __init__(self, app=None):
|
||||
if app:
|
||||
self.init_app(app)
|
||||
|
||||
def std_handler(self, error):
|
||||
if isinstance(error, HTTPException):
|
||||
message = error.description
|
||||
elif hasattr(error, 'message'):
|
||||
message = error.message
|
||||
else:
|
||||
message = str(error)
|
||||
|
||||
status_code = error.code if isinstance(error, HTTPException) else 500
|
||||
|
||||
response = {
|
||||
'status_code': status_code,
|
||||
'message': escape(message)
|
||||
}
|
||||
return jsonify(response), status_code
|
||||
|
||||
def init_app(self, app):
|
||||
self.app = app
|
||||
self.register(HTTPException)
|
||||
for code, v in default_exceptions.items():
|
||||
self.register(code)
|
||||
|
||||
def register(self, exception_or_code, handler=None):
|
||||
self.app.errorhandler(exception_or_code)(handler or self.std_handler)
|
||||
12
openflexure_microscope/api/microscope.py
Normal file
12
openflexure_microscope/api/microscope.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from openflexure_microscope.microscope import Microscope
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
default_microscope = Microscope()
|
||||
|
||||
# Restore loaded capture array to camera object
|
||||
logging.debug("Restoring captures...")
|
||||
default_microscope.captures.rebuild_captures()
|
||||
|
||||
logging.debug("Microscope successfully attached!")
|
||||
3
openflexure_microscope/api/static/.env.development
Normal file
3
openflexure_microscope/api/static/.env.development
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
NODE_ENV=development
|
||||
VUE_APP_PLATFORM=web
|
||||
VUE_APP_TARGET=web
|
||||
3
openflexure_microscope/api/static/.env.production
Normal file
3
openflexure_microscope/api/static/.env.production
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
NODE_ENV=production
|
||||
VUE_APP_PLATFORM=web
|
||||
VUE_APP_TARGET=web
|
||||
24
openflexure_microscope/api/static/.eslintrc.js
Normal file
24
openflexure_microscope/api/static/.eslintrc.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true
|
||||
},
|
||||
extends: [
|
||||
"plugin:vue/recommended",
|
||||
"eslint:recommended",
|
||||
"prettier/vue",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
rules: {
|
||||
"vue/component-name-in-template-casing": ["error", "PascalCase"],
|
||||
"no-console": process.env.NODE_ENV === "production" ? "error" : "off",
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "off"
|
||||
},
|
||||
globals: {
|
||||
$nuxt: true
|
||||
},
|
||||
parserOptions: {
|
||||
parser: "babel-eslint"
|
||||
}
|
||||
};
|
||||
30
openflexure_microscope/api/static/.gitignore
vendored
Normal file
30
openflexure_microscope/api/static/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
/dist-lite
|
||||
/app/dist
|
||||
/release-builds
|
||||
/installers/release-builds
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw*
|
||||
|
||||
# NativeScript application
|
||||
hooks
|
||||
platforms
|
||||
./webpack.config.js
|
||||
93
openflexure_microscope/api/static/CONTRIBUTING.md
Normal file
93
openflexure_microscope/api/static/CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Contributing
|
||||
|
||||
When contributing to this repository, please first discuss the change you wish to make via issue,
|
||||
email, or any other method with the owners of this repository before making a change.
|
||||
|
||||
Please note we have a working flow, and a code of conduct. Please follow it in all your interactions with the project.
|
||||
|
||||
## Flow
|
||||
<img src="https://gitlab.com/openflexure/openflexure-identity/raw/master/flow.png" height="200">
|
||||
|
||||
### Development
|
||||
|
||||
* Master is the latest working version.
|
||||
* New features and bug fixes should be developed on branches (or forks), and merged into master.
|
||||
|
||||
### Releases
|
||||
|
||||
* Once we've merged a few things in and want to make a release, we tag it on master.
|
||||
* Not every commit on master will get a tag, we'll accumulate a few changes before making a release, unless there's a reason to release straight away.
|
||||
* Once we're confident that the tagged release is building and stable, we merge it into the stable branch.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, gender identity and expression, level of experience,
|
||||
nationality, personal appearance, race, religion, or sexual identity and
|
||||
orientation.
|
||||
|
||||
### Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
### Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
### Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
### Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at [INSERT EMAIL ADDRESS]. All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
### Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at [http://contributor-covenant.org/version/1/4][version]
|
||||
|
||||
[homepage]: http://contributor-covenant.org
|
||||
[version]: http://contributor-covenant.org/version/1/4/
|
||||
674
openflexure_microscope/api/static/LICENSE
Normal file
674
openflexure_microscope/api/static/LICENSE
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
openflexure-microscope-jsclient
|
||||
Copyright (C) 2019 OpenFlexure.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
openflexure-microscope-jsclient Copyright (C) 2019 OpenFlexure.org
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
20
openflexure_microscope/api/static/README.md
Normal file
20
openflexure_microscope/api/static/README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# OpenFlexure Microscope JS Client
|
||||
|
||||
## Developer notes
|
||||
|
||||
### VS Code and ESLint
|
||||
|
||||
To prevent the editor from interfering with ESLint, add to your project `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"cSpell.enabled": false,
|
||||
"eslint.validate": ["vue","javascript", "javascriptreact"],
|
||||
"editor.formatOnSave": false,
|
||||
"vetur.validation.template": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
}
|
||||
```
|
||||
5
openflexure_microscope/api/static/babel.config.js
Normal file
5
openflexure_microscope/api/static/babel.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/app'
|
||||
]
|
||||
}
|
||||
13812
openflexure_microscope/api/static/package-lock.json
generated
Normal file
13812
openflexure_microscope/api/static/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
50
openflexure_microscope/api/static/package.json
Normal file
50
openflexure_microscope/api/static/package.json
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"name": "openflexure-microscope-jsclient",
|
||||
"version": "2.4.0",
|
||||
"private": true,
|
||||
"description": "User client for the OpenFlexure Microscope Server",
|
||||
"author": "OpenFlexure <contact@openflexure.org> (https://www.openflexure.org)",
|
||||
"homepage": "https://gitlab.com/openflexure/openflexure-microscope-jsclient",
|
||||
"publisher": "Bath Open Instrumentation Group",
|
||||
"displayName": "OpenFlexure Microscope",
|
||||
"license": "GNU General Public License v3.0 ",
|
||||
"scripts": {
|
||||
"build": "vue-cli-service build --mode production",
|
||||
"serve": "vue-cli-service serve --mode development"
|
||||
},
|
||||
"dependencies": {
|
||||
"material-design-icons": "^3.0.1",
|
||||
"mousetrap": "^1.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^3.12.1",
|
||||
"@vue/cli-service": "^4.4.1",
|
||||
"axios": "^0.19.2",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"css-loader": "^3.5.3",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-config-prettier": "^6.11.0",
|
||||
"eslint-plugin-prettier": "^3.1.3",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"less": "^3.11.2",
|
||||
"less-loader": "^5.0.0",
|
||||
"mdns-js": "^1.0.3",
|
||||
"uikit": "^3.5.3",
|
||||
"vue": "^2.6.11",
|
||||
"vue-friendly-iframe": "^0.17.0",
|
||||
"vue-plugin-load-script": "^1.2.0",
|
||||
"vue-template-compiler": "^2.6.11",
|
||||
"vue-tour": "^1.4.0",
|
||||
"vuex": "^3.4.0"
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"autoprefixer": {}
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not ie <= 8"
|
||||
]
|
||||
}
|
||||
BIN
openflexure_microscope/api/static/public/favicon-16x16.png
Normal file
BIN
openflexure_microscope/api/static/public/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
openflexure_microscope/api/static/public/favicon-32x32.png
Normal file
BIN
openflexure_microscope/api/static/public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
openflexure_microscope/api/static/public/favicon.ico
Normal file
BIN
openflexure_microscope/api/static/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
17
openflexure_microscope/api/static/public/index.html
Normal file
17
openflexure_microscope/api/static/public/index.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title>OpenFlexure Microscope</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but vue3 doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
9
openflexure_microscope/api/static/public/titleicon.svg
Normal file
9
openflexure_microscope/api/static/public/titleicon.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 90 56" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;">
|
||||
<g transform="matrix(1,0,0,1,-30.245,-47.0254)">
|
||||
<g id="path3773" transform="matrix(0.12596,0,0,0.12596,28.1368,19.1335)">
|
||||
<path d="M212.85,344.652C234.052,276.913 297.347,227.702 372.047,227.702C446.747,227.702 510.042,276.913 531.245,344.652L531.245,344.652L531.246,344.656C531.789,346.391 532.304,348.138 532.791,349.896C556.452,421.618 655.593,393.868 655.593,393.868L721.09,563.056L472.356,659.347L430.306,550.728L430.129,550.794L406.853,490.142C408.621,489.464 410.361,488.736 412.072,487.962C419.736,484.232 426.404,480.109 432.198,475.703C447.995,463.209 459.586,446.041 465.257,426.69C473.38,395.32 463.384,366.691 463.101,365.891C449.309,328.959 413.54,302.366 372.047,302.366C330.554,302.366 294.785,328.959 280.994,365.891C280.711,366.689 270.714,395.319 278.838,426.69C284.509,446.041 296.099,463.209 311.897,475.703C317.69,480.109 324.359,484.232 332.023,487.962C333.734,488.736 335.474,489.464 337.242,490.142L337.166,490.34C337.166,490.34 313.965,550.794 313.965,550.794L313.788,550.728L271.739,659.347L23.004,563.056L88.501,393.868C88.501,393.868 187.642,421.618 211.303,349.896C211.79,348.138 212.306,346.391 212.849,344.656L212.85,344.652L212.85,344.652Z" style="fill:white;fill-rule:nonzero;stroke:white;stroke-width:12.54px;"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
470
openflexure_microscope/api/static/src/App.vue
Normal file
470
openflexure_microscope/api/static/src/App.vue
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
<template>
|
||||
<div
|
||||
id="app"
|
||||
class="uk-height-1-1 uk-margin-remove uk-padding-remove"
|
||||
:class="handleTheme"
|
||||
>
|
||||
<loadingContent v-if="!$store.getters.ready" />
|
||||
<div v-if="$store.getters.ready" id="tour-header"></div>
|
||||
<appContent v-if="$store.getters.ready" />
|
||||
<!-- Runtime modals -->
|
||||
<div
|
||||
id="modal-center"
|
||||
ref="keyboardManualModal"
|
||||
class="uk-flex-top"
|
||||
uk-modal
|
||||
>
|
||||
<div class="uk-modal-dialog uk-modal-body uk-margin-auto-vertical">
|
||||
<button class="uk-modal-close-default" type="button" uk-close></button>
|
||||
<div
|
||||
v-for="shortcut in keyboardManual"
|
||||
:key="shortcut.shortcut"
|
||||
class="uk-margin-small"
|
||||
uk-grid
|
||||
>
|
||||
<div class="uk-width-small">{{ shortcut.shortcut }}</div>
|
||||
<div class="uk-width-expand">{{ shortcut.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-tour
|
||||
v-show="$store.getters.ready"
|
||||
name="guidedTour"
|
||||
:steps="tourSteps"
|
||||
:callbacks="tourCallbacks"
|
||||
:options="{ highlight: true }"
|
||||
></v-tour>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// Import components
|
||||
import appContent from "./components/appContent.vue";
|
||||
import loadingContent from "./components/loadingContent.vue";
|
||||
|
||||
import axios from "axios";
|
||||
var Mousetrap = require("mousetrap");
|
||||
|
||||
Mousetrap.prototype.stopCallback = function(e, element, combo) {
|
||||
// if the element has the class "mousetrap" then no need to stop
|
||||
if ((" " + element.className + " ").indexOf(" mousetrap ") > -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if we're in a lightbox, stop mousetrap
|
||||
if (element.classList.contains("lightbox-link")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// stop for input, select, and textarea
|
||||
return (
|
||||
element.tagName == "INPUT" ||
|
||||
element.tagName == "SELECT" ||
|
||||
element.tagName == "TEXTAREA" ||
|
||||
(element.contentEditable && element.contentEditable == "true")
|
||||
);
|
||||
};
|
||||
|
||||
// Export main app
|
||||
export default {
|
||||
name: "App",
|
||||
|
||||
components: {
|
||||
appContent,
|
||||
loadingContent
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
appAvailable: false,
|
||||
arrowKeysDown: {},
|
||||
keyboardManual: [],
|
||||
systemDark: undefined,
|
||||
themeObserver: undefined,
|
||||
tourCallbacks: {
|
||||
onStop: () => {
|
||||
this.setLocalStorageObj("completedTour", true);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
isSystemDark: function() {
|
||||
if (
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
handleTheme: function() {
|
||||
var isDark = false;
|
||||
if (this.$store.state.globalSettings.appTheme == "dark") {
|
||||
isDark = true;
|
||||
} else if (this.$store.state.globalSettings.appTheme == "system") {
|
||||
if (this.systemDark) {
|
||||
isDark = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
"uk-light": isDark,
|
||||
"uk-background-secondary": isDark
|
||||
};
|
||||
},
|
||||
tourSteps: function() {
|
||||
return [
|
||||
{
|
||||
target: "#tour-header", // We're using document.querySelector() under the hood
|
||||
header: {
|
||||
title: "Welcome to the OpenFlexure Microscope"
|
||||
},
|
||||
content: `Click Next to learn how to use your microscope`,
|
||||
params: {
|
||||
placement: "bottom"
|
||||
}
|
||||
},
|
||||
{
|
||||
target: "#gallery-tab-icon",
|
||||
header: {
|
||||
title: "Capture gallery"
|
||||
},
|
||||
content: `View and download your microscope images from the gallery tab`
|
||||
},
|
||||
{
|
||||
target: "#navigate-tab-icon",
|
||||
header: {
|
||||
title: "Navigate around your sample"
|
||||
},
|
||||
content: `Move your microscope stage and perform autofocus from the navigate tab`
|
||||
},
|
||||
{
|
||||
target: "#capture-tab-icon",
|
||||
header: {
|
||||
title: "Capture microscope images"
|
||||
},
|
||||
content: `Take images and simple tile scans from the capture tab`
|
||||
},
|
||||
{
|
||||
target: "#settings-tab-icon",
|
||||
header: {
|
||||
title: "Change settings"
|
||||
},
|
||||
content: `Change app and microscope settings, including microscope calibration, from the settings tab`
|
||||
},
|
||||
{
|
||||
target: "#extension-tab-divider",
|
||||
header: {
|
||||
title: "Microscope extensions"
|
||||
},
|
||||
content: `Extensions installed on your microscope will appear below this line`
|
||||
}
|
||||
];
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Query CSS dark theme preference
|
||||
var mql = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
// Check for system dark theme when mounted
|
||||
if (mql.matches) {
|
||||
this.systemDark = true;
|
||||
}
|
||||
// Create a theme observer to watch for changes
|
||||
this.themeObserver = mql.addListener(e => {
|
||||
if (e.matches) {
|
||||
this.systemDark = true;
|
||||
} else {
|
||||
this.systemDark = false;
|
||||
}
|
||||
});
|
||||
// Check connection to API
|
||||
this.checkConnection();
|
||||
// Handle guided tour
|
||||
// If the user has already completed or skipped the guided tour
|
||||
// TODO: Only run this if connected to the API
|
||||
var completedTour = this.getLocalStorageObj("completedTour") || false;
|
||||
if (!completedTour) {
|
||||
this.$tours["guidedTour"].start();
|
||||
}
|
||||
},
|
||||
|
||||
created: function() {
|
||||
window.addEventListener("beforeunload", this.handleExit);
|
||||
// Scrollwheel listener
|
||||
window.addEventListener("wheel", this.wheelMonitor);
|
||||
// Watch for origin changes
|
||||
this.unwatchOriginFunction = this.$store.watch(
|
||||
(state, getters) => {
|
||||
return getters.uriV2;
|
||||
},
|
||||
uriV2 => {
|
||||
this.checkConnection();
|
||||
console.log(uriV2);
|
||||
}
|
||||
);
|
||||
|
||||
// Keyboard shortcuts
|
||||
|
||||
// TODO: Shortcut guide
|
||||
Mousetrap.bind("?", () => {
|
||||
console.log(this.keyboardManual);
|
||||
this.toggleModalElement(this.$refs["keyboardManualModal"]); // Calls the mixin
|
||||
});
|
||||
|
||||
// Arrow keys
|
||||
Mousetrap.bind(
|
||||
["up", "down", "left", "right"],
|
||||
event => {
|
||||
this.arrowKeysDown[event.keyCode] = true; //Add key to array
|
||||
this.navigateKeyHandler();
|
||||
},
|
||||
"keydown"
|
||||
);
|
||||
Mousetrap.bind(
|
||||
["up", "down", "left", "right"],
|
||||
event => {
|
||||
delete this.arrowKeysDown[event.keyCode]; //Remove key from array
|
||||
},
|
||||
"keyup"
|
||||
);
|
||||
this.keyboardManual.push({
|
||||
shortcut: "←↑→↓",
|
||||
description: "Move the microscope stage"
|
||||
});
|
||||
|
||||
// Focus keys
|
||||
Mousetrap.bind("pageup", () => {
|
||||
this.$root.$emit("globalMoveStepEvent", 0, 0, 1);
|
||||
});
|
||||
Mousetrap.bind("pagedown", () => {
|
||||
this.$root.$emit("globalMoveStepEvent", 0, 0, -1);
|
||||
});
|
||||
this.keyboardManual.push({
|
||||
shortcut: "pgup / pgdn",
|
||||
description: "Move the microscope focus"
|
||||
});
|
||||
|
||||
// Capture
|
||||
Mousetrap.bind("c", () => {
|
||||
this.$root.$emit("globalCaptureEvent");
|
||||
});
|
||||
this.keyboardManual.push({
|
||||
shortcut: "c",
|
||||
description: "Take a capture"
|
||||
});
|
||||
|
||||
// Autofocus
|
||||
Mousetrap.bind("a", () => {
|
||||
this.$root.$emit("globalFastAutofocusEvent");
|
||||
});
|
||||
this.keyboardManual.push({
|
||||
shortcut: "a",
|
||||
description: "Fast autofocus"
|
||||
});
|
||||
|
||||
// Increment/decrement tab
|
||||
Mousetrap.bind("shift+down", () => {
|
||||
this.$root.$emit("globalIncrementTab");
|
||||
});
|
||||
Mousetrap.bind("shift+up", () => {
|
||||
this.$root.$emit("globalDecrementTab");
|
||||
});
|
||||
this.keyboardManual.push({
|
||||
shortcut: "shift+↑ / shift+↓",
|
||||
description: "Switch tab"
|
||||
});
|
||||
|
||||
// Re-run tour
|
||||
Mousetrap.bind("alt+t", () => {
|
||||
this.$tours["guidedTour"].start();
|
||||
});
|
||||
},
|
||||
|
||||
beforeDestroy: function() {
|
||||
// Disconnect the theme observer
|
||||
if (this.themeObserver) {
|
||||
this.themeObserver.disconnect();
|
||||
}
|
||||
// Remove scrollwheel listener
|
||||
window.removeEventListener("wheel", this.wheelMonitor);
|
||||
// Remove origin watcher
|
||||
this.unwatchOriginFunction();
|
||||
// Remove key listeners
|
||||
console.log("Resetting Mousetrap");
|
||||
Mousetrap.reset();
|
||||
},
|
||||
|
||||
methods: {
|
||||
checkConnection: function() {
|
||||
var uriV2 = this.$store.getters.uriV2;
|
||||
this.$store.commit("changeWaiting", true);
|
||||
axios
|
||||
.get(uriV2)
|
||||
.then(() => {
|
||||
this.$store.commit("setConnected");
|
||||
this.$store.commit("setErrorMessage", null);
|
||||
})
|
||||
.catch(error => {
|
||||
this.$store.commit("setErrorMessage", error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.$store.commit("changeWaiting", false);
|
||||
});
|
||||
},
|
||||
|
||||
handleExit: function() {
|
||||
console.log("Triggered beforeunload");
|
||||
this.$root.$emit("globalTogglePreview", false);
|
||||
},
|
||||
|
||||
// Handle global mouse wheel events to be associated with navigation
|
||||
wheelMonitor: function(event) {
|
||||
// Only capture scroll if the event target's parent contains the "scrollTarget" class
|
||||
if (
|
||||
event.target.parentNode.classList.contains("scrollTarget") ||
|
||||
event.target.classList.contains("scrollTarget")
|
||||
) {
|
||||
var z_rel = event.deltaY / 100;
|
||||
// Emit a signal to move, acted on by panelNavigate.vue
|
||||
this.$root.$emit("globalMoveStepEvent", 0, 0, z_rel, false);
|
||||
}
|
||||
},
|
||||
|
||||
navigateKeyHandler: function() {
|
||||
// Calculate movement array
|
||||
var x_rel = 0;
|
||||
var y_rel = 0;
|
||||
var z_rel = 0;
|
||||
if (37 in this.arrowKeysDown) {
|
||||
x_rel = x_rel + 1;
|
||||
}
|
||||
if (39 in this.arrowKeysDown) {
|
||||
x_rel = x_rel - 1;
|
||||
}
|
||||
if (38 in this.arrowKeysDown) {
|
||||
y_rel = y_rel + 1;
|
||||
}
|
||||
if (40 in this.arrowKeysDown) {
|
||||
y_rel = y_rel - 1;
|
||||
}
|
||||
// Make a position request
|
||||
// Emit a signal to move, acted on by panelNavigate.vue
|
||||
this.$root.$emit("globalMoveStepEvent", x_rel, y_rel, z_rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
// Basic UIkit CSS
|
||||
@import "../node_modules/uikit/src/less/uikit.less";
|
||||
// Custom UIkit CSS modifications
|
||||
@import "./assets/less/theme.less";
|
||||
|
||||
// We override the custom-electron-titlebar z-index
|
||||
// UIKit lightbox must be able to draw over the titlebar
|
||||
// as it currently always spawns at the root of the DOM
|
||||
.titlebar,
|
||||
.titlebar > * {
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
#app {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-align: left;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.uk-disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.control-component {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
width: 300px;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background-color: rgba(180, 180, 180, 0.03);
|
||||
border-width: 0 1px 0 0;
|
||||
border-style: solid;
|
||||
border-color: rgba(180, 180, 180, 0.25);
|
||||
}
|
||||
|
||||
.view-component {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// Style tour
|
||||
.v-tour__target--highlighted {
|
||||
box-shadow: 0px 40px 200px 30px rgba(0, 0, 0, 0.5),
|
||||
0px 0px 0px 4px rgba(128, 128, 128, 0.5) !important;
|
||||
border-radius: 5px;
|
||||
opacity: 100% !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.v-step {
|
||||
background: @global-primary-background !important;
|
||||
}
|
||||
|
||||
.v-step__header {
|
||||
background-color: darken(@global-primary-background, 7%) !important;
|
||||
}
|
||||
|
||||
.v-step__button {
|
||||
font-size: 0.9rem !important;
|
||||
}
|
||||
|
||||
// Change step arrow colour
|
||||
// This is awful and hacky and makes me sad, but needs must
|
||||
.v-step .v-step__arrow {
|
||||
border-color: darken(@global-primary-background, 7%) !important;
|
||||
&--dark {
|
||||
border-color: darken(@global-primary-background, 7%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.v-step[x-placement^="top"] .v-step__arrow {
|
||||
border-left-color: transparent !important;
|
||||
border-right-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
}
|
||||
|
||||
.v-step[x-placement^="bottom"] .v-step__arrow {
|
||||
border-left-color: transparent !important;
|
||||
border-right-color: transparent !important;
|
||||
border-top-color: transparent !important;
|
||||
}
|
||||
|
||||
.v-step[x-placement^="right"] .v-step__arrow {
|
||||
border-left-color: transparent !important;
|
||||
border-top-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
}
|
||||
|
||||
.v-step[x-placement^="left"] .v-step__arrow {
|
||||
border-top-color: transparent !important;
|
||||
border-right-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
18
openflexure_microscope/api/static/src/assets/less/app.less
Normal file
18
openflexure_microscope/api/static/src/assets/less/app.less
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Theming specific to the electron app
|
||||
|
||||
.menubar-menu-container {
|
||||
box-shadow: 0.5px 1px 4px 0px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
.menubar-menu-container>* {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.menubar-menu-container .action-menu-item,
|
||||
.menubar-menu-container .action-label {
|
||||
cursor: default
|
||||
}
|
||||
|
||||
.menubar-menu-container a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* UIkit Theme for Highlight.js
|
||||
*/
|
||||
|
||||
.hljs {
|
||||
display: block;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-meta {
|
||||
color: #969896;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-strong,
|
||||
.hljs-emphasis,
|
||||
.hljs-quote {
|
||||
color: @global-danger-background;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-type {
|
||||
color: #1f34aa;
|
||||
}
|
||||
|
||||
.hljs-literal,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet,
|
||||
.hljs-attribute {
|
||||
color: #0086b3;
|
||||
}
|
||||
|
||||
.hljs-section,
|
||||
.hljs-name {
|
||||
color: #63a35c;
|
||||
}
|
||||
|
||||
.hljs-tag {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-attr,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-pseudo {
|
||||
color: #795da3;
|
||||
}
|
||||
|
||||
.hljs-addition {
|
||||
color: #55a532;
|
||||
background-color: #eaffea;
|
||||
}
|
||||
|
||||
.hljs-deletion {
|
||||
color: #bd2c00;
|
||||
background-color: #ffecec;
|
||||
}
|
||||
|
||||
.hljs-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
374
openflexure_microscope/api/static/src/assets/less/theme.less
Normal file
374
openflexure_microscope/api/static/src/assets/less/theme.less
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
// UIkit Core
|
||||
@import "../../../node_modules/uikit/src/less/uikit.theme.less";
|
||||
|
||||
// Highlight.js
|
||||
@import "./highlight.less";
|
||||
|
||||
// Electron app theming
|
||||
@import "./app.less";
|
||||
|
||||
// Custom OpenFlexure theming
|
||||
|
||||
//
|
||||
// Colors
|
||||
//
|
||||
|
||||
@global-color: #666;
|
||||
@global-emphasis-color: #333;
|
||||
@global-muted-color: #666;
|
||||
|
||||
@global-primary-background: #C32280;
|
||||
|
||||
@inverse-primary-muted-color: lighten(@global-primary-background, 15%);
|
||||
@inverse-global-color: fade(@global-inverse-color, 80%);
|
||||
|
||||
@global-border: #d5d5d5;
|
||||
|
||||
// UIkit
|
||||
// ========================================================================
|
||||
|
||||
@global-font-family: ProximaNova, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
@global-font-size: 14px;
|
||||
|
||||
@global-xxlarge-font-size: 38px;
|
||||
@global-xlarge-font-size: 30px;
|
||||
@global-large-font-size: 24px;
|
||||
@global-medium-font-size: 20px;
|
||||
@global-small-font-size: 14px;
|
||||
|
||||
@global-emphasis-color: #222;
|
||||
|
||||
//
|
||||
// Base
|
||||
//
|
||||
|
||||
@base-code-font-family: 'Roboto Mono', monospace;
|
||||
@base-code-font-size: 12px;
|
||||
|
||||
@base-heading-font-weight: 300;
|
||||
|
||||
@base-pre-font-size: 12px;
|
||||
@base-pre-padding: 25px;
|
||||
@base-pre-background: @global-muted-background;
|
||||
@base-pre-border-width: 0;
|
||||
@base-pre-border-radius: 0;
|
||||
|
||||
.hook-base-body() {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
//
|
||||
// Decorations
|
||||
//
|
||||
@small-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
@big-shadow: 0 14px 25px rgba(0, 0, 0, 0.16);
|
||||
|
||||
//
|
||||
// Container
|
||||
//
|
||||
|
||||
@container-max-width: 1380px;
|
||||
@container-small-max-width: 650px;
|
||||
|
||||
//
|
||||
// Navbar
|
||||
//
|
||||
|
||||
@inverse-navbar-nav-item-color: @inverse-global-color;
|
||||
@inverse-navbar-nav-item-hover-color: @inverse-global-emphasis-color;
|
||||
|
||||
//
|
||||
// Nav
|
||||
//
|
||||
|
||||
@nav-header-font-size: 12px;
|
||||
|
||||
//
|
||||
// Subnav
|
||||
//
|
||||
|
||||
@inverse-subnav-item-color: @inverse-global-color;
|
||||
@inverse-subnav-item-hover-color: @inverse-global-emphasis-color;
|
||||
|
||||
//
|
||||
// Tab
|
||||
//
|
||||
|
||||
@tab-item-padding-horizontal: 10px;
|
||||
@tab-item-padding-vertical: 9px;
|
||||
@tab-item-border-width: 2px;
|
||||
@tab-item-font-size: 12px;
|
||||
|
||||
.hook-tab-item() {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
//
|
||||
// Table
|
||||
//
|
||||
|
||||
@table-header-cell-font-size: 12px;
|
||||
|
||||
//
|
||||
// Label
|
||||
//
|
||||
|
||||
@label-font-size: 12px;
|
||||
|
||||
//
|
||||
// Text
|
||||
//
|
||||
|
||||
.hook-text-lead() {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.hook-text-large() {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
//
|
||||
// Utility
|
||||
//
|
||||
|
||||
@inverse-logo-color: @inverse-global-emphasis-color;
|
||||
@inverse-logo-hover-color: @inverse-global-emphasis-color;
|
||||
|
||||
//
|
||||
// Off-canvas
|
||||
//
|
||||
|
||||
@offcanvas-bar-background: #fff;
|
||||
@offcanvas-bar-color-mode: dark;
|
||||
|
||||
//
|
||||
// Inverse
|
||||
//
|
||||
|
||||
@inverse-global-color: fade(@global-inverse-color, 80%);
|
||||
@inverse-global-muted-color: fade(@global-inverse-color, 60%);
|
||||
|
||||
//
|
||||
// Paper
|
||||
//
|
||||
@paper-border-radius: 4px;
|
||||
@button-border-radius: 3px;
|
||||
|
||||
|
||||
/* ========================================================================
|
||||
Theme
|
||||
========================================================================== */
|
||||
|
||||
@sidebar-left-width: 240px;
|
||||
@sidebar-left-width-xl: 300px;
|
||||
|
||||
@sidebar-right-width: 200px;
|
||||
@sidebar-right-left: 0px;
|
||||
@sidebar-right-left-xl: 60px;
|
||||
|
||||
|
||||
|
||||
/* UIkit modifiers
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Navbar
|
||||
*/
|
||||
|
||||
// Line Mode
|
||||
@navbar-nav-item-line-margin-vertical: 20px;
|
||||
@navbar-nav-item-line-margin-horizontal: @navbar-nav-item-padding-horizontal;
|
||||
@navbar-nav-item-line-height: 1px;
|
||||
@navbar-nav-item-line-background: currentColor;
|
||||
@navbar-nav-item-line-transition-duration: 0.3s;
|
||||
|
||||
.uk-navbar-dropdown.uk-open {
|
||||
border-radius: @paper-border-radius;
|
||||
}
|
||||
|
||||
/*
|
||||
* Cards
|
||||
*/
|
||||
|
||||
.uk-card {
|
||||
border-radius: @paper-border-radius;
|
||||
//border: 1px solid rgba(180, 180, 180, 0.25);
|
||||
box-shadow: @small-shadow;
|
||||
}
|
||||
|
||||
.uk-card-media-top img {
|
||||
border-radius: @paper-border-radius @paper-border-radius 0 0;
|
||||
}
|
||||
|
||||
.uk-card-default .uk-card-footer {
|
||||
border-top: 1px solid rgba(180, 180, 180, 0.25);
|
||||
}
|
||||
|
||||
.uk-card-primary .uk-card-footer {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.075);
|
||||
}
|
||||
|
||||
.uk-card-default {
|
||||
color: @global-color;
|
||||
}
|
||||
|
||||
.hook-inverse() {
|
||||
|
||||
// Override background colour in dark mode
|
||||
.uk-card-default {
|
||||
background-color: #2a2a2a !important;
|
||||
color: @inverse-global-color;
|
||||
}
|
||||
|
||||
// Lighten on hover to show depth in dark mode
|
||||
.uk-card-default:hover {
|
||||
transition: background-color @animation-fast-duration ease;
|
||||
background-color: #333 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Modal
|
||||
*/
|
||||
.uk-modal-dialog {
|
||||
border-radius: @paper-border-radius;
|
||||
box-shadow: @big-shadow;
|
||||
transition: 0.3s ease !important;
|
||||
}
|
||||
|
||||
.uk-modal-footer {
|
||||
border-radius: 0 0 @paper-border-radius @paper-border-radius;
|
||||
}
|
||||
|
||||
.uk-modal-header {
|
||||
border-radius: @paper-border-radius @paper-border-radius 0 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Notifications
|
||||
*/
|
||||
.uk-notification-message {
|
||||
border-radius: @paper-border-radius;
|
||||
box-shadow: @big-shadow;
|
||||
}
|
||||
|
||||
.uk-notification-message-danger {
|
||||
border: 1px solid @notification-message-danger-color
|
||||
}
|
||||
|
||||
.uk-notification-message-warning {
|
||||
border: 1px solid @notification-message-warning-color
|
||||
}
|
||||
|
||||
.uk-notification-message-success {
|
||||
border: 1px solid @notification-message-success-color
|
||||
}
|
||||
|
||||
/*
|
||||
* Links
|
||||
*/
|
||||
.uk-link,
|
||||
.uk-button-link {
|
||||
color: @global-muted-color;
|
||||
}
|
||||
|
||||
.uk-link:hover,
|
||||
.uk-button-link:hover,
|
||||
a:hover {
|
||||
color: @global-primary-background;
|
||||
}
|
||||
|
||||
.hook-inverse() {
|
||||
|
||||
.uk-link:hover,
|
||||
.uk-button-link:hover,
|
||||
a:hover {
|
||||
color: @inverse-primary-muted-color;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Buttons
|
||||
*/
|
||||
.uk-button {
|
||||
border-radius: @button-border-radius;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.uk-button-default {
|
||||
background-color: rgba(180, 180, 180, 0.10);
|
||||
border-color: @global-border;
|
||||
}
|
||||
|
||||
.uk-button-primary {
|
||||
background-color: rgba(180, 180, 180, 0.10);
|
||||
border-color: @global-primary-background;
|
||||
color: @button-text-color;
|
||||
}
|
||||
|
||||
.uk-button-danger {
|
||||
background-color: transparent;
|
||||
color: #f0506e;
|
||||
border: 1px solid #f0506e;
|
||||
}
|
||||
|
||||
.hook-inverse() {
|
||||
.uk-button-primary {
|
||||
background-color: rgba(180, 180, 180, 0.15);
|
||||
color: @inverse-primary-muted-color;
|
||||
border-color: @inverse-primary-muted-color;
|
||||
}
|
||||
|
||||
.uk-button-primary:hover {
|
||||
background-color: @inverse-primary-muted-color;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.uk-icon-button,
|
||||
.uk-icon-button:hover {
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
/*
|
||||
* Accordion
|
||||
*/
|
||||
|
||||
.uk-accordion-title {
|
||||
display: block;
|
||||
font-size: @global-font-size;
|
||||
line-height: 1.4;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.uk-accordion-title::before {
|
||||
content: "";
|
||||
width: 1.4em;
|
||||
height: 1.4em;
|
||||
float: left;
|
||||
margin: 0 2px 0 0;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M10 17l5-5-5-5v10z'/%3E%3Cpath fill='none' d='M0 24V0h24v24H0z'/%3E%3C/svg%3E");
|
||||
mask-position: 100% 50%;
|
||||
background-image: none;
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.uk-open>.uk-accordion-title::before {
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3Cpath d='M0 0h24v24H0z' fill='none'/%3E%3C/svg%3E");
|
||||
mask-position: 100% 50%;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.uk-accordion-content {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.hook-inverse() {
|
||||
.uk-accordion-title::before {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
}
|
||||
345
openflexure_microscope/api/static/src/components/appContent.vue
Normal file
345
openflexure_microscope/api/static/src/components/appContent.vue
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
<template>
|
||||
<div
|
||||
id="app-content"
|
||||
class="uk-margin-remove uk-padding-remove uk-height-1-1"
|
||||
uk-grid
|
||||
>
|
||||
<!-- Initialisation modals -->
|
||||
<calibrationModal
|
||||
ref="calibrationModal"
|
||||
:available-plugins="plugins"
|
||||
@onClose="enterApp()"
|
||||
></calibrationModal>
|
||||
<!-- Vertical tab bar -->
|
||||
<div
|
||||
id="switcher-left"
|
||||
class="uk-flex uk-flex-column uk-padding-remove uk-width-auto uk-height-1-1 uk-text-center"
|
||||
>
|
||||
<tabIcon
|
||||
id="view-tab-icon"
|
||||
tab-i-d="view"
|
||||
:require-connection="true"
|
||||
:current-tab="currentTab"
|
||||
@set-tab="setTab"
|
||||
>
|
||||
<i class="material-icons">visibility</i>
|
||||
</tabIcon>
|
||||
|
||||
<tabIcon
|
||||
id="gallery-tab-icon"
|
||||
tab-i-d="gallery"
|
||||
:require-connection="true"
|
||||
:current-tab="currentTab"
|
||||
@set-tab="setTab"
|
||||
>
|
||||
<i class="material-icons">photo_library</i>
|
||||
</tabIcon>
|
||||
|
||||
<hr />
|
||||
|
||||
<tabIcon
|
||||
id="navigate-tab-icon"
|
||||
tab-i-d="navigate"
|
||||
:require-connection="true"
|
||||
:current-tab="currentTab"
|
||||
@set-tab="setTab"
|
||||
>
|
||||
<i class="material-icons">gamepad</i>
|
||||
</tabIcon>
|
||||
<tabIcon
|
||||
id="capture-tab-icon"
|
||||
tab-i-d="capture"
|
||||
:require-connection="true"
|
||||
:current-tab="currentTab"
|
||||
@set-tab="setTab"
|
||||
>
|
||||
<i class="material-icons">camera_alt</i>
|
||||
</tabIcon>
|
||||
<tabIcon
|
||||
id="settings-tab-icon"
|
||||
tab-i-d="settings"
|
||||
:require-connection="false"
|
||||
:current-tab="currentTab"
|
||||
@set-tab="setTab"
|
||||
>
|
||||
<i class="material-icons">settings</i>
|
||||
</tabIcon>
|
||||
|
||||
<hr id="extension-tab-divider" />
|
||||
|
||||
<tabIcon
|
||||
v-for="plugin in pluginsGuiList"
|
||||
:key="plugin.id"
|
||||
:tab-i-d="plugin.id"
|
||||
:title="plugin.title"
|
||||
:require-connection="plugin.requiresConnection"
|
||||
:current-tab="currentTab"
|
||||
:click-callback="updatePlugins"
|
||||
@set-tab="setTab"
|
||||
>
|
||||
<i class="material-icons">{{ plugin.icon || "extension" }}</i>
|
||||
</tabIcon>
|
||||
|
||||
<tabIcon
|
||||
id="about-tab-icon"
|
||||
class="uk-margin-auto-top"
|
||||
tab-i-d="about"
|
||||
:require-connection="false"
|
||||
:current-tab="currentTab"
|
||||
@set-tab="setTab"
|
||||
>
|
||||
<i class="material-icons">info</i>
|
||||
</tabIcon>
|
||||
</div>
|
||||
|
||||
<!-- Corresponding vertical tab content -->
|
||||
<div
|
||||
id="container-left"
|
||||
class="uk-padding-remove uk-height-1-1 uk-width-expand"
|
||||
>
|
||||
<tabContent
|
||||
tab-i-d="view"
|
||||
:require-connection="true"
|
||||
:current-tab="currentTab"
|
||||
>
|
||||
<viewContent />
|
||||
</tabContent>
|
||||
<tabContent
|
||||
tab-i-d="gallery"
|
||||
:require-connection="false"
|
||||
:current-tab="currentTab"
|
||||
>
|
||||
<galleryContent />
|
||||
</tabContent>
|
||||
<tabContent
|
||||
tab-i-d="navigate"
|
||||
:require-connection="true"
|
||||
:current-tab="currentTab"
|
||||
>
|
||||
<navigateContent />
|
||||
</tabContent>
|
||||
<tabContent
|
||||
tab-i-d="capture"
|
||||
:require-connection="true"
|
||||
:current-tab="currentTab"
|
||||
>
|
||||
<captureContent />
|
||||
</tabContent>
|
||||
<tabContent
|
||||
tab-i-d="settings"
|
||||
:require-connection="false"
|
||||
:current-tab="currentTab"
|
||||
>
|
||||
<settingsContent class="section-content" />
|
||||
</tabContent>
|
||||
|
||||
<tabContent
|
||||
v-for="plugin in pluginsGuiList"
|
||||
:key="plugin.id"
|
||||
:tab-i-d="plugin.id"
|
||||
:require-connection="plugin.requiresConnection"
|
||||
:current-tab="currentTab"
|
||||
>
|
||||
<extensionContent
|
||||
:forms="plugin.forms"
|
||||
:frame="plugin.frame"
|
||||
:web-component="plugin.wc"
|
||||
:view-panel="plugin.viewPanel"
|
||||
@reloadForms="updatePlugins()"
|
||||
/>
|
||||
</tabContent>
|
||||
|
||||
<tabContent
|
||||
tab-i-d="about"
|
||||
:require-connection="false"
|
||||
:current-tab="currentTab"
|
||||
>
|
||||
<aboutContent class="section-content" />
|
||||
</tabContent>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
|
||||
// Import generic components
|
||||
import tabIcon from "./genericComponents/tabIcon";
|
||||
import tabContent from "./genericComponents/tabContent";
|
||||
|
||||
// Import new content components
|
||||
import navigateContent from "./tabContentComponents/navigateContent.vue";
|
||||
import captureContent from "./tabContentComponents/captureContent.vue";
|
||||
import viewContent from "./tabContentComponents/viewContent.vue";
|
||||
import settingsContent from "./tabContentComponents/settingsContent.vue";
|
||||
import galleryContent from "./tabContentComponents/galleryContent.vue";
|
||||
import extensionContent from "./tabContentComponents/extensionContent.vue";
|
||||
import aboutContent from "./tabContentComponents/aboutContent.vue";
|
||||
|
||||
// Import modal components for device initialisation
|
||||
import calibrationModal from "./modalComponents/calibrationModal.vue";
|
||||
|
||||
// Export main app
|
||||
export default {
|
||||
name: "AppContent",
|
||||
|
||||
components: {
|
||||
tabIcon,
|
||||
tabContent,
|
||||
navigateContent,
|
||||
captureContent,
|
||||
viewContent,
|
||||
settingsContent,
|
||||
galleryContent,
|
||||
extensionContent,
|
||||
calibrationModal,
|
||||
aboutContent
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
plugins: [],
|
||||
currentTab: "view",
|
||||
unwatchStoreFunction: null
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
pluginsUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/extensions`;
|
||||
},
|
||||
pluginsGuiList: function() {
|
||||
// List of plugin GUIs, obtained from this.plugins values
|
||||
console.log("Recalculating plugins");
|
||||
var pluginGuis = [];
|
||||
for (let plugin of Object.values(this.plugins)) {
|
||||
if (plugin.meta.gui) {
|
||||
pluginGuis.push(plugin.meta.gui);
|
||||
}
|
||||
}
|
||||
return pluginGuis;
|
||||
},
|
||||
tabOrder: function() {
|
||||
// TODO: There must be a better way to do this, somehow reading the order of the HTML elements?
|
||||
var ind = ["view", "gallery", "navigate", "capture", "settings"];
|
||||
for (const plugin of this.pluginsGuiList) {
|
||||
ind.push(plugin.id);
|
||||
}
|
||||
ind.push("about");
|
||||
return ind;
|
||||
},
|
||||
currentTabIndex: function() {
|
||||
return this.tabOrder.indexOf(this.currentTab);
|
||||
}
|
||||
},
|
||||
|
||||
created: function() {
|
||||
if (this.$store.getters.ready) {
|
||||
// Detect local connection
|
||||
if (
|
||||
["localhost", "0.0.0.0", "127.0.0.1", "[::1]"].includes(
|
||||
window.location.hostname
|
||||
)
|
||||
) {
|
||||
this.$store.commit("changeSetting", ["disableStream", true]);
|
||||
this.$store.commit("changeSetting", ["autoGpuPreview", true]);
|
||||
this.$store.commit("changeSetting", ["trackWindow", true]);
|
||||
}
|
||||
// Update plugins
|
||||
this.updatePlugins().then(() => {
|
||||
// Start initialisation modals
|
||||
this.startModals();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// A global signal listener to switch tab
|
||||
this.$root.$on("globalSwitchTab", tabID => {
|
||||
this.currentTab = tabID;
|
||||
});
|
||||
// A global signal listener to increment tab
|
||||
this.$root.$on("globalIncrementTab", () => {
|
||||
this.incrementTabBy(1);
|
||||
});
|
||||
// A global signal listener to decrement tab
|
||||
this.$root.$on("globalDecrementTab", () => {
|
||||
this.incrementTabBy(-1);
|
||||
});
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
// Then we call that function here to unwatch
|
||||
if (this.unwatchStoreFunction) {
|
||||
this.unwatchStoreFunction();
|
||||
this.unwatchStoreFunction = null;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
updatePlugins: function() {
|
||||
console.log("Updating plugin forms");
|
||||
return axios
|
||||
.get(this.pluginsUri)
|
||||
.then(response => {
|
||||
console.log(response);
|
||||
this.plugins = response.data;
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
setTab: function(event, tab) {
|
||||
if (!(this.currentTab == tab)) {
|
||||
this.currentTab = tab;
|
||||
}
|
||||
},
|
||||
incrementTabBy: function(n) {
|
||||
const newIndex =
|
||||
(((this.currentTabIndex + n) % this.tabOrder.length) +
|
||||
this.tabOrder.length) %
|
||||
this.tabOrder.length;
|
||||
const newId = this.tabOrder[newIndex];
|
||||
this.currentTab = newId;
|
||||
},
|
||||
startModals: function() {
|
||||
this.$refs["calibrationModal"].show();
|
||||
},
|
||||
enterApp: function() {
|
||||
// Stuff to do once connected and all init modals are finished
|
||||
console.log("Entering main application");
|
||||
this.currentTab = "view";
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
#component-left {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#container-left {
|
||||
overflow: hidden;
|
||||
background-color: rgba(180, 180, 180, 0.025);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#switcher-left {
|
||||
border-width: 0 1px 0 0;
|
||||
border-style: solid;
|
||||
border-color: rgba(180, 180, 180, 0.25);
|
||||
}
|
||||
|
||||
#switcher-left a {
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
#switcher-left {
|
||||
width: 75px;
|
||||
background-color: rgba(180, 180, 180, 0.1);
|
||||
padding-top: 2px !important;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div>
|
||||
<form class="uk-form-stacked" @submit.prevent="overrideAPIHost">
|
||||
<label class="uk-form-label">Override API origin</label>
|
||||
<input v-model="currentOrigin" class="uk-input" type="text" />
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// Export main app
|
||||
export default {
|
||||
name: "DevTools",
|
||||
|
||||
components: {},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
currentOrigin: this.$store.state.origin
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
overrideAPIHost: function() {
|
||||
this.$store.commit("changeOrigin", this.currentOrigin);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.error-icon {
|
||||
font-size: 120px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,467 @@
|
|||
<template>
|
||||
<div id="paneCapture" class="uk-padding-small">
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text">Filename</label>
|
||||
<input
|
||||
v-model="filename"
|
||||
class="uk-input uk-width-1-1 uk-form-small"
|
||||
name="inputFilename"
|
||||
placeholder="Leave blank for default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p uk-tooltip="title: Capture will be removed automatically; delay: 500">
|
||||
<label
|
||||
><input v-model="temporary" class="uk-checkbox" type="checkbox" />
|
||||
Temporary</label
|
||||
>
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="uk-child-width-1-2" uk-grid>
|
||||
<p>
|
||||
<label
|
||||
><input
|
||||
v-model="fullResolution"
|
||||
class="uk-checkbox"
|
||||
type="checkbox"
|
||||
/>
|
||||
Full resolution</label
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<label
|
||||
><input v-model="storeBayer" class="uk-checkbox" type="checkbox" />
|
||||
Store raw data</label
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<p>
|
||||
<label
|
||||
><input v-model="resizeCapture" class="uk-checkbox" type="checkbox" />
|
||||
Resize capture</label
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="uk-child-width-1-2" uk-grid>
|
||||
<div>
|
||||
<input
|
||||
v-model="resizeDims[0]"
|
||||
:class="resizeClass"
|
||||
class="uk-input uk-form-width-medium uk-form-small"
|
||||
type="number"
|
||||
name="inputResizeW"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
v-model="resizeDims[1]"
|
||||
:class="resizeClass"
|
||||
class="uk-input uk-form-width-medium uk-form-small"
|
||||
type="number"
|
||||
name="inputResizeH"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul uk-accordion="multiple: true">
|
||||
<li>
|
||||
<a class="uk-accordion-title" href="#">Notes</a>
|
||||
<div class="uk-accordion-content">
|
||||
<div class="uk-margin-small">
|
||||
<textarea
|
||||
v-model="captureNotes"
|
||||
class="uk-textarea"
|
||||
rows="5"
|
||||
placeholder="Capture notes"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="uk-accordion-title" href="#">Annotations</a>
|
||||
<div class="uk-accordion-content">
|
||||
<keyvalList v-model="annotations" />
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="uk-accordion-title" href="#">Tags</a>
|
||||
<div class="uk-accordion-content">
|
||||
<tagList v-model="tags" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr />
|
||||
|
||||
<ul uk-accordion="multiple: true">
|
||||
<!--Show stack and scan if scan plugin is enabled-->
|
||||
<li v-if="scanUri">
|
||||
<a class="uk-accordion-title" href="#">Stack and Scan</a>
|
||||
<div class="uk-accordion-content">
|
||||
<div class="uk-margin">
|
||||
<label
|
||||
><input
|
||||
v-model="scanCapture"
|
||||
class="uk-checkbox"
|
||||
type="checkbox"
|
||||
/>
|
||||
Scan capture</label
|
||||
>
|
||||
</div>
|
||||
|
||||
<div :class="{ 'uk-disabled': !scanCapture }">
|
||||
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>x step-size</label
|
||||
>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="scanStepSize.x"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
name="inputPositionX"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>y step-size</label
|
||||
>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="scanStepSize.y"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
name="inputPositionY"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>z step-size</label
|
||||
>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="scanStepSize.z"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
name="inputPositionZx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>x steps</label
|
||||
>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="scanSteps.x"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
name="inputPositionX"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>y steps</label
|
||||
>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="scanSteps.y"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
name="inputPositionY"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>z steps</label
|
||||
>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="scanSteps.z"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
name="inputPositionZx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-margin-small uk-margin-remove-bottom">
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>Autofocus</label
|
||||
>
|
||||
<select v-model="scanDeltaZ" class="uk-select">
|
||||
<option>Off</option>
|
||||
<option>Coarse</option>
|
||||
<option>Medium</option>
|
||||
<option>Fine</option>
|
||||
<option>Fast</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="uk-margin-small uk-margin-remove-bottom">
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>Scan Style</label
|
||||
>
|
||||
<select v-model="scanStyle" class="uk-select">
|
||||
<option>Raster</option>
|
||||
<option>Snake</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="scanCapture" class="uk-margin uk-margin-remove-top ">
|
||||
<taskSubmitter
|
||||
:submit-url="scanUri"
|
||||
:submit-data="scanPayload"
|
||||
:submit-label="'Start Scan'"
|
||||
:button-primary="true"
|
||||
@submit="onScanSubmit"
|
||||
@response="onScanResponse"
|
||||
@error="onScanError"
|
||||
>
|
||||
</taskSubmitter>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="uk-button uk-button-primary uk-margin uk-margin-remove-top uk-width-1-1"
|
||||
@click="handleCapture()"
|
||||
>
|
||||
Capture
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
|
||||
import tagList from "../fieldComponents/tagList";
|
||||
import keyvalList from "../fieldComponents/keyvalList";
|
||||
|
||||
import taskSubmitter from "../genericComponents/taskSubmitter";
|
||||
|
||||
// Export main app
|
||||
export default {
|
||||
name: "PaneCapture",
|
||||
|
||||
components: {
|
||||
tagList,
|
||||
keyvalList,
|
||||
taskSubmitter
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
filename: "",
|
||||
temporary: false,
|
||||
fullResolution: false,
|
||||
storeBayer: false,
|
||||
resizeCapture: false,
|
||||
captureNotes: "",
|
||||
scanCapture: false,
|
||||
scanDeltaZ: "Fast",
|
||||
scanStyle: "Raster",
|
||||
scanStepSize: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
},
|
||||
scanSteps: {
|
||||
x: 3,
|
||||
y: 3,
|
||||
z: 5
|
||||
},
|
||||
resizeDims: [640, 480],
|
||||
tags: [],
|
||||
annotations: {
|
||||
Client: `${process.env.PACKAGE.name}.${process.env.PACKAGE.version}`
|
||||
},
|
||||
scanUri: null
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
resizeClass: function() {
|
||||
return {
|
||||
"uk-disabled": !this.resizeCapture
|
||||
};
|
||||
},
|
||||
captureActionUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/actions/camera/capture`;
|
||||
},
|
||||
pluginsUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/extensions`;
|
||||
},
|
||||
settingsFovUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/instrument/settings/fov`;
|
||||
},
|
||||
basePayload: function() {
|
||||
var payload = {};
|
||||
|
||||
// Filename
|
||||
if (this.filename) {
|
||||
payload.filename = this.filename;
|
||||
}
|
||||
|
||||
// Basic boolean params
|
||||
payload.temporary = this.temporary;
|
||||
payload.use_video_port = !this.fullResolution;
|
||||
payload.bayer = this.storeBayer;
|
||||
|
||||
// Resizing
|
||||
if (this.resizeCapture) {
|
||||
payload.resize = {
|
||||
width: this.resizeDims[0],
|
||||
height: this.resizeDims[1]
|
||||
};
|
||||
}
|
||||
|
||||
// Additional annotations
|
||||
payload.annotations = this.annotations;
|
||||
payload.tags = this.tags;
|
||||
|
||||
// Attach notes
|
||||
if (this.captureNotes) {
|
||||
payload.annotations["Notes"] = this.captureNotes;
|
||||
}
|
||||
|
||||
console.log(payload);
|
||||
|
||||
return payload;
|
||||
},
|
||||
|
||||
scanPayload: function() {
|
||||
var payload = this.basePayload;
|
||||
|
||||
// Scan params
|
||||
payload.grid = [this.scanSteps.x, this.scanSteps.y, this.scanSteps.z];
|
||||
payload.stride_size = [
|
||||
this.scanStepSize.x,
|
||||
this.scanStepSize.y,
|
||||
this.scanStepSize.z
|
||||
];
|
||||
payload.style = this.scanStyle.toLowerCase();
|
||||
|
||||
// Convert AF selector to dz
|
||||
var afDeltas = {
|
||||
Off: 0,
|
||||
Coarse: 100,
|
||||
Medium: 30,
|
||||
Fine: 10,
|
||||
Fast: 2000
|
||||
};
|
||||
|
||||
payload.autofocus_dz = afDeltas[this.scanDeltaZ];
|
||||
payload.fast_autofocus = this.scanDeltaZ == "Fast";
|
||||
|
||||
return payload;
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.updateScanUri();
|
||||
this.updateScanStepSize();
|
||||
// A global signal listener to perform a capture action
|
||||
this.$root.$on("globalCaptureEvent", () => {
|
||||
this.handleCapture();
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleCapture: function() {
|
||||
var payload = this.basePayload;
|
||||
|
||||
// Do capture
|
||||
axios
|
||||
.post(this.captureActionUri, payload)
|
||||
.then(() => {
|
||||
// Flash the stream (capture animation)
|
||||
this.$root.$emit("globalFlashStream");
|
||||
// Update the global capture list
|
||||
this.$root.$emit("globalUpdateCaptures");
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
|
||||
updateScanUri: function() {
|
||||
axios
|
||||
.get(this.pluginsUri) // Get a list of plugins
|
||||
.then(response => {
|
||||
var plugins = response.data;
|
||||
var foundExtension = plugins.find(
|
||||
e => e.title === "org.openflexure.scan"
|
||||
);
|
||||
// if ScanPlugin is enabled
|
||||
if (foundExtension) {
|
||||
// Get plugin action link
|
||||
this.scanUri = foundExtension.links.tile.href;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
|
||||
updateScanStepSize: function() {
|
||||
axios
|
||||
.get(this.settingsFovUri) // Get the microscope FOV
|
||||
.then(response => {
|
||||
this.scanStepSize = {
|
||||
x: parseInt(0.5 * response.data[0]),
|
||||
y: parseInt(0.5 * response.data[1]),
|
||||
z: 50
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
|
||||
onScanSubmit: function() {},
|
||||
|
||||
onScanResponse: function(responseData) {
|
||||
console.log("Scan finished with response data: ", responseData);
|
||||
this.modalNotify("Finished scan.");
|
||||
},
|
||||
|
||||
onScanError: function(error) {
|
||||
this.modalError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.deletable-label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deletable-label:hover {
|
||||
background-color: #f0506e;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
<template>
|
||||
<div id="paneNavigate" class="uk-padding-small">
|
||||
<div v-if="setPosition">
|
||||
<ul uk-accordion="multiple: true">
|
||||
<li>
|
||||
<a class="uk-accordion-title" href="#">Configure</a>
|
||||
<div class="uk-accordion-content">
|
||||
<div class="uk-child-width-1-2" uk-grid>
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>x-y step size</label
|
||||
>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="stepXy"
|
||||
class="uk-input uk-form-width-medium uk-form-small"
|
||||
type="number"
|
||||
name="inputStepXy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>z step size</label
|
||||
>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="stepZz"
|
||||
class="uk-input uk-form-width-medium uk-form-small"
|
||||
type="number"
|
||||
name="inputStepZz"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="uk-button uk-button-default uk-margin uk-width-1-1"
|
||||
@click="zeroRequest()"
|
||||
>
|
||||
Zero coordinates
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="uk-open">
|
||||
<a class="uk-accordion-title" href="#">Move-to</a>
|
||||
<div class="uk-accordion-content">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<!-- Text boxes to set and view position -->
|
||||
|
||||
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text">x</label>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="setPosition.x"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
name="inputPositionX"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text">y</label>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="setPosition.y"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
name="inputPositionY"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text">z</label>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="setPosition.z"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
name="inputPositionZx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<button
|
||||
type="submit"
|
||||
class="uk-button uk-button-default uk-float-right uk-width-1-1"
|
||||
>
|
||||
Move
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!--Show autofocus if default plugin is enabled-->
|
||||
<li v-show="fastAutofocusUri || normalAutofocusUri" class="uk-open">
|
||||
<a class="uk-accordion-title" href="#">Autofocus</a>
|
||||
<div class="uk-accordion-content">
|
||||
<div class="uk-grid-small uk-child-width-expand" uk-grid>
|
||||
<div v-show="!isAutofocusing || isAutofocusing == 1">
|
||||
<taskSubmitter
|
||||
v-if="fastAutofocusUri"
|
||||
:submit-url="fastAutofocusUri"
|
||||
:submit-data="{ dz: 2000 }"
|
||||
:submit-label="'Fast'"
|
||||
:button-primary="false"
|
||||
:submit-on-event="'globalFastAutofocusEvent'"
|
||||
@taskStarted="isAutofocusing = 1"
|
||||
@finished="isAutofocusing = 0"
|
||||
></taskSubmitter>
|
||||
</div>
|
||||
|
||||
<div v-show="!isAutofocusing || isAutofocusing == 2">
|
||||
<taskSubmitter
|
||||
v-if="normalAutofocusUri"
|
||||
:submit-url="normalAutofocusUri"
|
||||
:submit-data="{ dz: [-60, -30, 0, 30, 60] }"
|
||||
:submit-label="'Medium'"
|
||||
:button-primary="false"
|
||||
@taskStarted="isAutofocusing = 2"
|
||||
@finished="isAutofocusing = 0"
|
||||
></taskSubmitter>
|
||||
</div>
|
||||
|
||||
<div v-show="!isAutofocusing || isAutofocusing == 3">
|
||||
<taskSubmitter
|
||||
v-if="normalAutofocusUri"
|
||||
:submit-url="normalAutofocusUri"
|
||||
:submit-data="{ dz: [-20, -10, 0, 10, 20] }"
|
||||
:submit-label="'Fine'"
|
||||
:button-primary="false"
|
||||
@taskStarted="isAutofocusing = 3"
|
||||
@finished="isAutofocusing = 0"
|
||||
></taskSubmitter>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else class="uk-text-warning">
|
||||
<p>Stage positioning is disabled since no stage is connected.</p>
|
||||
<p>
|
||||
Please check all data and power connections to your motor controller
|
||||
board.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import taskSubmitter from "../genericComponents/taskSubmitter";
|
||||
|
||||
// Export main app
|
||||
export default {
|
||||
name: "PaneNavigate",
|
||||
|
||||
components: {
|
||||
taskSubmitter
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
stepXy: 200,
|
||||
stepZz: 50,
|
||||
setPosition: null,
|
||||
isAutofocusing: 0,
|
||||
moveLock: false,
|
||||
fastAutofocusUri: null,
|
||||
normalAutofocusUri: null,
|
||||
moveInImageCoordinatesUri: null
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
moveActionUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/actions/stage/move`;
|
||||
},
|
||||
zeroActionUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/actions/stage/zero`;
|
||||
},
|
||||
positionStatusUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/instrument/state/stage/position`;
|
||||
},
|
||||
pluginsUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/extensions`;
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// A global signal listener to perform a move action
|
||||
this.$root.$on("globalMoveEvent", (x, y, z, absolute) => {
|
||||
this.moveRequest(x, y, z, absolute);
|
||||
});
|
||||
// A global signal listener to perform a move action in pixels
|
||||
this.$root.$on("globalMoveInImageCoordinatesEvent", (x, y, absolute) => {
|
||||
this.moveInImageCoordinatesRequest(x, y, absolute);
|
||||
});
|
||||
// A global signal listener to perform a move in multiples of a step size
|
||||
this.$root.$on("globalMoveStepEvent", (x_steps, y_steps, z_steps) => {
|
||||
this.moveRequest(
|
||||
x_steps * this.stepXy,
|
||||
y_steps * this.stepXy,
|
||||
z_steps * this.stepZz,
|
||||
false
|
||||
);
|
||||
});
|
||||
// Update the current position in text boxes
|
||||
this.updatePosition();
|
||||
// Look for autofocus plugin
|
||||
this.updateAutofocusUri();
|
||||
this.updateMoveInImageCoordinatesUri();
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
// Remove global signal listener to perform a move action
|
||||
this.$root.$off("globalMoveEvent");
|
||||
this.$root.$off("globalMoveInImageCoordinatesEvent");
|
||||
this.$root.$off("globalMoveStepEvent");
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleSubmit: function() {
|
||||
this.moveRequest(
|
||||
this.setPosition.x,
|
||||
this.setPosition.y,
|
||||
this.setPosition.z,
|
||||
true
|
||||
);
|
||||
},
|
||||
|
||||
moveRequest: function(x, y, z, absolute) {
|
||||
console.log(`Sending move request of ${x}, ${y}, ${z}`);
|
||||
// If not movement-locked
|
||||
if (!this.moveLock) {
|
||||
// Lock move requests
|
||||
this.moveLock = true;
|
||||
|
||||
// Send move request
|
||||
axios
|
||||
.post(this.moveActionUri, {
|
||||
x: x,
|
||||
y: y,
|
||||
z: z,
|
||||
absolute: absolute
|
||||
})
|
||||
.then(() => {
|
||||
this.updatePosition(); // Update the position in text boxes
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
})
|
||||
.then(() => {
|
||||
this.moveLock = false; // Release the move lock
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
moveInImageCoordinatesRequest: function(x, y) {
|
||||
console.log(`Sending move request in image coordinates: ${x}, ${y}`);
|
||||
// If not movement-locked
|
||||
if (!this.moveLock) {
|
||||
// Lock move requests
|
||||
this.moveLock = true;
|
||||
|
||||
if (!this.moveInImageCoordinatesUri) {
|
||||
this.modalError(
|
||||
"Moving in image coordinates is not supported - you will need to upgrade your microscope's software."
|
||||
);
|
||||
}
|
||||
|
||||
// Send move request
|
||||
axios
|
||||
.post(this.moveInImageCoordinatesUri, {
|
||||
x: y, // NB the coordinates are numpy/PIL style, meaning X and Y are swapped.
|
||||
y: x
|
||||
})
|
||||
.then(() => {
|
||||
this.updatePosition(); // Update the position in text boxes
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
})
|
||||
.then(() => {
|
||||
this.moveLock = false; // Release the move lock
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
zeroRequest: function() {
|
||||
// Send move request
|
||||
axios
|
||||
.post(this.zeroActionUri)
|
||||
.then(() => {
|
||||
this.updatePosition(); // Update the position in text boxes
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
|
||||
updatePosition: function() {
|
||||
axios
|
||||
.get(this.positionStatusUri)
|
||||
.then(response => {
|
||||
this.setPosition = response.data;
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
|
||||
updateAutofocusUri: function() {
|
||||
axios
|
||||
.get(this.pluginsUri) // Get a list of plugins
|
||||
.then(response => {
|
||||
var plugins = response.data;
|
||||
var foundExtension = plugins.find(
|
||||
e => e.title === "org.openflexure.autofocus"
|
||||
);
|
||||
// if ScanPlugin is enabled
|
||||
if (foundExtension) {
|
||||
// Get plugin action link
|
||||
this.fastAutofocusUri = foundExtension.links.fast_autofocus.href;
|
||||
this.normalAutofocusUri = foundExtension.links.autofocus.href;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
|
||||
updateMoveInImageCoordinatesUri: function() {
|
||||
axios
|
||||
.get(this.pluginsUri) // Get a list of plugins
|
||||
.then(response => {
|
||||
var plugins = response.data;
|
||||
var foundExtension = plugins.find(
|
||||
e => e.title === "org.openflexure.camera_stage_mapping"
|
||||
);
|
||||
if (foundExtension) {
|
||||
// Get plugin action link
|
||||
this.moveInImageCoordinatesUri =
|
||||
foundExtension.links.move_in_image_coordinates.href;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<div>
|
||||
<label>{{ label }}</label>
|
||||
|
||||
<div class="uk-form-controls">
|
||||
<div v-for="option in options" :key="option">
|
||||
<label>
|
||||
<input
|
||||
class="uk-checkbox"
|
||||
type="checkbox"
|
||||
:value="option"
|
||||
:checked="value && value.includes(option)"
|
||||
v-bind="$attrs"
|
||||
@change="updateValue($event.target)"
|
||||
/>
|
||||
{{ option }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "CheckList",
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: function() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateValue(target) {
|
||||
var newSelected = this.value != null ? [...this.value] : []; // Clone value array
|
||||
|
||||
if (target.checked) {
|
||||
if (!newSelected.includes(target.value)) {
|
||||
newSelected.push(target.value);
|
||||
}
|
||||
} else {
|
||||
if (newSelected.includes(target.value)) {
|
||||
newSelected = newSelected.filter(function(value) {
|
||||
return value != target.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
this.$emit("input", newSelected);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<div>
|
||||
<p v-html="content"></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "HtmlBlock",
|
||||
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ""
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
<template>
|
||||
<div>
|
||||
<form @submit.prevent="handleMetadataSubmit">
|
||||
<div class="uk-margin-remove uk-flex uk-flex-middle">
|
||||
<div
|
||||
class="uk-margin-remove-top uk-padding-remove uk-grid-small uk-width-expand"
|
||||
uk-grid
|
||||
>
|
||||
<div class="uk-margin-remove uk-width-1-2">
|
||||
<input
|
||||
ref="textboxKey"
|
||||
v-model="newMetadata.key"
|
||||
class="uk-input uk-form-width-small uk-form-small"
|
||||
type="text"
|
||||
name="flavor"
|
||||
placeholder="Key"
|
||||
/>
|
||||
</div>
|
||||
<div class="uk-margin-remove uk-width-1-2">
|
||||
<input
|
||||
v-model="newMetadata.value"
|
||||
class="uk-input uk-form-width-small uk-form-small"
|
||||
type="text"
|
||||
name="flavor"
|
||||
placeholder="Value"
|
||||
@keyup.enter="handleMetadataSubmit()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
class="uk-icon uk-margin-left"
|
||||
@click="handleMetadataSubmit()"
|
||||
><i class="material-icons">add_circle</i></a
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-for="(val, key) in value"
|
||||
:key="key"
|
||||
class="uk-width-1-1 uk-margin-small uk-margin-remove-left uk-margin-remove-right uk-flex uk-flex-middle"
|
||||
>
|
||||
<div class="uk-margin-remove-top uk-padding-remove uk-width-expand">
|
||||
<labelInput
|
||||
:name="key"
|
||||
:label="key"
|
||||
:value="value[key]"
|
||||
@input="value[key] = $event"
|
||||
/>
|
||||
</div>
|
||||
<a href="#" class="uk-icon uk-width-auto" @click="delMetadataKey(key)"
|
||||
><i class="material-icons">delete</i></a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import labelInput from "../fieldComponents/labelInput";
|
||||
|
||||
export default {
|
||||
name: "KeyvalList",
|
||||
|
||||
components: {
|
||||
labelInput
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
newMetadata: {
|
||||
key: "",
|
||||
value: ""
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleMetadataSubmit: function() {
|
||||
var newSelected = {};
|
||||
|
||||
if (this.value != null) {
|
||||
Object.assign(newSelected, this.value);
|
||||
}
|
||||
|
||||
newSelected[this.newMetadata.key] = this.newMetadata.value;
|
||||
this.newMetadata.key = "";
|
||||
this.newMetadata.value = "";
|
||||
|
||||
this.$emit("input", newSelected);
|
||||
|
||||
// Move focus back to key textbox
|
||||
this.$refs.textboxKey.focus();
|
||||
},
|
||||
|
||||
delMetadataKey: function(key) {
|
||||
var newSelected = {};
|
||||
|
||||
if (this.value != null) {
|
||||
Object.assign(newSelected, this.value);
|
||||
}
|
||||
|
||||
this.$delete(newSelected, key);
|
||||
|
||||
this.$emit("input", newSelected);
|
||||
},
|
||||
|
||||
modifyValue: function(e, v) {
|
||||
console.log(e);
|
||||
console.log(v);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<div>
|
||||
<label class="uk-form-label uk-text-bold">{{ label }}</label>
|
||||
|
||||
<div
|
||||
uk-tooltip="title: Click to edit value; delay: 250"
|
||||
@click="setEditing(true)"
|
||||
>
|
||||
<div v-show="editing == false">
|
||||
<label> {{ value }} </label>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-show="editing == true"
|
||||
ref="textinput"
|
||||
class="uk-input uk-form-small"
|
||||
type="text"
|
||||
:name="name"
|
||||
:value="value"
|
||||
autofocus
|
||||
@blur="setEditing(false)"
|
||||
@keyup.enter="setEditing(false)"
|
||||
@input="$emit('input', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "LabelInput",
|
||||
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
editing: false
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
setEditing(editing) {
|
||||
this.editing = editing;
|
||||
if (editing == true) {
|
||||
this.$nextTick(() => this.$refs.textinput.focus());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<div>
|
||||
<label class="uk-form-label">{{ label }}</label>
|
||||
|
||||
<input
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
:name="name"
|
||||
:value="value"
|
||||
:placeholder="placeholder"
|
||||
v-bind="$attrs"
|
||||
@input="$emit('input', Number($event.target.value))"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "NumberInput",
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 0
|
||||
},
|
||||
placeholder: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 0
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<template>
|
||||
<div>
|
||||
<label>{{ label }}</label>
|
||||
|
||||
<div class="uk-form-controls">
|
||||
<div v-for="option in options" :key="option">
|
||||
<label
|
||||
><input
|
||||
class="uk-radio"
|
||||
type="radio"
|
||||
:name="name"
|
||||
:value="option"
|
||||
:checked="value == option"
|
||||
@input="$emit('input', $event.target.value)"
|
||||
/>
|
||||
{{ option }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "RadioList",
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ""
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<div>
|
||||
<label class="uk-form-label">{{ label }}</label>
|
||||
|
||||
<select
|
||||
class="uk-select uk-form-small"
|
||||
:value="value"
|
||||
v-bind="$attrs"
|
||||
@input="$emit('input', $event.target.value)"
|
||||
>
|
||||
<option v-for="option in options" :key="option">
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "SelectList",
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ""
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<template>
|
||||
<div>
|
||||
<form @submit.prevent="handleTagSubmit">
|
||||
<div class="uk-margin-small uk-flex uk-flex-middle">
|
||||
<div class="uk-margin-remove-top uk-width-expand">
|
||||
<div class="uk-inline uk-width-1-1">
|
||||
<span class="uk-form-icon"
|
||||
><i class="material-icons">label</i></span
|
||||
>
|
||||
<input
|
||||
v-model="newTag"
|
||||
class="uk-input uk-form-small"
|
||||
type="text"
|
||||
name="flavor"
|
||||
placeholder="Tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="#" class="uk-icon uk-margin-left" @click="handleTagSubmit()"
|
||||
><i class="material-icons">add_circle</i></a
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<span
|
||||
v-for="tag in value"
|
||||
:key="tag"
|
||||
class="uk-label uk-margin-small-right deletable-label"
|
||||
@click="delTag(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "TagList",
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
newTag: ""
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleTagSubmit: function() {
|
||||
var newSelected = this.value != null ? [...this.value] : []; // Clone value array
|
||||
|
||||
newSelected.push(this.newTag);
|
||||
this.newTag = "";
|
||||
|
||||
this.$emit("input", newSelected);
|
||||
},
|
||||
|
||||
delTag: function(tag) {
|
||||
var newSelected = this.value != null ? [...this.value] : []; // Clone value array
|
||||
|
||||
if (newSelected.includes(tag)) {
|
||||
newSelected = newSelected.filter(function(value) {
|
||||
return value != tag;
|
||||
});
|
||||
}
|
||||
|
||||
this.$emit("input", newSelected);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<div>
|
||||
<label v-if="label" class="uk-form-label">{{ label }}</label>
|
||||
|
||||
<input
|
||||
class="uk-input uk-form-small"
|
||||
type="text"
|
||||
:name="name"
|
||||
:value="value"
|
||||
:placeholder="placeholder"
|
||||
v-bind="$attrs"
|
||||
@input="$emit('input', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "TextInput",
|
||||
|
||||
props: {
|
||||
placeholder: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ""
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
<template>
|
||||
<div
|
||||
class="progress uk-margin-top uk-margin-horizontal-remove uk-padding-remove"
|
||||
>
|
||||
<div class="indeterminate"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ProgressBar",
|
||||
|
||||
props: {},
|
||||
|
||||
computed: {
|
||||
tooltipOptions: function() {
|
||||
var title = this.id.charAt(0).toUpperCase() + this.id.slice(1);
|
||||
return `pos: right; title: ${title}; delay: 500`;
|
||||
},
|
||||
|
||||
classObject: function() {
|
||||
return {
|
||||
"tabicon-active": this.currentTab == this.id,
|
||||
"uk-disabled": this.requireConnection && !this.$store.getters.ready
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
setThisTab(event) {
|
||||
this.$emit("set-tab", event, this.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import "../../assets/less/theme.less";
|
||||
|
||||
.progress {
|
||||
position: relative;
|
||||
height: 5px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
background-color: rgba(180, 180, 180, 0.15);
|
||||
border-radius: 2px;
|
||||
background-clip: padding-box;
|
||||
margin: 0.5rem 0 1rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress .determinate {
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
transition: width 0.3s linear;
|
||||
}
|
||||
|
||||
.progress .indeterminate,
|
||||
.progress .determinate {
|
||||
background-color: @global-primary-background;
|
||||
}
|
||||
|
||||
.hook-inverse() {
|
||||
.progress .indeterminate,
|
||||
.progress .determinate {
|
||||
background-color: @inverse-primary-muted-color;
|
||||
}
|
||||
}
|
||||
|
||||
.progress .indeterminate:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
will-change: left, right;
|
||||
-webkit-animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395)
|
||||
infinite;
|
||||
animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
|
||||
}
|
||||
|
||||
.progress .indeterminate:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
will-change: left, right;
|
||||
-webkit-animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
|
||||
infinite;
|
||||
animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
|
||||
infinite;
|
||||
-webkit-animation-delay: 1.15s;
|
||||
animation-delay: 1.15s;
|
||||
}
|
||||
|
||||
@-webkit-keyframes indeterminate {
|
||||
0% {
|
||||
left: -35%;
|
||||
right: 100%;
|
||||
}
|
||||
60% {
|
||||
left: 100%;
|
||||
right: -90%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
right: -90%;
|
||||
}
|
||||
}
|
||||
@keyframes indeterminate {
|
||||
0% {
|
||||
left: -35%;
|
||||
right: 100%;
|
||||
}
|
||||
60% {
|
||||
left: 100%;
|
||||
right: -90%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
right: -90%;
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes indeterminate-short {
|
||||
0% {
|
||||
left: -200%;
|
||||
right: 100%;
|
||||
}
|
||||
60% {
|
||||
left: 107%;
|
||||
right: -8%;
|
||||
}
|
||||
100% {
|
||||
left: 107%;
|
||||
right: -8%;
|
||||
}
|
||||
}
|
||||
@keyframes indeterminate-short {
|
||||
0% {
|
||||
left: -200%;
|
||||
right: 100%;
|
||||
}
|
||||
60% {
|
||||
left: 107%;
|
||||
right: -8%;
|
||||
}
|
||||
100% {
|
||||
left: 107%;
|
||||
right: -8%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="!(requireConnection && !$store.getters.ready)"
|
||||
:hidden="currentTab != tabID"
|
||||
class="uk-width-expand uk-height-1-1"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "TabContent",
|
||||
|
||||
props: {
|
||||
tabID: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
currentTab: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
requireConnection: Boolean
|
||||
},
|
||||
computed: {},
|
||||
|
||||
methods: {}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.section-heading {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
line-height: 20px;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
<template>
|
||||
<a
|
||||
href="#"
|
||||
class="uk-link"
|
||||
:class="classObject"
|
||||
:uk-tooltip="tooltipOptions"
|
||||
@click="setThisTab"
|
||||
>
|
||||
<slot></slot>
|
||||
<div v-if="showTitle" class="tabtitle">
|
||||
{{ computedTitle }}
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "TabIcon",
|
||||
|
||||
props: {
|
||||
tabID: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
showTitle: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
showTooltip: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
currentTab: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
clickCallback: {
|
||||
type: Function,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
requireConnection: Boolean
|
||||
},
|
||||
|
||||
computed: {
|
||||
computedTitle: function() {
|
||||
if (this.title !== undefined) {
|
||||
return this.title;
|
||||
} else {
|
||||
// Get the last section of a fully qualified name
|
||||
var topName = this.tabID.split(".").pop();
|
||||
// Make first character uppercase, then add the rest of the string
|
||||
return topName.charAt(0).toUpperCase() + topName.slice(1);
|
||||
}
|
||||
},
|
||||
|
||||
tooltipOptions: function() {
|
||||
if (this.showTooltip) {
|
||||
return `pos: right; title: ${this.computedTitle}; delay: 500`;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
classObject: function() {
|
||||
return {
|
||||
"tabicon-active": this.currentTab == this.tabID,
|
||||
"uk-disabled": this.requireConnection && !this.$store.getters.ready
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
setThisTab(event) {
|
||||
this.$emit("set-tab", event, this.tabID);
|
||||
if (this.clickCallback) {
|
||||
this.clickCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// Custom UIkit CSS modifications
|
||||
@import "../../assets/less/theme.less";
|
||||
|
||||
.tabicon-active {
|
||||
color: #fff !important;
|
||||
background-color: @global-primary-background !important;
|
||||
box-shadow: @small-shadow;
|
||||
}
|
||||
|
||||
.tabtitle {
|
||||
max-width: 60px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
.uk-link:hover {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue