First images on labthings-fastapi

We now have very basic camera and stage support.
This commit is contained in:
Richard Bowman 2023-08-29 17:02:16 +01:00
parent ac45423739
commit 081654533f
100 changed files with 60 additions and 10149 deletions

View file

@ -1,12 +0,0 @@
HTTP API
========
Live documentation
------------------
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``.
The API is described in an OpenAPI description, available at ``http://{your microscope IP address}/api/v2/docs/openapi.yaml``. It is also available from our `build server`_. It can be conveniently viewed using `Redoc's online preview`_.
.. _`build server`: https://build.openflexure.org/openflexure-microscope-server/
.. _`Redoc's online preview`: https://redocly.github.io/redoc/?url=https://build.openflexure.org/openflexure-microscope-server/latest-api.yaml

View file

@ -1,5 +0,0 @@
Base Streaming Camera
=======================================================
.. automodule:: openflexure_microscope.camera.base
:members:

View file

@ -1,5 +0,0 @@
Base Microscope Stage
=====================
.. automodule:: openflexure_microscope.stage.base
:members:

View file

@ -1,35 +0,0 @@
Camera Class
============
.. toctree::
:maxdepth: 2
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)

View file

@ -1,11 +0,0 @@
Capture Object
=======================================================
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 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.captures.capture
:members:

View file

@ -1,253 +0,0 @@
# -*- coding: utf-8 -*-
#
# Configuration file for the Sphinx documentation builder.
#
# This file does only contain a selection of the most common options. For a
# full list see the documentation:
# http://www.sphinx-doc.org/en/master/config
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
# Workaround to install and execute git-lfs on Read the Docs
if os.system("git lfs env > /dev/null") == 0:
os.system("git lfs fetch")
os.system("git lfs checkout")
else:
print(
"Warning: it seems git lfs is not installed. Bodging our way around the problem to retrieve images..."
)
os.system(
"wget https://github.com/git-lfs/git-lfs/releases/download/v2.7.1/git-lfs-linux-amd64-v2.7.1.tar.gz"
)
os.system("tar xvfz git-lfs-linux-amd64-v2.7.1.tar.gz")
os.system("./git-lfs install") # make lfs available in current repository
os.system("./git-lfs fetch") # download content from remote
os.system("./git-lfs checkout") # make local files to have the real content on them
# Load module from relative imports by modifying the path
module_path = os.path.abspath("../..")
sys.path.insert(0, module_path)
# Handle mock imports for non-platform-agnostic modules
# This allows modules to load that depend on hardware that's not present, e.g.
# the `picamera` related modules. They won't *work* but if they load, we can
# extract the docstrings, which is what we care about here.
from unittest.mock import MagicMock
class Mock(MagicMock):
@classmethod
def __getattr__(cls, name):
return MagicMock()
mock_imports = ["picamerax", "picamerax.array", "picamerax.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" # pylint: disable=redefined-builtin
author = "Bath Open Instrumentation Group"
# TODO: extract version from ../setup.py
# The short X.Y version
version = ""
# The full version, including alpha/beta/rc tags
release = ""
# -- General configuration ---------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.napoleon",
"sphinx.ext.intersphinx",
"sphinx.ext.todo",
"sphinx.ext.viewcode",
"sphinx.ext.githubpages",
"sphinx.ext.ifconfig",
]
# Override ordering
autodoc_member_order = "bysource"
# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = ".rst"
# The master toctree document.
master_doc = "index"
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = []
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = None
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = "sphinx_rtd_theme"
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#
# html_theme_options = {}
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ["_static"]
# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# The default sidebars (for documents that don't match any pattern) are
# defined by theme itself. Builtin themes are using these templates by
# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
# 'searchbox.html']``.
#
# html_sidebars = {}
# -- Options for HTMLHelp output ---------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = "OpenFlexureMicroscopeSoftwaredoc"
# -- Options for LaTeX output ------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(
master_doc,
"OpenFlexureMicroscopeSoftware.tex",
"OpenFlexure Microscope Software Documentation",
"Bath Open Instrumentation Group",
"manual",
)
]
# -- Options for manual page output ------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(
master_doc,
"openflexuremicroscopesoftware",
"OpenFlexure Microscope Software Documentation",
[author],
1,
)
]
# -- Options for Texinfo output ----------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(
master_doc,
"OpenFlexureMicroscopeSoftware",
"OpenFlexure Microscope Software Documentation",
author,
"OpenFlexureMicroscopeSoftware",
"One line description of project.",
"Miscellaneous",
)
]
# -- Options for Epub output -------------------------------------------------
# Bibliographic Dublin Core info.
epub_title = project
# The unique identifier of the text. This can be a ISBN number
# or the project homepage.
#
# epub_identifier = ''
# A unique identification for the text.
#
# epub_uid = ''
# A list of files that should not be packed into the epub file.
epub_exclude_files = ["search.html"]
# -- Extension configuration -------------------------------------------------
# -- Options for intersphinx extension ---------------------------------------
# Example configuration for intersphinx: refer to the Python standard library.
# TODO: update with pysangaboard?
intersphinx_mapping = {
"openflexure_stage": ("https://openflexure-stage.readthedocs.io/en/latest/", None),
"picamerax": ("https://picamerax.readthedocs.io/en/latest//", None),
"marshmallow": ("https://marshmallow.readthedocs.io/en/stable/", None),
"webargs": ("https://webargs.readthedocs.io/en/latest/", None),
}
# -- Options for todo extension ----------------------------------------------
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = True

View file

@ -1,21 +0,0 @@
Microscope settings
===================
.. toctree::
:maxdepth: 2
:caption: Contents:
.. _MicroscopeRC:
Microscope settings file
------------------------
Microscope settings are made persistent via a microscope settings file. By default, this
file exists at ``~/.openflexure/microscope_settings.json``.
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.
.. automodule:: openflexure_microscope.config
:members:

View file

@ -1,56 +0,0 @@
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(load_default=True)}
# Our success response (200) returns an image (image/jpeg mimetype)
responses = {
200: {
"content": { "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

View file

@ -1,223 +0,0 @@
OpenFlexure eV GUI
==================
Introduction
------------
The main client application for the OpenFlexure Microscope, OpenFlexure eV, can render simple GUIs (graphical user interfaces) for extensions.
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
# Create your extension object
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")
...
my_extension.add_meta("myKey", "My metadata value")
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 ``gui``
---------------------------
Root level
++++++++++
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 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:
``name`` - A human-readable name for the form
``route`` - String of the corresponding API route. *Must* match a route defined in your ``api_views`` dictionary
``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`` - 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 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.
An overview of available components, and their properties, can be found below.
Arranging components
^^^^^^^^^^^^^^^^^^^^
You can request that the client render several components in a horizontal grid by placing them in an array. You cannot nest arrays however. Each component in the array will be rendered with equal width as far as possible.
Overview of components
----------------------
.. list-table::
:widths: 10 10 40 20
:header-rows: 1
:stub-columns: 1
* - fieldType
- Data type
- Properties
- Example
* - checkList
- [str, str,...]
- **name** (str) Unique name of the component
**label** (str) Friendly label for the component
**value** ([str, str,...]) List of selected options
**options** ([str, str,...]) List of all options
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/checkList.png
* - htmlBlock
- N/A
- **name** (str) Unique name of the component
**label** (str) Friendly label for the component
**content** (str) HTML string to be rendered
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/htmlBlock.png
* - keyvalList
- dict
- **name** (str) Unique name of the component
**value** (dict) Dictionary of key-value pairs
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/keyvalList.png
* - labelInput
- str
- **name** (str) Unique name of the component
**label** (str) Friendly label for the component
**value** (str) Value of the editable label text
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/labelInput.png
* - numberInput
- int
- **name** (str) Unique name of the component
**label** (str) Friendly label for the component
**value** (int) Value of the input
**placeholder** (int) Placeholder value
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/numberInput.png
* - radioList
- String
- **name** (str) Unique name of the component
**label** (str) Friendly label for the component
**value** (str) Currently selected option
**options** ([str, str,...]) List of all options
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/radioList.png
* - selectList
- str
- **name** (str) Unique name of the component
**label** (str) Friendly label for the component
**value** (str) Currently selected option
**options** ([str, str,...]) List of all options
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/selectList.png
* - tagList
- [str, str,...]
- **name** (str) Unique name of the component
**value** ([str, str,...]) List of tag strings
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/tagList.png
* - textInput
- str
- **name** (str) Unique name of the component
**label** (str) Friendly label for the component
**value** (int) Value of the input
**placeholder** (str) Placeholder value
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/textInput.png
**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.
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))
Complete example
----------------
Adding a GUI to our previous timelapse example extension becomes:
.. literalinclude:: ./example_extension/07_ev_gui.py

View file

@ -1,36 +0,0 @@
from labthings import find_component
from labthings.extensions import BaseExtension
# Create the extension class
class MyExtension(BaseExtension):
def __init__(self):
# 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()
LABTHINGS_EXTENSIONS = (MyExtension,)

View file

@ -1,65 +0,0 @@
from labthings import fields, find_component
from labthings.extensions import BaseExtension
from labthings.views import View
# Create the extension class
class MyExtension(BaseExtension):
def __init__(self):
# Superclass init function
super().__init__("com.myname.myextension", version="0.0.0")
# Add our API Views (defined below MyExtension)
self.add_view(ExampleIdentifyView, "/identify")
self.add_view(ExampleRenameView, "/rename")
def identify(self, 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(self, 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 self.extension.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, metadata={"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
self.extension.rename(microscope, new_name)
# Return our identify function's output
return self.extension.identify(microscope)
LABTHINGS_EXTENSIONS = (MyExtension,)

View file

@ -1,73 +0,0 @@
from labthings import Schema, fields, find_component
from labthings.extensions import BaseExtension
from labthings.views import View
# Create the extension class
class MyExtension(BaseExtension):
def __init__(self):
# Superclass init function
super().__init__("com.myname.myextension", version="0.0.0")
# Add our API Views (defined below MyExtension)
self.add_view(ExampleIdentifyView, "/identify")
self.add_view(ExampleRenameView, "/rename")
def rename(self, microscope, new_name):
"""
Rename the microscope
"""
microscope.name = new_name
microscope.save_settings()
# 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)
## 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, metadata={"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
self.extension.rename(microscope, new_name)
# Return our microscope object,
# let schema handle formatting the output
return microscope
LABTHINGS_EXTENSIONS = (MyExtension,)

View file

@ -1,89 +0,0 @@
from labthings import Schema, fields, find_component
from labthings.extensions import BaseExtension
from labthings.views import PropertyView
# Create the extension class
class MyExtension(BaseExtension):
def __init__(self):
# Superclass init function
super().__init__("com.myname.myextension", version="0.0.0")
# Add our API Views (defined below MyExtension)
self.add_view(ExampleIdentifyView, "/identify")
self.add_view(ExampleRenameView, "/rename")
def rename(self, microscope, new_name):
"""
Rename the microscope
"""
microscope.name = new_name
microscope.save_settings()
# 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)
## Extension viewss
# 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, metadata={"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
self.extension.rename(microscope, new_name)
# Return our microscope object,
# let schema handle formatting the output
return microscope
LABTHINGS_EXTENSIONS = (MyExtension,)

View file

@ -1,123 +0,0 @@
import io # Used in our capture action
from flask import send_file # Used to send images from our server
from labthings import Schema, fields, find_component
from labthings.extensions import BaseExtension
from labthings.views import ActionView, PropertyView
# Create the extension class
class MyExtension(BaseExtension):
def __init__(self):
# Superclass init function
super().__init__("com.myname.myextension", version="0.0.0")
# Add our API Views (defined below MyExtension)
self.add_view(ExampleIdentifyView, "/identify")
self.add_view(ExampleRenameView, "/rename")
def rename(self, microscope, new_name):
"""
Rename the microscope
"""
microscope.name = new_name
microscope.save_settings()
# 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)
## 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, metadata={"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
self.extension.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(load_default=True)}
# Our success response (200) returns an image (image/jpeg mimetype)
responses = {200: {"content": {"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")
LABTHINGS_EXTENSIONS = (MyExtension,)

View file

@ -1,87 +0,0 @@
import time # Used in our timelapse function
from labthings import current_action, fields, find_component, update_action_progress
from labthings.extensions import BaseExtension
from labthings.views import ActionView
# Used in our timelapse function
from openflexure_microscope.captures.capture_manager import generate_basename
# Create the extension class
class TimelapseExtension(BaseExtension):
def __init__(self):
# Superclass init function
super().__init__("org.openflexure.timelapse-extension", version="0.0.0")
# Add our API views
self.add_view(TimelapseAPIView, "/timelapse")
def timelapse(self, 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):
# Elegantly handle action cancellation
if current_action() and current_action().stopped:
return
# 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 TimelapseAPIView(ActionView):
"""
Take a series of images in a timelapse
"""
args = {
"n_images": fields.Integer(
required=True, metadata={"example": 5, "description": "Number of images"}
),
"t_between": fields.Number(
load_default=1,
metadata={"example": 1, "description": "Time (seconds) between images"},
),
}
def post(self, args):
# Find our microscope component
microscope = find_component("org.openflexure.microscope")
# Start "timelapse"
return self.extension.timelapse(
microscope, args.get("n_images"), args.get("t_between")
)
LABTHINGS_EXTENSIONS = (TimelapseExtension,)

View file

@ -1,120 +0,0 @@
import time # Used in our timelapse function
from labthings import current_action, fields, find_component, update_action_progress
from labthings.extensions import BaseExtension
from labthings.views import ActionView
# Used to convert our GUI dictionary into a complete eV extension GUI
from openflexure_microscope.api.utilities.gui import build_gui
# Used in our timelapse function
from openflexure_microscope.captures.capture_manager import generate_basename
# Create the extension class
class TimelapseExtension(BaseExtension):
def __init__(self):
# Superclass init function
super().__init__("org.openflexure.timelapse-extension", version="0.0.0")
# Add our API views
self.add_view(TimelapseAPIView, "/timelapse")
# Add our GUI description
gui_description = {
"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
},
],
}
],
}
self.add_meta("gui", build_gui(gui_description, self))
def timelapse(self, 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):
# Elegantly handle action cancellation
if current_action() and current_action().stopped:
return
# 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 TimelapseAPIView(ActionView):
"""
Take a series of images in a timelapse
"""
args = {
"n_images": fields.Integer(
required=True, metadata={"example": 5, "description": "Number of images"}
),
"t_between": fields.Number(
load_default=1,
metadata={"example": 1, "description": "Time (seconds) between images"},
),
}
def post(self, args):
# Find our microscope component
microscope = find_component("org.openflexure.microscope")
# Start "timelapse"
return self.extension.timelapse(
microscope, args.get("n_images"), args.get("t_between")
)
LABTHINGS_EXTENSIONS = (TimelapseExtension,)

View file

@ -1,30 +0,0 @@
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:`picamerax.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 classes, and includes them in a global constant `LABTHINGS_EXTENSIONS` list.
For example, if your extension classes are defined in a file ``my_extension.py``, your adjascent ``__init__.py`` file may look like:
.. code-block:: python
from .my_extension import MyExtensionClass, MyOtherExtensionClass
LABTHINGS_EXTENSIONS = (MyExtensionClass, MyOtherExtensionClass)
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.

View file

@ -1,53 +0,0 @@
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

View file

@ -1,171 +0,0 @@
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, load_default="Default value", metadata={"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.schema import Schema
from labthings import fields
class UserSchema(Schema):
name = fields.String(required=True)
age = fields.Integer(required=True)
job = fields.String(required=False, load_default="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, load_default="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, load_default="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, metadata={"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

View file

@ -1,114 +0,0 @@
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, metadata={"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

View file

@ -1,28 +0,0 @@
Basic extension structure
=========================
An extension starts as a subclass of :py:class:`labthings.extensions.BaseExtension`.
Each extension is described by a single ``BaseExtension`` instance, containing any number of methods, API views, and additional hardware components.
You will build your extension by subclassing :py:class:`labthings.extensions.BaseExtension`, and adding the class to a top-level `LABTHINGS_EXTENSIONS` list.
In order to access the currently running microscope object, use the :py:func:`labthings.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 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()

View file

@ -1,107 +0,0 @@
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 stopping 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 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.
Accessing the current action instance
+++++++++++++++++++++++++++++++++++++
Every time a user requests your action, a new :class:`labthings.actions.ActionThread` instance is created to hold the state of your action. This object holds return values, errors, action progress and status, and handles action cancellation.
In some cases, your action function will need to access the currently running :class:`labthings.actions.ActionThread` instance. The :func:`labthings.current_action` function will return the currently running :class:`labthings.actions.ActionThread` instance if it's called from within an ``ActonThread``, and will return ``None`` if running outside of an `ActionThread`.
Handling action cancellation
++++++++++++++++++++++++++++
Users always have the option to stop an action while it's running. Your action function has the option to support an elegant cancellation by watching for cancellation requests on the running :class:`labthings.actions.ActionThread` instance.
The ``labthings.current_action().stopped`` attribute will return ``True`` if the Action has been requested to stop, and ``False`` otherwise. If your action runs a loop, this can be checked at each iteration, and used to return early if the action has been stopped.
If a stop request is sent and your action does not return within a timeout (by default 5 seconds), then the thread will be forcefully terminated. This is to ensure that actions can be stopped even if they have become stuck, or would otherwise take an unexpected amount of time. However, every effort should be made to handle action cancellation elegantly from within the action.
An example of elegant action cancellation is included in the example later on this page.
The ``ActionView.default_stop_timeout`` class attribute can be used to increase or descrease the forced cancellation timeout. Developers should carefully consider how long their action should take to elegantly stop, and avoid abusing this timeout override to simply prevent forceful cancelltion.
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.

View file

@ -1,44 +0,0 @@
Adding web API views
====================
Key terminology
---------------
API View (or View)
++++++++++++++++++
*"A view function is the code you write to respond to requests to your application [...] For RESTful APIs its especially helpful to execute a different function for each HTTP method. With the [View class] you can easily do that. Each HTTP method maps to a function with the same name (just in lowercase)"* - `Flask documentation <https://flask.palletsprojects.com/en/1.1.x/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.
Your extension functions can be accessed from within an API View by using ``self.extension``. Once your view has been added to your extension, this will point to the extension object, allowing your API views to use your extension functionality.
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``.

View file

@ -1,23 +0,0 @@
Welcome to OpenFlexure Microscope Software's documentation!
===========================================================
.. toctree::
:maxdepth: 2
:caption: Contents:
quickstart.rst
webapp/index.rst
config.rst
microscope.rst
camera.rst
stage.rst
plugins.rst
api.rst
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

View file

@ -1,11 +0,0 @@
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.microscope
:members:

View file

@ -1,5 +0,0 @@
Raspberry Pi Streaming Camera
=======================================================
.. automodule:: openflexure_microscope.camera.pi
:members:

View file

@ -1,15 +0,0 @@
Developing API Extensions
=========================
.. toctree::
:maxdepth: 2
./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

View file

@ -1,30 +0,0 @@
Quickstart
=======================================================
Install
-------
Stable installation
+++++++++++++++++++
The OpenFlexure Microscope software is designed to be run on the embedded Raspberry Pi, in an OpenFlexure Microscope. For most users, our `pre-built Raspbian SD card image. <https://openflexure.org/projects/microscope/install>`_ is the easiest way to get started. This SD card image is based on Raspberry Pi OS and includes both the microscope server and OpenFlexure Connect. A desktop shortcut will directly start OpenFlexure Connect if you are using the Raspberry Pi directly, or the microscope can be controlled over the network with its default hostname `raspberrypi.local`.
Manual installation
+++++++++++++++++++
To install the server on a Raspberry Pi without using the pre-built OpenFlexure Raspbian image, or to install the server on a different system (this is useful for development), follow the instructions in the README file at the top level of the project's repository.
Usage
-----
The easiest way to use the microscope is through OpenFlexure Connect, our cross-platform application that handles discovering and connecting to the microscope. It is detailed on the `instruction page on our website <https://openflexure.org/projects/microscope/control>`_ including a download link. OpenFlexure Connect is pre-installed on the full SD card image (not the "lite" image, as this does not have support for a graphical desktop).
If you know the hostname or IP address of your microscope, you can also connect to the same interface using a web browser by entering `http://microscope.local:5000/` as the address. `microscope.local` is the default hostname of the microscope if you use our pre-built SD card image. If you know the IP address or have customised the hostname, you can use that instead. Note that the hostname is announced via mDNS, which is usually reliable if the microscope is connected via a network cable directly to your client computer but may not work if both devices are connected to a more complicated network. As support for mDNS varies between operating systems, OpenFlexure Connect often detects microscopes even if you cannot resolve the microscope using the mDNS hostname.
Whether you connect with OpenFlexure Connect, or through a web browser, the web application interface is the same. See the "web application" section of this manual for more details.
Managing the 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.

View file

@ -1,5 +0,0 @@
Sangaboard Microscope Stage
===========================
.. automodule:: openflexure_microscope.stage.sanga
:members:

View file

@ -1,8 +0,0 @@
Stage Class
===========
.. toctree::
:maxdepth: 2
sangastage.rst
basestage.rst

View file

@ -1,22 +0,0 @@
Web Application interface
=========================
.. toctree::
:maxdepth: 2
:caption: Contents:
pane_navigate
pane_capture
pane_settings
pane_gallery
The main graphical interface for the OpenFlexure Microscope is implemented as a web application, which allows it to be accessed either through OpenFlexure Connect or a web browser. See the :doc:`../quickstart` page for connection instructions. A "tour" should guide users through the interface when they connect for the first time, and introduce the key interface elements.
Interface structure
-------------------
The main interface has a tab bar on the left, which allows the operator to select different controls. By default, the "view" pane does not show any additional controls, and a video feed from the camera fills the window.
.. image:: pane_view.png
Selecting one of the tab icons on the left will bring up the corresponding interface. Most of the pages display the image on the right hand side, and add additional controls between the image display and the tab bar.

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0dcbbfbc4d5ba77824083b497b015b4178ec47f375f8e0457cb103da5befd2cf
size 1971891

View file

@ -1,23 +0,0 @@
Capture pane
============
The capture pane allows images to be acquired through the interface. By default, a single image is captured to the microscope's internal SD card, and can be downloaded from the gallery. Various settings are available to control the resolution of the image captured: the "full resolution" checkbox will save the image at native resolution, and the "store raw data" checkbox saves raw pixel data as an EXIF annotation.
Due to the underlying ``picamera`` library, images are always saved as JPEG files, and raw data is simply appended to the file for later extraction. If an image is saved with raw data, the JPEG image is still the processed, compressed version; an external tool must be used to extract and process the raw Bayer data.
.. image:: pane_capture.png
It is also possible to acquire a grid of images for stitching together into a mosaic, by expanding the "Stack and Scan" section and enabling the "Scan capture" checkbox. Scans are 3D by default (i.e. a mosaic of images in X and Y, with a Z stack at each position) but 2D or linear scans can be performed by setting the number of steps in the unused axes to 1. This allows XY mosaics or Z stacks to be performed.
When scanning is enabled, the "capture" button is replaced by a "start scan" button, which will start the scan and display a progress indicator until scanning has finished.
.. image:: scan_dialog.png
The step sizes (in motor steps) for each axis specify the number of motor steps to move between images, then the "steps" fields specify the number of images to acquire along each axis. If "steps" is set to 1 for any axis, no scanning happens along that axis.
The scan routine will move through XY coordinates, and at each XY position will optionally run an autofocus routine, then acquire either a single image or a Z stack depending on the value of "z steps". The autofocus options correspond to those available in the "move" pane, allowing "fast" autofocus, or conventional autofocus with coarse, medium, or fine steps to be used. Selecting "none" disables autofocus. The vast majority of the time, "fast" autofocus is both quicker and more accurate than the other methods.
Various scan patterns are available for XY scanning. Raster scanning is the default, which scans columns (i.e. from lowest to highest Y coordinate) and works from low to high X coordinates as the "slow" scan axis. Snake scanning reverses every other column, such that there is a smaller distance from the end of one column to the beginning of the next. This can be helpful if the sample is not perfectly flat, as it avoids losing focus between columns. Finally, "spiral" scanning starts by taking an image at the current position, then works outwards in concentric squares. Spiral scans use the "x steps" value to set the number of rings, and ignore the "y steps" value.
Images acquired during the scan will be saved to a folder on the Raspberry Pi. They can be named according to their coordinates in the scan (default) or numbered sequentially (in case the latter is easier to process).
To retrieve images acquired during a scan, or captured individually, you can use the :doc:`pane_gallery`.

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c5dc30ff335f782d9a5c9d5568e13787f63be7e5d0a46c73afd5d525e79d557
size 1370769

View file

@ -1,10 +0,0 @@
Gallery pane
============
.. image:: pane_gallery.png
The gallery displays all the images currently stored on the microscope. Scans are grouped together into folders. Clicking on an image will display it in a "lightbox" view that allows scrolling through all images in the current view. When an image is displayed in the lightbox view, it may be right-clicked to download it. Multiple images can also be downloaded as a zip archive.
Bulk transfer of images is often easier using SCP, and images are stored by default in ``/var/openflexure/data/micrographs/`` on the Raspberry Pi.
Saving of images to external storage is possible - this can be configured using the "autostorage" plugin, which currently displays an SD card icon in the navigation bar at the left of the screen.

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:400d4f25aef0fecddbab296d982fb881367f1c5b6cdbd83b592bbbd142f3ef04
size 3184560

View file

@ -1,10 +0,0 @@
Navigate pane
=============
The navigate pane displays the current stage position. Editing the values for X, Y, and Z and then hitting "enter" or clicking "move" will move the stage to the specified coordinates. Using the arrow keys (or page up/down) when not editing a text box will also move the stage, and the step size used can be set in the "configure" section at the top of the pane (which is collapsed by default). Using the mouse scroll wheel on the image will also move in Z, using the same configured step size. Double clicking on the image will bring the point clicked to the centre of the field of view, if the camera-stage mapping has been calibrated (see :doc:`pane_settings`).
Autofocus can also be run from the navigate pane, by clicking the "fast", "medium" or "fine" buttons. Fast autofocus moves the stage up and down in a continuous motion, using the size of images in the MJPEG stream from the camera to determine the sharpest point. This is usually both faster and more accurate than the other methods, however the other two options use a different metric, and stop the stage for each measurement. This can lead to them being more reliable in some circumstances.
.. image:: pane_navigate.png

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ac21613d68c30c3e9038f71d738d41a266e6d99f3f93c9b92f833f1564ccd179
size 668472

View file

@ -1,14 +0,0 @@
Settings pane
=============
The settings for the microscope are gathered together into a settings pane, which is further subdivided into sections. This page does not provide an exhaustive list, but a few of the notable controls are:
* Adjusting exposure time and gain of the camera, including automatic adjustment.
* Automatic white balance and flat-field correction for the camera.
* Enabling or disabling certain features of the software (e.g. ImJoy integration).
* Enabling or disabling the video stream (this allows the native low-latency preview on the Raspberry Pi to be used instead).
* Calibrating the relationship between stage coordinates and pixel coordinates in the video stream, allowing click-to-move to function.
Important calibration tasks (in particular camera settings adjustment and click-to-move calibration) will be prompted in a "wizard" dialogue when the software is first run, to help first time users set up their microscope. All of the auto calibration routines are also available from the settings pane.
.. image:: pane_settings.png

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aca1cddcdd8038920748196f95307d8d0110ed702990dd4831b1566afe01dabd
size 1930201

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5bc4944c89be5f43b178145fc7ff8c414dac1716275dc83e02a4bc23493aa2fb
size 32284

View file

@ -1,273 +0,0 @@
#!/usr/bin/env python
import argparse
import atexit
import logging
import logging.handlers
import sys
import time
# Look for debug flag
if "-d" in sys.argv or "--debug" in sys.argv:
debug_app = True
log_level = logging.DEBUG
else:
debug_app = False
log_level = logging.INFO
# Set root logger level
root_log: logging.Logger = logging.getLogger()
root_log.setLevel(log_level)
import os
from datetime import datetime
import pkg_resources
from flask import abort, send_file
from flask_cors import CORS, cross_origin
from labthings import create_app
from labthings.extensions import find_extensions
from labthings.views import View
from openflexure_microscope.api.utilities import init_default_extensions, list_routes
from openflexure_microscope.api.v2 import views
from openflexure_microscope.json import JSONEncoder
from openflexure_microscope.microscope import Microscope
from openflexure_microscope.paths import (
OPENFLEXURE_EXTENSIONS_PATH,
OPENFLEXURE_VAR_PATH,
logs_file_path,
)
from .openapi import add_spec_extras
# Custom RotatingFileHandler subclass
class CustomRotatingFileHandler(logging.handlers.RotatingFileHandler):
"""
A custom class for a rotating file log handler, with defaults we like.
1MB per file, maximum of 5 historic files.
Non-propagating logs (so we can separate access logs from error logs)
Optional debugging level.
"""
def __init__(self, filename: str, debug: bool = False) -> None:
super().__init__(filename, maxBytes=1_000_000, backupCount=5)
# Set formatter
self.setFormatter(
logging.Formatter(
"[%(asctime)s] [%(threadName)s] [%(levelname)s] %(message)s"
)
)
# Never propagate
self.propagate: bool = False
# Conditionally enable debugging
if debug:
self.setLevel(logging.DEBUG)
# Log files
ROOT_LOGFILE: str = logs_file_path("openflexure_microscope.log")
ACCESS_LOGFILE: str = logs_file_path("openflexure_microscope.access.log")
# Our WSGI server uses Werkzeug, so use that for the access log
access_log: logging.Logger = logging.getLogger("werkzeug")
# Block the access logs from propagating up to the root logger
access_log.propagate = False
# Create error log file handler
fh: logging.Handler = CustomRotatingFileHandler(ROOT_LOGFILE, debug=debug_app)
# Create access log file handler
afh: logging.Handler = CustomRotatingFileHandler(ACCESS_LOGFILE, debug=debug_app)
# Add file handler to root logger
root_log.addHandler(fh)
access_log.addHandler(afh)
# Log server paths being used
logging.info("Running with data path %s", OPENFLEXURE_VAR_PATH)
# Create the microscope object
api_microscope: Microscope = Microscope()
logging.debug("Restoring captures...")
api_microscope.captures.rebuild_captures()
logging.debug("Microscope successfully attached!")
# Create flask app
logging.info("Creating app")
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"},
)
# Enable CORS for some routes outside of LabThings
cors: CORS = CORS(app)
# Use custom JSON encoder
labthing.json_encoder = JSONEncoder
app.json_encoder = JSONEncoder
# Add the microscope object to LabThings so extensions can access it
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, "/captures")
labthing.add_root_link(views.CaptureList, "captures")
labthing.add_view(views.CaptureView, "/captures/<id_>")
labthing.add_view(views.CaptureDownload, "/captures/<id_>/download/<filename>")
labthing.add_view(views.CaptureTags, "/captures/<id_>/tags")
labthing.add_view(views.CaptureAnnotations, "/captures/<id_>/annotations")
# Attach settings and state resources
labthing.add_view(views.SettingsProperty, "/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")
# Attach stage resources
labthing.add_view(views.StageTypeProperty, "/instrument/stage/type")
# Attach camera resources
labthing.add_view(views.LSTImageProperty, "/instrument/camera/lst")
# Attach streams resources
labthing.add_view(views.MjpegStream, "/streams/mjpeg")
labthing.add_view(views.SnapshotStream, "/streams/snapshot")
# Attach microscope action resources
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}")
# Add log file download view
class LogFileView(View):
def get(self):
"""
Most recent 1mb of log output
"""
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
return send_file(
ROOT_LOGFILE,
as_attachment=True,
attachment_filename="openflexure_microscope_{}.log".format(timestamp),
)
labthing.add_view(LogFileView, "/log")
@app.route("/")
def openflexure_ev():
return app.send_static_file("index.html")
@app.route("/routes")
@cross_origin()
def routes():
"""
List of all connected API routes
"""
return list_routes(app)
@app.route("/api/v1/", defaults={"path": ""})
@app.route("/api/v1/<path:path>")
def api_v1_catch_all(path): # pylint: disable=W0613
abort(410, "API v1 is no longer in use. Please upgrade your client.")
add_spec_extras(labthing.spec)
# Automatically clean up microscope at exit
def cleanup():
logging.debug("App teardown started...")
logging.debug("Settling...")
time.sleep(0.5)
# Save config
logging.debug("Saving config for teardown...")
api_microscope.save_settings()
logging.debug("Settling...")
time.sleep(0.5)
# Close down the microscope
logging.debug("Closing devices...")
api_microscope.close()
logging.debug("Settling...")
time.sleep(0.5)
logging.debug("App teardown complete.")
atexit.register(cleanup)
def ofm_serve():
# Start a debug server
from labthings import Server
logging.info("Starting OpenFlexure Microscope Server...")
server: Server = Server(app)
server.run(host="0.0.0.0", port=5000, debug=debug_app, zeroconf=True)
def generate_openapi():
parser = argparse.ArgumentParser("Generate an OpenAPI specification document")
parser.add_argument(
"-o",
dest="output",
default="openapi.yaml",
help=(
"Specify the output filename. If it ends in .json, we output JSON."
"Use .yml or .yaml for YAML (which is the default"
),
)
parser.add_argument(
"--validate",
action="store_true",
help="Validate the API spec, returning an error code if it does not pass.",
)
args = parser.parse_args()
if args.validate:
import apispec.utils
if apispec.utils.validate_spec(labthing.spec):
print("OpenAPI specification validated OK.")
fname = args.output
if fname.endswith(".json"):
import json
with open(fname, "w", encoding="utf-8") as fd:
json.dump(labthing.spec.to_dict(), fd)
else:
with open(fname, "w", encoding="utf-8") as fd:
fd.write(labthing.spec.to_yaml())
# Start the app if the module is run directly
if __name__ == "__main__":
ofm_serve()

View file

@ -1,49 +0,0 @@
import logging
import traceback
from contextlib import contextmanager
from typing import List, Type
from labthings.extensions import BaseExtension
LABTHINGS_EXTENSIONS: List[Type[BaseExtension]] = []
@contextmanager
def handle_extension_error(extension_name):
"""'gracefully' log an error if an extension fails to load."""
try:
yield
except Exception: # pylint: disable=W0703
logging.error(
"Exception loading builtin extension %s: \n%s",
extension_name,
traceback.format_exc(),
)
with handle_extension_error("autofocus"):
from .autofocus import AutofocusExtension
LABTHINGS_EXTENSIONS.append(AutofocusExtension)
with handle_extension_error("scan"):
from .scan import ScanExtension
LABTHINGS_EXTENSIONS.append(ScanExtension)
with handle_extension_error("zip builder"):
from .zip_builder import ZipBuilderExtension
LABTHINGS_EXTENSIONS.append(ZipBuilderExtension)
with handle_extension_error("autostorage"):
from .autostorage import AutostorageExtension
LABTHINGS_EXTENSIONS.append(AutostorageExtension)
with handle_extension_error("lens shading calibration"):
from .picamera_autocalibrate import LSTExtension
LABTHINGS_EXTENSIONS.append(LSTExtension)
with handle_extension_error("camera stage mapping"):
from .camera_stage_mapping import CSMExtension
LABTHINGS_EXTENSIONS.append(CSMExtension)

View file

@ -1,710 +0,0 @@
import inspect
import logging
import time
from contextlib import contextmanager
from typing import Callable, Dict, List, Optional, Tuple, cast
import numpy as np
from labthings import current_action, fields, find_component
from labthings.extensions import BaseExtension
from labthings.utilities import get_docstring, get_summary
from labthings.views import ActionView, View
from scipy import ndimage
from openflexure_microscope.camera.base import BaseCamera
from openflexure_microscope.devel import abort
from openflexure_microscope.microscope import Microscope
from openflexure_microscope.stage.base import BaseStage
from openflexure_microscope.utilities import set_properties
### Autofocus utilities
class JPEGSharpnessMonitor:
"""Monitor JPEG frame size in a background thread
This class starts a background thread """
def __init__(self, microscope: Microscope):
self.microscope: Microscope = microscope
self.camera: BaseCamera = microscope.camera
self.stage: BaseStage = microscope.stage
self.recording_start_time: Optional[float] = None
self.stage_positions: List[Tuple[int, int, int]] = []
self.stage_times: List[float] = []
self.jpeg_times: List[float] = []
self.jpeg_sizes: List[int] = []
def start(self):
# Log the recording start time
self.recording_start_time = time.time()
def stop(self):
self.camera.stream.stop_tracking()
self.camera.stream.reset_tracking()
def hold(self, delay: int = 5):
"""Run time.sleep for delay seconds,
while monitoring the JPEG frame size of the stream"""
self.camera.stream.start_tracking()
self.stage_times.append(time.time())
self.stage_positions.append(self.stage.position)
time.sleep(delay)
self.camera.stream.stop_tracking()
self.stage_times.append(time.time())
self.stage_positions.append(self.stage.position)
# Retrieve frame data
for frame in self.camera.stream.frames:
# Make timestamp absolute Unix time
self.jpeg_times.append(frame.time)
self.jpeg_sizes.append(frame.size)
# Clear frame data for this move from the stream
self.camera.stream.reset_tracking()
# Index of the data for this movement
data_index: int = len(self.stage_positions) - 2
# Final z position after move
final_z_position: int = self.stage_positions[-1][2]
return data_index, final_z_position
def focus_rel(self, dz: int, backlash: bool = False, **kwargs) -> Tuple[int, int]:
# Store the start time and position
self.camera.stream.start_tracking()
self.stage_times.append(time.time())
self.stage_positions.append(self.stage.position)
# Main move
self.stage.move_rel((0, 0, dz), backlash=backlash, **kwargs)
# Store the end time and position
self.camera.stream.stop_tracking()
self.stage_times.append(time.time())
self.stage_positions.append(self.stage.position)
# Retrieve frame data
for frame in self.camera.stream.frames:
# Make timestamp absolute Unix time
self.jpeg_times.append(frame.time)
self.jpeg_sizes.append(frame.size)
# Clear frame data for this move from the stream
self.camera.stream.reset_tracking()
# Index of the data for this movement
data_index: int = len(self.stage_positions) - 2
# Final z position after move
final_z_position: int = self.stage_positions[-1][2]
return data_index, final_z_position
def move_data(
self, istart: int, istop: Optional[int] = None
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Extract sharpness as a function of (interpolated) z"""
if istop is None:
istop = istart + 2
jpeg_times: np.ndarray = np.array(self.jpeg_times) # np.ndarray[float]
jpeg_sizes: np.ndarray = np.array(self.jpeg_sizes) # np.ndarray[int]
stage_times: np.ndarray = np.array(self.stage_times)[
istart:istop
] # np.ndarray[float]
stage_zs: np.ndarray = np.array(self.stage_positions)[
istart:istop, 2
] # np.ndarray[int]
try:
start: int = int(np.argmax(jpeg_times > stage_times[0]))
stop: int = int(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?"
) from e
else:
raise e
if stop < 1:
stop = len(jpeg_times)
logging.debug("changing stop to %s", (stop))
jpeg_times = jpeg_times[start:stop]
jpeg_zs: np.ndarray = np.interp(
jpeg_times, stage_times, stage_zs
) # np.ndarray[float]
return jpeg_times, jpeg_zs, jpeg_sizes[start:stop]
def sharpest_z_on_move(self, index: int) -> int:
"""Return the z position of the sharpest image on a given move"""
_, 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) -> Dict[str, np.ndarray]:
"""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
@contextmanager
def monitor_sharpness(microscope: Microscope):
m: JPEGSharpnessMonitor = JPEGSharpnessMonitor(microscope)
m.start()
try:
yield m
finally:
m.stop()
def sharpness_sum_lap2(rgb_image: np.ndarray) -> float:
"""Return an image sharpness metric: sum(laplacian(image)**")"""
image_bw = np.mean(rgb_image, 2)
image_lap = ndimage.filters.laplace(image_bw)
return float(np.mean(image_lap.astype(float) ** 4))
def sharpness_edge(image: np.ndarray) -> float:
"""Return a sharpness metric optimised for vertical lines"""
gray = np.mean(image.astype(float), 2)
n: int = 20
edge: np.ndarray = np.array([[-1] * n + [1] * n])
return float(
np.sum([np.sum(ndimage.filters.convolve(gray, W) ** 2) for W in [edge, edge.T]])
)
def find_microscope() -> Microscope:
"""Find the microscope component or raise an exception.
This function will fail with HTTPError extensions if it can't
find the appropriate hardware.
We return a `Microscope` object
"""
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(503, "No microscope connected. Unable to autofocus.")
return microscope
def find_microscope_with_real_stage() -> Microscope:
"""Find the microscope and ensure it has a real stage.
This function wraps `find_microscope()` and additionally asserts
that there is a real stage, raising a `503` code if not.
"""
microscope = find_microscope()
if not microscope.has_real_stage():
abort(503, "No stage connected. Unable to autofocus.")
return microscope
def extension_action(args=None):
"""A decorator to auto-create an Action endpoint for a method
Use this decorator on any method of an extension (`BaseExtension` subclass)
and it will automatically be added to the API. At present it is deliberately
basic, and the plan is to expand the options in the future.
Currently, you may specify `args` (which is a Marshmallow-format schema
or dictionary, determining the datatype of the arguments, which will be
taken from the JSON payload of the POST request initiating the action).
The parsed arguments dictionary is expanded as the function arguments,
i.e. we call `decorated_method(self, **kwargs)`. This means that any
unused arguments will cause an error, which is probably good practice...
NB this decorator does **not** replace the function with a `View` or
register it with the parent `Extension`. It adds the created `View` as
a property of the function, `flask_view`. The parent `Extension` is
responsible for collating and adding the views in its `__init__` method.
"""
supplied_args = args
def decorator(func):
class_docstring = f"""Manage actions for {func.__name__}.
This `View` class will return a list of `Action` objects representing
each time {func.__name__} has been run in response to a `GET` request,
and will start a new `Action` in response to a `POST` request.
"""
class ActionViewWrapper(ActionView):
__doc__ = class_docstring
args = supplied_args
def post(self, arguments):
# Run the action
return func(self.extension, **arguments)
def get(
self, *args, **kwargs
): # pylint: disable=useless-super-delegation,arguments-differ
# Explicitly wrap the `get` method to allow us to add a docstring
return super().get(*args, **kwargs)
# Create a nice docstring. NB because the source function and this docstring
# aren't guaranteed to have the same leading whitespace, we just make sure
# both docstrings are stripped of leading indent, using `inspect.cleandoc()`
ActionViewWrapper.post.description = (
get_docstring(func, remove_newlines=False)
+ "\n\n"
+ inspect.cleandoc(
"""
This `POST` request starts an Action, i.e. the hardware will do something
that may continue after the HTTP request has been responded to. The
response will always be an Action object, that details the current
status of the action and provides an interface to poll for completion.
If the action completes within a specified timeout, we will return
an HTTP status code of `200` and the return value will include any
output from the action. If it does not complete, we will return a
`201` response code, and the action's endpoint may be polled to follow
its progress.
"""
)
)
ActionViewWrapper.post.summary = get_summary(func)
ActionViewWrapper.post.__doc__ = ActionViewWrapper.post.description
ActionViewWrapper.__name__ = func.__name__
ActionViewWrapper.get.summary = (
f"List running and completed `{func.__name__}` actions."
)
ActionViewWrapper.get.description = (
ActionViewWrapper.get.summary
+ "\n\n"
+ inspect.cleandoc(
f"""
This `GET` request will return a list of `Action` objects corresponding
to the `{func.__name__}` action. It will include all the times it has
been run since the server was last restarted, including running, completed,
and failed attempts.
"""
)
)
func.flask_view = ActionViewWrapper
return func
return decorator
### Autofocus extension
class AutofocusExtension(BaseExtension):
def __init__(self):
super().__init__(
"org.openflexure.autofocus",
version="2.0.0",
description="Actions to move the microscope in Z and pick the point with the sharpest image.",
)
self.add_view(
MeasureSharpnessAPI, "/measure_sharpness", endpoint="measure_sharpness"
)
self.add_decorated_method_views()
def add_decorated_method_views(self):
"""Add views from any methods that have been decorated
Using the decorators `@extension_action()` et al will add a
property to the decorated method, `method_view`. If this is
present, this function will add the views to the extension.
"""
for k in dir(self):
obj = getattr(self, k)
if hasattr(obj, "flask_view"):
name = obj.__name__
self.add_view(obj.flask_view, f"/{name}", endpoint=name)
def measure_sharpness(
self,
microscope: Optional[Microscope] = None,
metric_fn: Callable = sharpness_sum_lap2,
) -> float:
"""Measure the sharpness from the MJPEG stream
Take a JPEG snapshot from the camera (extracted from the live preview stream)
and return its size. This is the sharpness metric used by the fast autofocus
method.
"""
if not microscope:
microscope = find_microscope()
if hasattr(microscope.camera, "array") and callable(
getattr(microscope.camera, "array")
):
return metric_fn(getattr(microscope.camera, "array")(use_video_port=True))
else:
raise RuntimeError(f"Object {microscope.camera} has no method `array`")
@extension_action(
args={
"dz": fields.List(
fields.Int(),
metadata={
"description": "An ascending list of relative z positions",
"example": [int(x) for x in np.linspace(-300, 300, 7)],
},
)
}
)
def autofocus(
self,
microscope: Optional[Microscope] = None,
dz: Optional[List[int]] = None,
settle: float = 0.5,
metric_fn: Callable = sharpness_sum_lap2,
) -> Tuple[List[int], List[float]]:
"""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)
"""
if not microscope:
microscope = find_microscope_with_real_stage()
camera: BaseCamera = microscope.camera
stage: BaseStage = microscope.stage
if not dz:
dz = list(np.linspace(-300, 300, 7))
dz = cast(List[int], dz) # dz can't now be None, so fix its type.
with set_properties(stage, backlash=256), stage.lock, camera.lock:
sharpnesses: List[float] = []
positions: List[int] = []
# Some cameras may not have annotate_text. Reset if it does
if getattr(camera, "annotate_text", None):
setattr(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(self.measure_sharpness(microscope, metric_fn))
newposition: int = positions[int(np.argmax(sharpnesses))]
stage.move_rel((0, 0, newposition - stage.position[2]))
return positions, sharpnesses
def move_and_find_focus(
self, microscope: Optional[Microscope] = None, dz: int = 0
) -> int:
"""Make a relative Z move and return the peak sharpness position"""
if not microscope:
microscope = find_microscope_with_real_stage()
with monitor_sharpness(microscope) as m:
m.focus_rel(dz)
return m.sharpest_z_on_move(0)
@extension_action(
args={
"dz": fields.Int(
required=True, metadata={"description": "The relative Z move to make"}
)
}
)
def move_and_measure(
self, microscope: Optional[Microscope] = None, dz: int = 0
) -> Dict[str, np.ndarray]:
"""Make a relative move in Z and measure dynamic sharpness
This accesses the underlying method used by the fast autofocus routines, to
move the stage while monitoring the sharpness, as reported by the size of
each JPEG frame in the preview stream. It returns a dictionary with
stage position vs time and image size (i.e. sharpness) vs time.
"""
if not microscope:
microscope = find_microscope_with_real_stage()
with monitor_sharpness(microscope) as m:
m.focus_rel(dz)
return m.data_dict()
@extension_action(
args={
"dz": fields.Int(
load_default=2000,
metadata={
"description": "Total Z range to search over (in stage steps)",
"example": 2000,
},
)
}
)
def fast_autofocus(
self, microscope: Optional[Microscope] = None, dz: int = 2000
) -> Dict[str, np.ndarray]:
"""Perform a fast down-up-down-up autofocus
This "fast" autofocus method moves the stage continuously in Z, while
following the sharpness using the MJPEG stream. This version is the
simplest "fast" autofocus method, and performs the following sequence
of moves:
1. Move to `-dz/2`, i.e. the bottom of the range
2. Move up by `dz`, i.e. to the top of the range, while recording the
sharpness of the image as a function of time. Record the estimated
position of the stage when the sharpness was maximised, `fz`.
3. Move back to the bottom (by `-dz`)
4. Move up to the position where it was sharpest.
## Backlash correction
This routine should cancel out backlash: the stage is moving upwards as
we record the sharpnes vs z data, and it is also moving upwards when
we make the final move to the sharpest point. Mechanical backlash should
therefore be the same in both cases.
This does not account for lag between the sharpness measurements and the
stage's motion; that has been tested for and seems not to be a big issue
most of the time, but may need to be accounted for in the future, if
hardware or software changes increase the latency.
## Sharpness metric
This method uses the MJPEG preview stream to estimate the sharpness of
the image. MJPEG streams consist of a series of independent JPEG images,
so each frame can be looked at in isolation (though see later for an
important caveat). JPEG images are compressed lossily, by taking the
discrete cosine transform (DCT) of each 8x8 block in the image. A very
rough precis of how this works is that after the DCT, cosine components
that are deemed unimportant (i.e. smaller than a threshold) are discarded.
The effect is that images with lots of high-frequency information have a
larger file size.
We look only at the size of each JPEG frame in the stream, so we get a
remarkably robust estimate of image sharpness without even opening the
images! That's what lets us analyse 30 images/second even on the very
limited processing power available to the Raspberry Pi 3.
## Warning: frame independence
We assume that JPEG frames are independent. This is only true if the
MJPEG stream is encoded at *constant quality* without any additional
bit rate control. By default, many streams will reduce the quality
factor if they exceed a target bit rate, which badly affects this
method. We turn off bit rate limiting for the Raspberry Pi camera,
which fixes the problem, at the expense of sometimes failing if
particularly sharp images appear in the stream, as there is a fairly
small maximum size for each JPEG frame beyond which empty images are
returned.
## Estimation of sharpness vs z
What we record during an autofocus is two time series, from two parallel
threads. One thread monitors the camera, and records the size of each
JPEG frame as a function of time. NB this is time from `time.time()`
in Python, so will not be microsecond-accurate. The other thread is
responsible for moving the stage, and records its current Z position
before and after each move. Interpolating between these `(t, z)` points
gives us a `z` value for each JPEG size, and so we can estimate the
JPEG size as a function of `z` and hence determine the `z` value at
which sharpness is maximised.
"""
if not microscope:
microscope = find_microscope_with_real_stage()
with microscope.lock(timeout=1), microscope.camera.lock, microscope.stage.lock:
with monitor_sharpness(microscope) as m:
# Move to (-dz / 2)
m.focus_rel(-dz / 2)
# Move to dz while monitoring sharpness
# i: Sharpness monitor index for this move
# z: Final z position after move
i, z = m.focus_rel(dz)
# Get the z position with highest sharpness from the previous move (index i)
fz: int = m.sharpest_z_on_move(i)
# Move all the way to the start so it's consistent
# Store final absolute z position from this return move
i, z = m.focus_rel(-dz)
# Move to the target position fz
# Can't do absolute move here yet so move by (fz - z)
m.focus_rel(fz - z)
# Return all focus data
return m.data_dict()
@extension_action(
args={
"dz": fields.Int(
load_default=500,
metadata={
"description": "Total Z range to move down, then up (in stage steps)",
"example": 500,
},
),
"delay": fields.Int(
load_default=5,
metadata={
"description": "How long to measure sharpness for after the move, in seconds",
"example": 5,
},
),
}
)
def measure_settling_time(
self, microscope: Optional[Microscope] = None, delay: int = 2, dz: int = 400
) -> Dict[str, np.ndarray]:
"""Make a Z move down then up by dz, then pause for delay while monitoring sharpness.
This is useful so we can see how long we need to wait for the sharpness value to converge"""
if not microscope:
microscope = find_microscope_with_real_stage()
with microscope.lock(timeout=1), microscope.camera.lock, microscope.stage.lock:
with monitor_sharpness(microscope) as m:
m.focus_rel(-dz)
m.focus_rel(dz)
m.hold(delay)
return m.data_dict()
@extension_action(
args={
"dz": fields.Int(
load_default=2000,
metadata={
"description": "Total Z range to search over (in stage steps)",
"example": 2000,
},
),
"target_z": fields.Int(
load_default=0,
metadata={
"description": "Target finishing position, relative to the focus.",
"example": -100,
},
),
"initial_move_up": fields.Bool(
load_default=True,
metadata={
"description": "Set to False to disable the initial move upwards"
},
),
"backlash": fields.Int(
load_default=25,
metadata={
"description": "Distance to undershoot, before correction move.",
"minimum": 0,
},
),
}
)
def fast_up_down_up_autofocus(
self,
microscope: Optional[Microscope] = None,
dz: int = 2000,
target_z: int = 0,
initial_move_up: bool = True,
backlash: Optional[int] = None,
mini_backlash: Optional[int] = None,
) -> Dict[str, np.ndarray]:
"""Perform a fast up-down-up autofocus, with feedback
Autofocus by measuring on the way down, and moving back up with feedback.
This is a "fast" autofocus method, i.e. it moves the stage continuously
while monitoring the sharpness using the MJPEG stream. See `fast_autofocus`
for more details.
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. The
main source of error is that the curves on the way up and the way down are
not always identical, largely due to small lateral shifts as the Z axis is
moved.
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.
* **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.
* **mini_backlash**: (optional, default 25) is an alias for `backlash` and will be
removed in due course.
"""
if not microscope:
microscope = find_microscope_with_real_stage()
if not mini_backlash: # I renamed the argument,
mini_backlash = backlash or 25
with microscope.lock(timeout=1), microscope.camera.lock, microscope.stage.lock:
with monitor_sharpness(microscope) as m:
# Ensure the MJPEG stream has started
microscope.camera.start_stream()
logging.debug("Initial move")
if initial_move_up:
m.focus_rel(dz / 2)
# move down
logging.debug("Move down")
i: int
z: int
i, z = m.focus_rel(-dz)
# now inspect where the sharpest point is, and estimate the sharpness
# (JPEG size) that we should find at the start of the Z stack
jz: np.ndarray # np.ndarray[float]
js: np.ndarray # np.ndarray[float]
_, jz, js = m.move_data(i)
best_z: int = jz[np.argmax(js)]
# 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.camera.stream.last.size
imax: int = int(
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: int = int(
np.argmax(js < current_js)
) # use the curve we recorded to estimate our position
# 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: int = best_z + target_z - jz[inow]
logging.debug(
"Fast autofocus scan: correcting backlash by moving %s steps",
(correction_move),
)
m.focus_rel(correction_move)
return m.data_dict()
class MeasureSharpnessAPI(View):
__doc__ = AutofocusExtension.measure_sharpness.__doc__
def post(self):
return {"sharpness": self.extension.measure_sharpness()}

View file

@ -1,274 +0,0 @@
import logging
import os
from typing import Dict, List, Optional, Tuple
import psutil
from flask import abort
from labthings import fields, find_component
from labthings.extensions import BaseExtension
from labthings.marshalling import use_args
from labthings.views import PropertyView, View
from openflexure_microscope.api.utilities.gui import build_gui
from openflexure_microscope.captures.capture_manager import (
BASE_CAPTURE_PATH,
CaptureManager,
)
from openflexure_microscope.microscope import Microscope
from openflexure_microscope.paths import check_rw, settings_file_path
AS_SETTINGS_PATH = settings_file_path("autostorage_settings.json")
def get_partitions() -> List[str]:
return [disk.mountpoint for disk in psutil.disk_partitions() if "rw" in disk.opts]
def get_permissive_partitions() -> List[str]:
return [partition for partition in get_partitions() if check_rw(partition)]
def get_permissive_locations() -> List[Tuple[str, str]]:
return [
(partition, os.path.join(partition, "openflexure", "data", "micrographs"))
for partition in get_permissive_partitions()
]
def get_current_location(capture_manager: Optional[CaptureManager]) -> str:
if capture_manager:
return capture_manager.paths.get("default", BASE_CAPTURE_PATH)
return BASE_CAPTURE_PATH
def set_current_location(capture_manager: Optional[CaptureManager], location: str):
if not capture_manager:
logging.warning(
"Cannot set_current_location of a missing capture_manager. Skipping."
)
return
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() -> str:
return BASE_CAPTURE_PATH
def get_all_locations() -> Dict[str, str]:
locations: Dict[str, str] = {}
# 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 AutostorageExtension(BaseExtension):
def __init__(self):
super().__init__(
"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: Optional[CaptureManager] = None
self.initial_location: str = get_default_location()
# Register the on_microscope function to run when the microscope is attached
self.on_component("org.openflexure.microscope", self.on_microscope)
self.add_view(GetLocationsView, "/list-locations")
self.add_view(PreferredLocationView, "/location")
self.add_view(PreferredLocationGUIView, "/location-from-title")
self.add_meta("gui", build_gui(self.dynamic_form, self))
def dynamic_form(self):
self.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": self.get_titles(),
"value": self.get_preferred_title(),
}
],
}
],
}
def on_microscope(self, microscope_obj: Microscope):
"""Function to automatically call when the parent LabThing has a microscope attached."""
logging.debug("Autostorage extension found microscope %s", microscope_obj)
if hasattr(microscope_obj, "captures"):
logging.debug(
"Autostorage extension bound to CaptureManager %s", 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())
else:
raise RuntimeError(
"Attached a microscope with no `captures` capture manager. Skipping extension."
)
def check_location(self, location: Optional[str] = None) -> bool:
location = location or get_current_location(self.capture_manager)
if not location:
return False
# If preferred path does not exist, or cannot be written to
if not (os.path.isdir(location) and check_rw(location)):
logging.error(
"Preferred capture path %s is missing or cannot be written to. Restoring defaults.",
location,
)
# Reset the storage location to default
set_current_location(self.capture_manager, get_default_location())
return True
def get_locations(self) -> Dict[str, str]:
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) -> Optional[str]:
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."
)
if matches:
return matches[0]
else:
logging.warning("No matches found. Skipping.")
return None
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)
if location:
set_current_location(self.capture_manager, location)
def key_to_title(self, path_key: Optional[str]) -> Optional[str]:
if not path_key:
return None
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) -> 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) -> List[str]:
titles: List[str] = []
for key in self.get_locations().keys():
title: Optional[str] = self.key_to_title(key)
if title:
titles.append(title)
return titles
def get_preferred_title(self) -> Optional[str]:
return self.key_to_title(self.get_preferred_key())
class GetLocationsView(PropertyView):
def get(self):
self.extension.check_location()
return self.extension.get_locations()
class PreferredLocationView(PropertyView):
schema = fields.String(required=True, metadata={"example": "Default"})
def get(self):
self.extension.check_location()
return self.extension.get_preferred_key()
def post(self, new_path_key):
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(503, "No microscope connected. Unable to autofocus.")
self.extension.check_location()
self.extension.set_preferred_key(new_path_key)
microscope.save_settings()
class PreferredLocationGUIView(View):
@use_args({"new_path_title": fields.String(required=True)})
def post(self, args):
new_path_title = args.get("new_path_title")
logging.debug(new_path_title)
new_path_key = self.extension.title_to_key(new_path_title)
logging.debug(new_path_key)
self.extension.check_location()
self.extension.set_preferred_key(new_path_key)
return new_path_title

View file

@ -1,398 +0,0 @@
"""
OpenFlexure Microscope API extension for stage calibration
This file contains the HTTP API for camera/stage calibration. It
includes calibration functions that measure the relationship between
stage coordinates and camera coordinates, as well as functions that
move by a specified displacement in pixels, perform closed-loop moves,
and return the calibration data.
This module is only intended to be called from the OpenFlexure Microscope
server, and depends on that server and its underlying LabThings library.
"""
import io
import json
import logging
import os
import time
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple
import numpy as np
import PIL
from camera_stage_mapping.camera_stage_calibration_1d import (
calibrate_backlash_1d,
image_to_stage_displacement_from_1d,
)
from camera_stage_mapping.camera_stage_tracker import Tracker
from camera_stage_mapping.closed_loop_move import closed_loop_move, closed_loop_scan
from camera_stage_mapping.scan_coords_times import ordered_spiral
from labthings import fields
from labthings.extensions import BaseExtension
from labthings.find import find_component
from labthings.utilities import create_from_path, get_by_path, set_by_path
from labthings.views import ActionView, PropertyView
from openflexure_microscope.config import JSONEncoder
from openflexure_microscope.microscope import Microscope
from openflexure_microscope.paths import data_file_path
CSM_DATAFILE_NAME = "csm_calibration.json"
CSM_DATAFILE_PATH = data_file_path(CSM_DATAFILE_NAME)
CoordinateType = Tuple[float, float, float]
XYCoordinateType = Tuple[float, float]
class MoveHistory(NamedTuple):
times: List[float]
stage_positions: List[CoordinateType]
class LoggingMoveWrapper:
"""Wrap a move function, and maintain a log position/time.
This class is callable, so it doesn't change the signature
of the function it wraps - it just makes it possible to get
a list of all the moves we've made, and how long they took.
Said list is intended to be useful for calibrating the stage
so we can estimate how long moves will take.
"""
def __init__(self, move_function: Callable):
self._move_function: Callable = move_function
self._current_position: Optional[CoordinateType] = None
self.clear_history()
def __call__(self, new_position: CoordinateType, *args, **kwargs):
"""Move to a new position, and record it"""
self._history.append((time.time(), self._current_position))
self._move_function(new_position, *args, **kwargs)
self._current_position = new_position
self._history.append((time.time(), self._current_position))
@property
def history(self) -> MoveHistory:
"""The history, as a numpy array of times and another of positions"""
times: List[float] = [t for t, p in self._history if p is not None]
positions: List[CoordinateType] = [p for t, p in self._history if p is not None]
return MoveHistory(times, positions)
def clear_history(self):
"""Reset our history to be an empty list"""
self._history: List[Tuple[float, Optional[CoordinateType]]] = []
class CSMUncalibratedError(RuntimeError):
"""A calibrated camera stage mapper is required, but this one is not calibrated.
The camera stage mapper requires calibration information to relate image pixels
to stage coordinates. If a method attempts to retrieve this calibration before
it exists, we raise this exception.
"""
class CSMExtension(BaseExtension):
"""
Use the camera as an encoder, so we can relate camera and stage coordinates
"""
def __init__(self):
BaseExtension.__init__(
self, "org.openflexure.camera_stage_mapping", version="0.0.1"
)
self.add_view(Calibrate1DView, "/calibrate_1d", endpoint="calibrate_1d")
self.add_view(CalibrateXYView, "/calibrate_xy", endpoint="calibrate_xy")
self.add_view(
MoveInImageCoordinatesView,
"/move_in_image_coordinates",
endpoint="move_in_image_coordinates",
)
self.add_view(
ClosedLoopMoveInImageCoordinatesView,
"/closed_loop_move_in_image_coordinates",
)
self.add_view(
TestClosedLoopSpiralScanView,
"/test_closed_loop_spiral_scan",
endpoint="test_closed_loop_spiral_scan",
)
self.add_view(
GetCalibrationFile, "/get_calibration", endpoint="get_calibration"
)
_microscope: Optional[Microscope] = None
@property
def microscope(self):
# TODO: does caching the microscope actually help?
if self._microscope is None:
self._microscope = find_component("org.openflexure.microscope")
return self._microscope
def update_settings(self, settings):
"""Update the stored extension settings dictionary"""
keys: List[str] = ["extensions", self.name]
dictionary: dict = create_from_path(keys)
set_by_path(dictionary, keys, settings)
logging.info("Updating settings with %s", dictionary)
self.microscope.update_settings(dictionary)
self.microscope.save_settings()
def get_settings(self) -> Dict[str, Any]:
"""Retrieve the settings for this extension"""
keys: List[str] = ["extensions", self.name]
try:
return get_by_path(self.microscope.read_settings(), keys)
except KeyError as exc:
raise CSMUncalibratedError(
"Camera stage mapping calibration data is missing"
) from exc
def camera_stage_functions(self) -> Tuple[Callable, Callable, Callable, Callable]:
"""Return functions that allow us to interface with the microscope"""
def grab_image():
jpeg: bytes = self.microscope.camera.get_frame()
return np.array(PIL.Image.open(io.BytesIO(jpeg)))
def get_position() -> CoordinateType:
return self.microscope.stage.position
move: Callable = self.microscope.stage.move_abs
def wait():
time.sleep(0.2)
return grab_image, get_position, move, wait
def calibrate_1d(self, direction: Tuple[float, float, float]) -> dict:
"""Move a microscope's stage in 1D, and figure out the relationship with the camera"""
grab_image: Callable
get_position: Callable
move: Callable
wait: Callable
grab_image, get_position, move, wait = self.camera_stage_functions()
move = LoggingMoveWrapper(move) # log positions and times for stage calibration
tracker = Tracker(grab_image, get_position, settle=wait)
direction_array: np.ndarray = np.array(direction)
result: dict = calibrate_backlash_1d(tracker, move, direction_array)
result["move_history"] = move.history
return result
def calibrate_xy(self) -> Dict[str, dict]:
"""Move the microscope's stage in X and Y, to calibrate its relationship to the camera"""
logging.info("Calibrating X axis:")
cal_x: dict = self.calibrate_1d((1, 0, 0))
logging.info("Calibrating Y axis:")
cal_y: dict = self.calibrate_1d((0, 1, 0))
# Combine X and Y calibrations to make a 2D calibration
cal_xy: dict = image_to_stage_displacement_from_1d([cal_x, cal_y])
self.update_settings(cal_xy)
data: Dict[str, dict] = {
"camera_stage_mapping_calibration": cal_xy,
"linear_calibration_x": cal_x,
"linear_calibration_y": cal_y,
}
with open(CSM_DATAFILE_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, cls=JSONEncoder)
return data
@property
def image_to_stage_displacement_matrix(self) -> np.ndarray: # 2x2 integer array
"""A 2x2 matrix that converts displacement in image coordinates to stage coordinates."""
settings = self.get_settings()
try:
return np.array(settings["image_to_stage_displacement"])
except KeyError as exc:
raise CSMUncalibratedError(
"The microscope has not yet been calibrated."
) from exc
def move_in_image_coordinates(self, displacement_in_pixels: XYCoordinateType):
"""Move by a given number of pixels on the camera"""
relative_move: np.ndarray = np.dot(
np.array(displacement_in_pixels), self.image_to_stage_displacement_matrix
)
self.microscope.stage.move_rel([relative_move[0], relative_move[1], 0])
def closed_loop_move_in_image_coordinates(
self, displacement_in_pixels: XYCoordinateType, **kwargs
):
"""Move by a given number of pixels on the camera, using the camera as an encoder."""
grab_image, get_position, _, wait = self.camera_stage_functions()
tracker = Tracker(grab_image, get_position, settle=wait)
tracker.acquire_template()
closed_loop_move(
tracker,
self.move_in_image_coordinates,
np.array(displacement_in_pixels),
**kwargs
)
def closed_loop_scan(
self, scan_path: List[XYCoordinateType], **kwargs
) -> List[CoordinateType]:
"""Perform closed-loop moves to each point defined in scan_path.
This returns a generator, which will move the stage to each point in
``scan_path``, then yield ``i, pos`` where ``i``
is the index of the scan point, and ``pos`` is the estimated position
in pixels relative to the starting point. To use it properly, you
should iterate over it, for example::
for i, pos in self.extension.closed_loop_scan(scan_path):
capture_image(f"image_{i}.jpg")
``scan_path`` should be an Nx2 array defining
the points to visit in pixels relative to the current position.
If an exception occurs during the scan, we automatically return to the
starting point. Keyword arguments are passed to
``closed_loop_move.closed_loop_scan``.
"""
grab_image, get_position, move, wait = self.camera_stage_functions()
tracker = Tracker(grab_image, get_position, settle=wait)
tracker.acquire_template()
return closed_loop_scan(
tracker, self.move_in_image_coordinates, move, np.array(scan_path), **kwargs
)
def test_closed_loop_spiral_scan(
self, step_size: Tuple[int, int], N: int, **kwargs
):
"""Move the microscope in a spiral scan, and return the positions."""
scan_path: List[XYCoordinateType] = ordered_spiral(0, 0, N, *step_size)
for _ in self.closed_loop_scan(scan_path, **kwargs):
pass
class Calibrate1DView(ActionView):
args = {
"direction": fields.List(
fields.Float(), required=True, metadata={"example": [1, 0, 0]}
)
}
def post(self, args):
"""Calibrate one axis of the microscope stage against the camera."""
direction: Tuple[float, float, float] = args.get("direction")
return self.extension.calibrate_1d(direction)
class CalibrateXYView(ActionView):
def post(self):
"""Calibrate both axes of the microscope stage against the camera."""
return self.extension.calibrate_xy()
class MoveInImageCoordinatesView(ActionView):
args = {
"x": fields.Float(
metadata={
"description": "The number of pixels to move in X",
"example": 100,
},
required=True,
),
"y": fields.Float(
metadata={
"description": "The number of pixels to move in Y",
"example": 100,
},
required=True,
),
}
def post(self, args):
"""Move the microscope stage, such that we move by a given number of pixels on the camera"""
logging.debug("moving in pixels")
self.extension.move_in_image_coordinates((args.get("x"), args.get("y")))
return self.extension.microscope.state["stage"]["position"]
class ClosedLoopMoveInImageCoordinatesView(ActionView):
args = {
"x": fields.Float(
metadata={
"description": "The number of pixels to move in X",
"example": 100,
},
required=True,
),
"y": fields.Float(
metadata={
"description": "The number of pixels to move in Y",
"example": 100,
},
required=True,
),
}
def post(self, args):
"""Move the microscope stage, such that we move by a given number of pixels on the camera"""
logging.debug("moving in pixels")
self.extension.closed_loop_move_in_image_coordinates(
(args.get("x"), args.get("y"))
)
return self.extension.microscope.state["stage"]["position"]
class TestClosedLoopSpiralScanView(ActionView):
args = {
"x_step": fields.Float(
metadata={
"description": "The number of pixels to move in X",
"example": 100,
},
required=True,
),
"y_step": fields.Float(
metadata={
"description": "The number of pixels to move in Y",
"example": 100,
},
required=True,
),
"N": fields.Int(
metadata={
"description": "The number of rings in the spiral scan",
"example": 100,
},
required=True,
),
}
def post(self, args):
"""Move the microscope stage, such that we move by a given number of pixels on the camera"""
logging.debug("moving in pixels")
return self.extension.test_closed_loop_spiral_scan(
(args.get("x"), args.get("y")), args.get("N")
)
class GetCalibrationFile(PropertyView):
def get(self):
"""Get the calibration data in JSON format."""
datafile_path = CSM_DATAFILE_PATH
if os.path.isfile(datafile_path):
with open(datafile_path, "rb") as f:
return json.load(f)
else:
return {}

View file

@ -1 +0,0 @@
from .extension import LSTExtension

View file

@ -1,244 +0,0 @@
import logging
from contextlib import contextmanager
from typing import Iterator, Tuple
import labthings.fields as fields
from flask import abort
from labthings import find_component
from labthings.extensions import BaseExtension
from labthings.views import ActionView
from picamerax import PiCamera
from openflexure_microscope.camera.base import BaseCamera
from openflexure_microscope.microscope import Microscope
from .recalibrate_utils import (
adjust_shutter_and_gain_from_raw,
adjust_white_balance_from_raw,
flat_lens_shading_table,
get_channel_percentiles,
lst_from_camera,
)
@contextmanager
def pause_stream(scamera: BaseCamera):
"""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.stream_resolution
if streaming:
logging.info("Stopping stream in pause_stream context manager")
scamera.stop_stream()
try:
yield scamera
finally:
scamera.stream_resolution = old_resolution
if streaming:
logging.info("Restarting stream in pause_stream context manager")
scamera.start_stream()
class LSTExtension(BaseExtension):
def __init__(self) -> None:
super().__init__(
"org.openflexure.calibration.picamera",
version="2.0.0-beta.1",
description="Routines to perform flat-field correction on the camera.",
)
self.add_view(RecalibrateView, "/recalibrate", endpoint="recalibrate")
self.add_view(
FlattenLSTView,
"/flatten_lens_shading_table",
endpoint="flatten_lens_shading_table",
)
self.add_view(
DeleteLSTView,
"/delete_lens_shading_table",
endpoint="delete_lens_shading_table",
)
self.add_view(
GetRawChannelPercentilesView,
"/get_raw_channel_percentiles",
endpoint="get_raw_channel_percentiles",
)
self.add_view(
AutoExposureFromRawView,
"/auto_exposure_from_raw",
endpoint="auto_exposure_from_raw",
)
self.add_view(
AutoWhiteBalanceFromRawView,
"/auto_white_balance_from_raw",
endpoint="auto_white_balance_from_raw",
)
self.add_view(
AutoLensShadingTableView,
"/auto_lens_shading_table",
endpoint="auto_lens_shading_table",
)
@contextmanager
def find_picamera() -> Iterator[Tuple[PiCamera, BaseCamera, Microscope]]:
"""Locate the microscope and raise a sensible error if it's missing."""
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(
503, "No microscope connected. Unable to use camera calibration functions."
)
scamera = microscope.camera
if not hasattr(scamera, "picamera"):
abort(503, "The PiCamera calibration plugin requires a Raspberry Pi camera.")
with scamera.lock:
yield scamera.picamera, scamera, microscope
class RecalibrateView(ActionView):
def post(self):
"""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.
This consists of three steps:
* Reset gain and exposure time, then increase them until
we have a suitably bright image
* Set the auto white balance based on a raw image
* Set the lens shading table based on a raw image
Each of these steps has its own endpoint if you want to
perform them separately.
"""
with find_picamera() as (picamera, scamera, microscope):
logging.info("Starting microscope recalibration...")
adjust_shutter_and_gain_from_raw(picamera)
adjust_white_balance_from_raw(picamera)
lst = lst_from_camera(picamera)
with pause_stream(scamera):
picamera.lens_shading_table = lst
microscope.save_settings()
class AutoLensShadingTableView(ActionView):
def post(self):
"""Perform flat-field correction
This routine acquires a new image (which should be an
empty field of view, i.e. a perfect microscope would
record a uniform white image), and then uses it to set
the "lens shading table" such that future images will
be corrected for vignetting.
"""
with find_picamera() as (picamera, scamera, microscope):
logging.info("Generating lens shading table")
lst = lst_from_camera(picamera)
with pause_stream(scamera):
picamera.lens_shading_table = lst
microscope.save_settings()
class FlattenLSTView(ActionView):
def post(self):
with find_picamera() as (picamera, scamera, microscope):
with pause_stream(scamera):
flat_lst = flat_lens_shading_table(picamera)
picamera.lens_shading_table = flat_lst
microscope.save_settings()
class DeleteLSTView(ActionView):
def post(self):
with find_picamera() as (picamera, scamera, microscope):
with pause_stream(scamera):
picamera.lens_shading_table = None
microscope.save_settings()
percentile_field = fields.Float(
load_default=99.9,
metadata={
"example": 99.9,
"description": (
"A float between 0 and 100 setting the centile to use "
"to measure the white point of the image. A value "
"of 99.9 allows 0.1% of the pixels to be erroneously "
"bright - this helps stability in low light."
),
},
)
class AutoExposureFromRawView(ActionView):
args = {
"target_white_level": fields.Int(
load_default=700,
metadata={
"example": 700,
"description": (
"The pixel value (10-bit format) that we aim for when adjusting shutter/gain."
),
},
),
"max_iterations": fields.Int(
load_default=20,
metadata={
"description": (
"The number of adjustments to the camera's settings to make before giving up."
)
},
),
"tolerance": fields.Float(
load_default=0.05,
metadata={
"example": 0.05,
"description": (
"We stop adjusting when we get within this fraction of the target "
"value. It is a number between 0 and 1, usually 0.01--0.1."
),
},
),
"percentile": percentile_field,
}
def post(self, args):
with find_picamera() as (picamera, _, _):
adjust_shutter_and_gain_from_raw(picamera, **args)
class AutoWhiteBalanceFromRawView(ActionView):
args = {"percentile": percentile_field}
def post(self, args):
with find_picamera() as (picamera, _, _):
adjust_white_balance_from_raw(picamera, **args)
class GetRawChannelPercentilesView(ActionView):
args = {
"percentile": fields.Float(
metadata={
"description": "A float between 0 and 100 setting the centile to calculate",
"example": 99.9,
}
)
}
schema = fields.List(fields.Integer)
def post(self, args):
with find_picamera() as (picamera, _, _):
return get_channel_percentiles(picamera, args["percentile"])

View file

@ -1,408 +0,0 @@
"""
Functions to set up a Raspberry Pi Camera v2 for scientific use
This module provides slower, simpler functions to set the
gain, exposure, and white balance of a Raspberry Pi camera, using
`picamerax` (a fork of `picamera`) to get as-manual-as-possible
control over the camera. It's mostly used by the OpenFlexure
Microscope, though it deliberately has no hard dependencies on
said software, so that it's useful on its own.
There are three main calibration steps:
* Setting exposure time and gain to get a reasonably bright
image.
* Fixing the white balance to get a neutral image
* Taking a uniform white image and using it to calibrate
the Lens Shading Table
The most reliable way to do this, avoiding any issues relating
to "memory" or nonlinearities in the camera's image processing
pipeline, is to use raw images. This is quite slow, but very
reliable. The three steps above can be accomplished by:
```
picamera = picamerax.PiCamera()
adjust_shutter_and_gain_from_raw(picamera)
adjust_white_balance_from_raw(picamera)
lst = lst_from_camera(picamera)
picamera.lens_shading_table = lst
```
"""
import logging
import time
from typing import List, NamedTuple, Optional, Tuple
import numpy as np
from picamerax import PiCamera
from picamerax.array import PiBayerArray, PiRGBArray
def rgb_image(
camera: PiCamera, resize: Optional[Tuple[int, int]] = None, **kwargs
) -> PiRGBArray:
"""Capture an image and return an RGB numpy array"""
with PiRGBArray(camera, size=resize) as output:
camera.capture(output, format="rgb", resize=resize, **kwargs)
return output.array
def flat_lens_shading_table(camera: PiCamera) -> np.ndarray:
"""Return a flat (i.e. unity gain) lens shading table.
This is mostly useful because it makes it easy to get the size
of the array correct. NB if you are not using the forked picamera
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"
)
# pylint: disable=protected-access
return np.zeros(camera._lens_shading_table_shape(), dtype=np.uint8) + 32
def adjust_exposure_to_setpoint(camera: PiCamera, setpoint: int):
"""Adjust the camera's exposure time until the maximum pixel value is <setpoint>.
NB this method uses RGB images (i.e. processed ones) not raw images.
"""
logging.info(f"Adjusting shutter speed to hit setpoint {setpoint}")
for _ in range(3):
camera.shutter_speed = int(
camera.shutter_speed * setpoint / np.max(rgb_image(camera))
)
time.sleep(1)
def set_minimum_exposure(camera: PiCamera):
"""Enable manual exposure, with low gain and shutter speed
We set exposure mode to manual, analog and digital gain
to 1, and shutter speed to the minimum (8us for Pi Camera v2)
NB ISO is left at auto, because this is needed for the gains
to be set correctly.
"""
camera.exposure_mode = "off"
camera.iso = 0 # We must set ISO=0 (auto) or we can't set gain
camera.analog_gain = 1
camera.digital_gain = 1
# Setting the shutter speed to 1us will result in it being set
# to the minimum possible, which is probably 8us for PiCamera v2
camera.shutter_speed = 1
time.sleep(0.5)
class ExposureTest(NamedTuple):
"""Record the results of testing the camera's current exposure settings"""
level: int
shutter_speed: int
analog_gain: float
def test_exposure_settings(camera: PiCamera, percentile: float) -> ExposureTest:
"""Evaluate current exposure settings using a raw image
We will acquire a raw image and calculate the given percentile
of the pixel values. We return a dictionary containing the
percentile (which will be compared to the target), as well as
the camera's shutter and gain values.
"""
max_brightness = np.max(get_channel_percentiles(camera, percentile))
# The reported brightness can, theoretically, be negative or zero
# because of black level compensation. The line below forces a
# minimum value of 1 which will keep things well-behaved!
if max_brightness < 1:
logging.warning(
f"Measured brightness of {max_brightness}. "
"This should normally be >= 1, and may indicate the "
"camera's black level compensation has gone wrong."
)
max_brightness = 1
shutter_speed = int(camera.shutter_speed)
analog_gain = float(camera.analog_gain)
logging.info(
f"Brightness: {max_brightness: >5.0f}, "
f"Gain: {analog_gain: >4.1f}, "
f"Shutter: {shutter_speed: >7.0f}"
)
return ExposureTest(max_brightness, shutter_speed, analog_gain)
def check_convergence(test: ExposureTest, target: int, tolerance: float):
"""Check whether the brightness is within the specified target range"""
converged = abs(test.level - target) < target * tolerance
return converged
def adjust_shutter_and_gain_from_raw(
camera: PiCamera,
target_white_level: int = 700,
max_iterations: int = 20,
tolerance: float = 0.05,
percentile: float = 99.9,
) -> float:
"""Adjust exposure and analog gain based on raw images.
This routine is slow but effective. It uses raw images, so we
are not affected by white balance or digital gain.
Arguments:
target_white_level:
The raw, 10-bit value we aim for. The brightest pixels
should be approximately this bright. Maximum possible
is about 900, 700 is reasonable.
max_iterations:
We will terminate once we perform this many iterations,
whether or not we converge. More than 10 shouldn't happen.
tolerance:
How close to the target value we consider "done". Expressed
as a fraction of the ``target_white_level`` so 0.05 means
+/- 5%
percentile:
Rather then use the maximum value for each channel, we
calculate a percentile. This makes us robust to single
pixels that are bright/noisy. 99.9% still picks the top
of the brightness range, but seems much more reliable
than just ``np.max()``.
"""
if target_white_level * (tolerance + 1) >= 959:
raise ValueError(
"The target level is too high - a saturated image would be "
"considered successful. target_white_level * (tolerance + 1) "
"must be less than 959."
)
set_minimum_exposure(camera)
# We start with very low exposure settings and work up
# until either the brightness is high enough, or we can't increase the
# shutter speed any more.
iterations = 0
while iterations < max_iterations:
test = test_exposure_settings(camera, percentile)
if check_convergence(test, target_white_level, tolerance):
break
iterations += 1
# Adjust shutter speed so that the brightness approximates the target
# NB we put a maximum of 32 on this, to stop it increasing too quickly.
camera.shutter_speed = int(
test.shutter_speed * min(target_white_level / test.level, 32)
)
time.sleep(0.5)
# Check whether the shutter speed is still going up - if not, we've hit a maximum
if camera.shutter_speed == test.shutter_speed:
logging.info("Shutter speed has maxed out.")
break
# Now, if we've not converged, increase gain until we converge or run out of options.
while iterations < max_iterations:
test = test_exposure_settings(camera, percentile)
if check_convergence(test, target_white_level, tolerance):
break
iterations += 1
# Adjust gain to make the white level hit the target, again with a maximum
camera.analog_gain *= min(target_white_level / test.level, 2)
time.sleep(0.5)
# Check the gain is still changing - if not, we have probably hit the maximum
if camera.analog_gain == test.analog_gain:
logging.info("Gain has maxed out.")
break
if check_convergence(test, target_white_level, tolerance):
logging.info(f"Brightness has converged to within {tolerance * 100 :.0f}%.")
else:
logging.warning(
f"Failed to reach target brightness of {target_white_level}."
f"Brightness reached {test.level} after {iterations} iterations."
)
return test.level
def adjust_white_balance_from_raw(
camera: PiCamera, percentile: float = 99
) -> Tuple[float, float]:
"""Adjust the white balance in a single shot, based on the raw image.
NB if ``channels_from_raw_image`` is broken, this will go haywire.
We should probably have better logic to verify the channels really
are BGGR...
"""
blue, g1, g2, red = get_channel_percentiles(camera, percentile)
green = (g1 + g2) / 2.0
new_awb_gains = (green / red, green / blue)
logging.info(
f"Raw white point is R: {red} G: {green} B: {blue}, "
f"setting AWB gains to ({new_awb_gains[0]:.2f}, "
f"{new_awb_gains[1]:.2f})."
)
camera.awb_mode = "off"
camera.awb_gains = new_awb_gains
return new_awb_gains
def channels_from_bayer_array(bayer_array: np.ndarray) -> np.ndarray:
"""Given the 'array' from a PiBayerArray, return the 4 channels."""
bayer_pattern: List[Tuple[int, int]] = [(0, 0), (0, 1), (1, 0), (1, 1)]
channels_shape: Tuple[int, ...] = (
4,
bayer_array.shape[0] // 2,
bayer_array.shape[1] // 2,
)
channels: np.ndarray = np.zeros(channels_shape, 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
)
return channels
def get_channel_percentiles(camera: PiCamera, percentile: float) -> np.ndarray:
"""Calculate the brightness percentile of the pixels in each channel
This is a number between -64 and 959 for each channel, because the
camera takes 10-bit images (maximum=1023) and its zero level is set
at 64 for denoising purposes (there's black level compensation built
in, and to avoid skewing the noise, the black level is set as 64 to
leave some room for negative values.
"""
with PiBayerArray(camera) as output:
camera.capture(output, format="jpeg", bayer=True)
channels = channels_from_bayer_array(output.array)
return np.percentile(channels, percentile, axis=(1, 2)) - 64
def lst_from_channels(channels: np.ndarray) -> np.ndarray:
"""Given the 4 Bayer colour channels from a white image, generate a LST."""
full_resolution: np.ndarray = np.array(
channels.shape[1:]
) * 2 # channels have been binned
# NOTE: the size of the LST is 1/64th of the image, but rounded UP.
lst_resolution: List[int] = [(r // 64) + 1 for r in full_resolution]
logging.info("Generating a lens shading table at %sx%s", *lst_resolution)
lens_shading: np.ndarray = np.zeros(
[channels.shape[0]] + lst_resolution, dtype=float
)
for i in range(lens_shading.shape[0]):
image_channel: np.ndarray = channels[i, :, :]
iw: int
ih: int
iw, ih = image_channel.shape
ls_channel: np.ndarray = lens_shading[i, :, :]
lw: int
lh: int
lw, lh = ls_channel.shape
# The lens shading table is rounded **up** in size to 1/64th of the size of
# the image. Rather than handle edge images separately, I'm just going to
# pad the image by copying edge pixels, so that it is exactly 32 times the
# size of the lens shading table (NB 32 not 64 because each channel is only
# 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.ndarray = 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: %sx%s, shading table shape: %sx%s, after padding %s",
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: int = 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
# 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:
ls_channel /= np.max(ls_channel)
# NB the central pixel should now be *approximately* 1.0 (may not be exactly
# due to different averaging widths between the normalisation & shading table)
# For most sensible lenses I'd expect that 1.0 is the maximum value.
# NB ls_channel should be a "view" of the whole lens shading array, so we don't
# need to update the big array here.
# 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: np.ndarray = 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: np.ndarray = gains.astype(np.uint8)
return lens_shading_table[::-1, :, :].copy()
def lst_from_camera(camera: PiCamera) -> np.ndarray:
"""Acquire a raw image and use it to calculate a lens shading table."""
with PiBayerArray(camera) as a:
camera.capture(a, format="jpeg", bayer=True)
raw_image = a.array.copy()
# Now we need to calculate a lens shading table that would make this flat.
# raw_image is a 3D array, with full resolution and 3 colour channels. No
# de-mosaicing has been done, so 2/3 of the values are zero (3/4 for R and B
# channels, 1/2 for green because there's twice as many green pixels).
channels = channels_from_bayer_array(raw_image)
return lst_from_channels(channels)
def recalibrate_camera(camera: PiCamera):
"""Reset the lens shading table and exposure settings.
This method first resets to a flat lens shading table, then auto-exposes,
then generates a new lens shading table to make the current view uniform.
It should be run when the camera is looking at a uniform white scene.
NB the only parameter ``camera`` is a ``PiCamera`` instance and **not** a
``StreamingCamera``.
"""
camera.lens_shading_table = flat_lens_shading_table(camera)
_ = rgb_image(camera) # for some reason the camera won't work unless I do this!
lens_shading_table = lst_from_camera(camera)
camera.lens_shading_table = lens_shading_table
_ = rgb_image(camera)
# Fix the AWB gains so the image is neutral
channel_means = np.mean(np.mean(rgb_image(camera), axis=0, dtype=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],
)
time.sleep(1)
# Ensure the background is bright but not saturated
adjust_exposure_to_setpoint(camera, 230)
if __name__ == "__main__":
with PiCamera() as main_camera:
main_camera.start_preview()
time.sleep(3)
logging.info("Recalibrating...")
recalibrate_camera(main_camera)
logging.info("Done.")
time.sleep(2)

View file

@ -1,646 +0,0 @@
import datetime
import logging
import time
import uuid
from typing import Callable, Dict, List, Optional, Tuple
import marshmallow
import numpy as np
from labthings import (
current_action,
fields,
find_component,
find_extension,
update_action_progress,
)
from labthings.extensions import BaseExtension
from labthings.views import ActionView
from typing_extensions import Literal
from openflexure_microscope.api.v2.views.actions.camera import FullCaptureArgs
from openflexure_microscope.captures.capture_manager import generate_basename
from openflexure_microscope.devel import abort
from openflexure_microscope.microscope import Microscope
# Type alias for convenience
XyCoordinate = Tuple[int, int]
XyzCoordinate = Tuple[int, int, int]
### Grid construction
class FocusManager:
"""Manage axial motion during a series of XY moves
This class keeps track of the focus position as we move around.
It currently uses the regular autofocus method, and has support
for background detection.
"""
initial_position: XyzCoordinate = None # type: ignore[assignment]
focused_positions: List[XyzCoordinate] = None # type: ignore[assignment]
microscope: Microscope = None # type: ignore[assignment]
current_image_is_background_function: Optional[Callable] = None
autofocus_function: Optional[Callable] = None
axial_jump_threshold: Optional[float] = None
def __init__(
self,
microscope: Microscope,
initial_position: XyzCoordinate,
autofocus_function: Optional[Callable],
current_image_is_background_function: Optional[Callable] = None,
axial_jump_threshold: Optional[float] = 0.4,
):
"""Set up management of axial motion.
The `FocusManager` keeps track of previous positions where the
microscope was in focus, and will estimate the best Z value for
future XY positions.
Arguments:
* microscope: The microscope object.
* initial_position: the XYZ position of the start of the scan
* autofocus: A function that performs an autofocus.
* current_image_is_background: a function that returns `True`
if the microscope is currently looking at an empty field.
* axial_jump_threshold: the maximum ratio between axial and
lateral moves. If this is not None, it will not count the
autofocus routine as successful if the focus moves more than
this ratio times the lateral move between two points. This can
help avoid focus drift due to accidentally focusing on the coverslip.
If either of the `autofocus` or `current_image_is_background`
functions are None, we will neither perform an autofocus, nor
use background estimation.
"""
self.initial_position = initial_position
self.focused_positions = []
self.microscope = microscope
self.autofocus_function = autofocus_function
if not autofocus_function:
logging.info("Setting up FocusManager with autofocus disabled.")
self.current_image_is_background_function = current_image_is_background_function
self.axial_jump_threshold = axial_jump_threshold
def record_focused_point(self, position: XyzCoordinate):
"""Add a position to the list of successfully-focused points"""
self.focused_positions.append(position)
def closest_focused_point(self, position: XyCoordinate) -> Optional[XyzCoordinate]:
"""The closest point in our list of focused points to a given XY position."""
return closest_point_in_xy(position, self.focused_positions)
def estimate_z(self, position: XyCoordinate) -> int:
"""Estimate the z position most likely to be in focus at an XY point
The next z position is estimated based on the closest point that was in focus.
For a snake/spiral scan, this should always be the last point, unless
it's skipped for some reason. In a raster scan, this should be the last
point, except when we're at the start of a row when it will be the first
point of the preceding row.
It is possible that for some scan geometries, we won't be using the most
recent point (e.g. if X and Y spacing in a raster scan are very different).
This does not happen with the default settings used for raster scanning
clinical samples.
"""
closest_focused_point = self.closest_focused_point(position)
if closest_focused_point:
return closest_focused_point[2]
else:
return self.initial_position[2]
def check_for_axial_jumps(self, position: XyzCoordinate) -> bool:
"""Check if a position is inconsistent with previous positions.
This function returns `True` if the specified position is not consistent
with the list of previously-visited positions, i.e. it's made a big axial
move but a small lateral move.
The threshold is set by `self.axial_jump_threshold`, and if that is `None`
no check is performed. Sensible values are probably between 0.1 and 0.5.
"""
if not self.axial_jump_threshold:
return False
closest_focused_point = self.closest_focused_point(position[:2])
if not closest_focused_point:
return False
move = np.array(position) - np.array(closest_focused_point)
lateral_move = np.sqrt(np.sum(move[:2] ** 2))
axial_move = np.abs(move[2])
return axial_move > lateral_move * self.axial_jump_threshold
def autofocus(self):
"""Perform an autofocus routine.
If autofocus is disabled, nothing happens here. If it is enabled, we optionally
check whether there's anything in the image to focus on, and then run the autofocus
routine.
"""
if not self.autofocus_function:
logging.debug("Autofocus is disabled, skipping autofocus.")
return
if self.current_image_is_background_function:
# If it's been set, call the background detect function and skip
# autofocus if appropriate
if self.current_image_is_background_function():
here = self.microscope.stage.position
logging.info(f"Detected an empty field at {here}, skipping autofocus.")
return
# Assuming it's not disabled, and we're not skipping it, actually run the autofocus now
self.autofocus_function()
# Now, check for big jumps and record the new position if we've not made a big jump
here = self.microscope.stage.position
if self.check_for_axial_jumps(here):
logging.warning(
f"During a scan, there was a large axial jump from "
f"{self.closest_focused_point(here[:2])}"
f" to {here}. This may mean autofocus has failed."
)
else:
# If there has not been a jump in focus, record the point as successful
self.record_focused_point(here)
def construct_grid(
initial: XyCoordinate,
step_sizes: XyCoordinate,
n_steps: XyCoordinate,
style: Literal["raster", "snake", "spiral"] = "raster",
) -> List[List[XyCoordinate]]:
"""
Given an initial position, step sizes, and number of steps,
construct a 2-dimensional list of scan x-y positions.
"""
arr: List[List[XyCoordinate]] = [] # 2D array of coordinates
if style == "spiral":
# deal with the centre image immediately
coord = initial
arr.append([initial])
# for spiral, n_steps is the number of shells, and so only requires n_steps[0]
for i in range(2, n_steps[0] + 1):
arr.append([]) # Append new shell holder
side_length = (2 * i) - 1
# Iteratively generate the next location to append
# Start coordinate of the shell
# We create a copy of coord so that the new value of coord doesn't depend on itself
# Otherwise we create a generator, not a tuple, which makes type checking angry
last_coordinate: XyCoordinate = coord
coord = (
last_coordinate[0] + [-1, 1][0] * step_sizes[0],
last_coordinate[1] + [-1, 1][1] * step_sizes[1],
)
for direction in ([1, 0], [0, -1], [-1, 0], [0, 1]):
for _ in range(side_length - 1):
last_coordinate = coord
coord = (
last_coordinate[0] + direction[0] * step_sizes[0],
last_coordinate[1] + direction[1] * step_sizes[1],
)
arr[i - 1].append(coord)
# If raster or snake
else:
for i in range(n_steps[0]): # x axis
arr.append([])
for j in range(n_steps[1]): # y axis
# Create a coordinate tuple
coord = (
initial[0] + [i, j][0] * step_sizes[0],
initial[1] + [i, j][1] * step_sizes[1],
)
# Append coordinate array to position grid
arr[i].append(coord)
# Style modifiers
if style == "snake":
# For each line (row) in the coordinate array
for i, line in enumerate(arr):
# If it's an odd row
if i % 2 != 0:
# Reverse the list of coordinates
line.reverse()
return arr
def construct_grid_1d(
initial: XyCoordinate,
step_sizes: XyCoordinate,
n_steps: XyCoordinate,
style: Literal["raster", "snake", "spiral"] = "raster",
) -> List[XyCoordinate]:
"""Construct coordinates for a scan, returning a 1D list.
This is the same set of coordinates returned by `construct_grid`
but the list-of-lists is flattened to a simple list.
"""
path = []
grid = construct_grid(
initial=initial, step_sizes=step_sizes, n_steps=n_steps, style=style
)
for line in grid:
path += line # concatenate the lists
return path
def closest_point_in_xy(
current_position: XyCoordinate, points: List[XyzCoordinate]
) -> Optional[XyzCoordinate]:
"""Find the closest point in a list
Given a 2D position, find the 3D position that's closest in XY and return it.
In the event of a tie, the most recent (i.e. latest in the list) is returned.
If the list is empty, we return None
"""
if len(points) < 1:
return None
points_2d = np.asarray(points)[:, :2]
squared_distances = np.sum((points_2d - current_position) ** 2, axis=1)
# We reverse the distances before searching, as argmin will return the first
# point in the event of there being multiple points with the same minimum,
# and we want to pick the last one.
reverse_min_index = np.argmin(squared_distances[::-1])
# of course, now we must convert the index to be the right way round
min_index = len(points) - 1 - reverse_min_index
return points[int(min_index)] # The explicit cast is necessary for MyPy
### Capturing
class ScanExtension(BaseExtension):
def __init__(self):
BaseExtension.__init__(self, "org.openflexure.scan", version="2.0.0")
self._images_to_be_captured: int = 1
self._images_captured_so_far: int = 0
self.add_view(TileScanAPI, "/tile", endpoint="tile")
def capture(
self,
microscope: Microscope,
basename: Optional[str],
namemode: str = "coordinates",
temporary: bool = False,
use_video_port: bool = False,
resize: Optional[Tuple[int, int]] = None,
bayer: bool = False,
metadata: Optional[dict] = None,
annotations: Optional[Dict[str, str]] = None,
tags: Optional[List[str]] = None,
dataset: Optional[Dict[str, str]] = None,
):
metadata = metadata or {}
annotations = annotations or {}
tags = tags or []
# Construct a tile filename
if namemode == "coordinates":
filename = "{}_{}_{}_{}".format(basename, *microscope.stage.position)
else:
filename = "{}_{}".format(
basename,
str(self._images_captured_so_far).zfill(
len(str(self._images_to_be_captured))
),
)
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,
dataset=dataset,
metadata=metadata,
cache_key=folder,
)
def progress(self):
progress = (self._images_captured_so_far / self._images_to_be_captured) * 100
logging.info(progress)
return progress
def get_autofocus_function(self, dz: int, use_fast_autofocus: bool) -> Callable:
"""Return a function that will perform an autofocus routine.
This should be called at the start of a scan. It will check that
the necessary hardware and software are present, and raise a helpful
error if they are not.
"""
microscope = find_component("org.openflexure.microscope")
# Locate the autofocus extension
autofocus_extension = find_extension("org.openflexure.autofocus")
if not autofocus_extension:
raise RuntimeError(
"The Autofocus extension is missing: select 'None' as your autofocus "
"type to scan without autofocusing."
)
if not (microscope.has_real_stage() and microscope.has_real_camera()):
raise RuntimeError(
"A real stage and camera are needed in order to autofocus. You can "
"still run a scan without autofocus."
)
if use_fast_autofocus:
def autofocus():
# Run fast autofocus. Client should provide dz ~ 2000
autofocus_extension.fast_autofocus(microscope, dz=dz)
time.sleep(0.5)
return autofocus
else:
def autofocus():
# Run slow autofocus. Client should provide dz ~ 50
autofocus_extension.autofocus(microscope, range(-3 * dz, 4 * dz, dz))
time.sleep(0.5)
return autofocus
def get_background_detect_function(self):
"""Return a function that returns true if we are looking at background"""
# Check for the background detect extension, raise an error now if it's missing.
background_detect_extension = find_extension(
"org.openflexure.background-detect"
)
if not background_detect_extension:
raise RuntimeError(
"Detecting background fields requires the background detect extension and it was not found."
)
def current_image_is_background():
verdict = background_detect_extension.grab_and_classify_image()
logging.debug(f"Background detection verdict: {verdict}")
return verdict["classification"] == "background"
return current_image_is_background
### Scanning
def tile(
self,
microscope: Microscope,
basename: Optional[str] = None,
namemode: str = "coordinates",
temporary: bool = False,
stride_size: XyzCoordinate = (2000, 1500, 100),
grid: XyzCoordinate = (3, 3, 5),
style="raster",
autofocus_dz: int = 50,
use_video_port: bool = False,
resize: Optional[Tuple[int, int]] = None,
bayer: bool = False,
fast_autofocus: bool = False,
metadata: Optional[dict] = None,
annotations: Optional[Dict[str, str]] = None,
tags: Optional[List[str]] = None,
detect_empty_fields_and_skip_autofocus: bool = False,
):
metadata = metadata or {}
annotations = annotations or {}
tags = tags or []
start = time.time()
# Store initial position
initial_position = microscope.stage.position
# Construct an x-y scan path (list of 2D coordinates)
path = construct_grid_1d(
initial_position[:2], stride_size[:2], grid[:2], style=style
)
# Keep task progress
self._images_to_be_captured = len(path)
self._images_captured_so_far = 0
# Generate a basename if none given
if not basename:
basename = generate_basename()
# Add dataset metadata
dataset_d = {
"id": uuid.uuid4(),
"type": "xyzScan",
"name": basename,
"acquisitionDate": datetime.datetime.now().isoformat(),
"strideSize": stride_size,
"grid": grid,
"style": style,
"autofocusDz": autofocus_dz,
}
# Perform set-up to be able to autofocus, if needed
autofocus: Optional[Callable] = None
if autofocus_dz:
autofocus = self.get_autofocus_function(
dz=autofocus_dz, use_fast_autofocus=fast_autofocus
)
if detect_empty_fields_and_skip_autofocus:
current_image_is_background = self.get_background_detect_function()
else:
current_image_is_background = None
focus_manager = FocusManager(
microscope,
initial_position,
autofocus_function=autofocus,
current_image_is_background_function=current_image_is_background,
axial_jump_threshold=0.4
if detect_empty_fields_and_skip_autofocus
else None,
)
# Now step through each point in the x-y coordinate array
for x_y in path:
next_z = focus_manager.estimate_z(x_y)
# Move to new grid position
logging.debug("Moving to step %s", ([x_y[0], x_y[1], next_z]))
microscope.stage.move_abs((x_y[0], x_y[1], next_z))
# Autofocus (if requested)
focus_manager.autofocus()
# If we're not doing a z-stack, just capture
if grid[2] <= 1:
self.capture(
microscope,
basename,
namemode=namemode,
temporary=temporary,
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
dataset=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,
namemode=namemode,
temporary=temporary,
step_size=stride_size[2],
steps=grid[2],
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
dataset=dataset_d,
annotations=annotations,
tags=tags,
)
# Gracefully shut down if we have been requested to stop
if current_action() and current_action().stopped:
return
logging.debug("Returning to %s", (initial_position))
microscope.stage.move_abs(initial_position)
end = time.time()
logging.info("Scan took %s seconds", end - start)
def stack(
self,
microscope: Microscope,
basename: Optional[str] = None,
namemode: str = "coordinates",
temporary: bool = False,
step_size: int = 100,
steps: int = 5,
return_to_start: bool = True,
use_video_port: bool = False,
resize: Optional[Tuple[int, int]] = None,
bayer: bool = False,
metadata: Optional[dict] = None,
annotations: Optional[Dict[str, str]] = None,
dataset: Optional[Dict[str, str]] = None,
tags: Optional[List[str]] = None,
):
metadata = metadata or {}
annotations = annotations or {}
tags = tags or []
# Store initial position
initial_position = microscope.stage.position
logging.debug("Starting z-stack from position %s", 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("Starting scan from position %s", microscope.stage.position)
for i in range(steps):
time.sleep(0.1)
logging.debug("Capturing from position %s", microscope.stage.position)
self.capture(
microscope,
basename,
namemode=namemode,
temporary=temporary,
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
metadata=metadata,
annotations=annotations,
dataset=dataset,
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 %s", (step_size))
microscope.stage.move_rel((0, 0, step_size))
if return_to_start:
logging.debug("Returning to %s", (initial_position))
microscope.stage.move_abs(initial_position)
class TileScanArgs(FullCaptureArgs):
namemode = fields.String(
load_default="coordinates", metadata={"example": "coordinates"}
)
grid = fields.List(
fields.Integer(validate=marshmallow.validate.Range(min=1)),
load_default=[3, 3, 3],
metadata={"example": [3, 3, 3]},
)
style = fields.String(load_default="raster")
autofocus_dz = fields.Integer(load_default=50)
fast_autofocus = fields.Boolean(load_default=False)
stride_size = fields.List(
fields.Integer,
load_default=[2000, 1500, 100],
metadata={"example": [2000, 1500, 100]},
)
detect_empty_fields_and_skip_autofocus = fields.Boolean(load_default=False)
class TileScanAPI(ActionView):
args = TileScanArgs()
# 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 self.extension.tile(
microscope,
basename=args.get("filename"),
namemode=args.get("namemode"),
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"),
detect_empty_fields_and_skip_autofocus=args.get(
"detect_empty_fields_and_skip_autofocus"
),
)

View file

@ -1,230 +0,0 @@
import logging
import os
import tempfile
import uuid
import zipfile
from typing import IO, List, Optional
from flask import abort, send_file, url_for
from labthings import fields, find_component, update_action_progress
from labthings.extensions import BaseExtension
from labthings.schema import Schema, pre_dump
from labthings.utilities import description_from_view
from labthings.views import ActionView, PropertyView, View, described_operation
from openflexure_microscope.captures import CaptureObject
from openflexure_microscope.microscope import Microscope
class ZipObjectSchema(Schema):
id = fields.String()
data_size = fields.Number()
zip_size = fields.Number()
links = fields.Dict()
@pre_dump
def generate_links(self, data, **_):
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_: str, file_pointer: IO[bytes], data_size: Optional[float] = None
):
self.id: str = id_
self.fp: IO[bytes] = file_pointer
self.data_size: Optional[float] = data_size
self.zip_size: float = os.path.getsize(self.fp.name) * 1e-6
def close(self):
logging.debug(self.fp.name)
self.fp.close()
if os.path.exists(self.fp.name):
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: Microscope, capture_id_list: List[str]
):
logging.debug(capture_id_list)
# Get array of captures from IDs
optional_capture_list: List[Optional[CaptureObject]] = [
microscope.captures.images.get(capture_id) for capture_id in capture_id_list
]
# Remove Nones from list (missing/invalid captures)
capture_list: List[CaptureObject] = [
capture for capture in optional_capture_list if capture
]
# Get size (in bytes) of each capture
capture_sizes: List[float] = [
os.path.getsize(capture_obj.file) for capture_obj in capture_list
]
# Calculate size of input data in megabytes
data_size_megabytes: float = 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: int = len(capture_id_list)
# Create temporary file
fp: IO[bytes] = 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 = str(uuid.uuid4())
session_description: ZipObjectDescription = 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: str):
return self.session_zips[session_id].fp
def __del__(self):
for zd in self.session_zips.values():
zd.close()
class ZipBuilderExtension(BaseExtension):
def __init__(self):
super().__init__(
"org.openflexure.zipbuilder",
version="2.0.0",
description="Build and download capture collections as ZIP files",
)
self.manager = ZipManager()
self.add_view(ZipGetterAPIView, "/get/<string:session_id>", endpoint="get_id")
self.add_view(ZipListAPIView, "/get", endpoint="get")
self.add_view(ZipBuilderAPIView, "/build", endpoint="build")
class ZipBuilderAPIView(ActionView):
args = fields.List(fields.String(), required=True)
def post(self, args):
"""Build a zip file of some captures
Given a list of capture IDs as its argument, this action will
create a zip file that can be downloaded once the action has
completed.
"""
microscope = find_component("org.openflexure.microscope")
# Build a zip file from the supplied IDs
return self.extension.manager.marshaled_build_zip_from_capture_ids(
microscope, args
)
class ZipListAPIView(PropertyView):
"""List all the zip files currently available for download.
"""
schema = ZipObjectSchema(many=True)
def get(self):
return self.extension.manager.session_zips.values()
class ZipGetterAPIView(View):
"""Download or delete a particular capture collection ZIP file
"""
parameters = [
{
"name": "session_id",
"in": "path",
"description": "The unique ID of the zip builder session",
"required": True,
"schema": {"type": "string"},
}
]
responses = {404: {"description": "The session ID could not be found"}}
@described_operation
def get(self, session_id):
"""
Download a particular capture collection ZIP file
"""
if not session_id in self.extension.manager.session_zips:
return abort(404) # 404 Not Found
logging.info("Retrieving zip (session ID: %s)", session_id)
return send_file(
self.extension.manager.zip_fp_from_id(session_id).name,
mimetype="application/zip",
as_attachment=True,
attachment_filename=f"{session_id}.zip",
)
get.responses = {
200: {
"content": {"application/zip": {}},
"description": "A zip archive containing the selected captures",
}
}
@described_operation
def delete(self, session_id):
"""
Close and delete a particular capture collection ZIP file
"""
if not session_id in self.extension.manager.session_zips:
return abort(404) # 404 Not Found
# Close the file
self.extension.manager.session_zips[session_id].close()
# Delete the file reference
del self.extension.manager.session_zips[session_id]
return {"return": session_id}
delete.responses = {200: {"description": "The zip file was deleted"}}

View file

@ -1,3 +0,0 @@
from .tools import DevToolsExtension
LABTHINGS_EXTENSIONS = [DevToolsExtension]

View file

@ -1,40 +0,0 @@
import logging
import time
from labthings import fields
from labthings.extensions import BaseExtension
from labthings.views import ActionView
class DevToolsExtension(BaseExtension):
def __init__(self) -> None:
super().__init__(
"org.openflexure.dev.tools",
version="0.1.0",
description="Actions to cause various traumatic events in the microscope, used for testing.",
)
self.add_view(RaiseException, "/raise")
self.add_view(SleepFor, "/sleep")
class RaiseException(ActionView):
def post(self):
raise Exception("The developer raised an exception")
class SleepFor(ActionView):
schema = {"TimeAsleep": fields.Float()}
args = {
"time": fields.Float(
metadata={"description": "Time to sleep, in seconds", "example": 0.5}
)
}
def post(self, args):
sleep_time: int = args.get("time", 0)
logging.info("Going to sleep for %s...", sleep_time)
start = time.time()
time.sleep(sleep_time)
end = time.time()
logging.info("Waking up!")
return {"TimeAsleep": (end - start)}

View file

@ -1,36 +0,0 @@
API_TAGS = [
{
"name": "actions",
"description": (
"Actions that can be run on the microscope. These endpoints represent "
"actions that many not complete immediately, so they run on the server "
"as `Action` objects and their status can be queried using the links "
"embedded in the JSON action description."
),
"externalDocs": {
"url": "https://www.w3.org/TR/wot-thing-description/#actionaffordance",
"description": "W3C's description of Web of Things 'Action' resources.",
},
},
{
"name": "properties",
"description": (
"Properties can be read and/or written to, and affect the "
"state of the microscope."
),
"externalDocs": {
"url": "https://www.w3.org/TR/wot-thing-description/#propertyaffordance",
"description": "W3C's description of Web of Things 'Property' resources.",
},
},
{"name": "captures", "description": ""},
{"name": "extensions", "description": ""},
{"name": "events", "description": ""},
]
def add_spec_extras(spec):
"""Add extra documentation and features to the OpenAPI spec"""
# Add a list of tags, so we can control ordering and add descriptions
for t in API_TAGS:
spec.tag(t)

View file

@ -1,88 +0,0 @@
import errno
import logging
import os
from typing import Union
from flask import Blueprint, Flask, current_app, url_for
from flask.views import View
from . import gui
__all__ = [
"gui",
"view_class_from_endpoint",
"blueprint_for_module",
"get_bool",
"list_routes",
"create_file",
"init_default_extensions",
]
def view_class_from_endpoint(endpoint: str) -> View:
return current_app.view_functions[endpoint].view_class
def blueprint_for_module(module_name: str, api_ver: int = 2, suffix: str = ""):
return Blueprint(
blueprint_name_for_module(module_name, api_ver=api_ver, suffix=suffix),
module_name,
)
def blueprint_name_for_module(module_name: str, api_ver: int = 2, suffix: str = ""):
bp_name = module_name.split(".")[-1]
return f"v{api_ver}_{bp_name}_blueprint{suffix}"
def get_bool(get_arg: Union[bool, str]):
"""Convert GET request argument string to a Python bool"""
if isinstance(get_arg, bool):
return get_arg
elif get_arg == "true" or get_arg == "True" or get_arg == "1":
return True
else:
return False
def list_routes(app: Flask):
output = {}
for rule in app.url_map.iter_rules():
options = {}
for arg in rule.arguments:
options[arg] = "[{0}]".format(arg)
endpoint = rule.endpoint
methods = list(rule.methods)
url = url_for(rule.endpoint, **options)
line = {"endpoint": endpoint, "methods": methods}
output[url] = line
return output
def create_file(config_path: str):
if not os.path.exists(os.path.dirname(config_path)):
try:
os.makedirs(os.path.dirname(config_path))
except OSError as exc: # Guard against race condition
if exc.errno != errno.EEXIST:
raise
def init_default_extensions(extension_dir: str):
os.makedirs(extension_dir, exist_ok=True)
default_ext_path = os.path.join(extension_dir, "defaults.py")
if not os.path.isfile(default_ext_path): # If user extensions file doesn't exist
logging.warning("No extension file found at %s. Creating...", (extension_dir))
create_file(default_ext_path)
logging.info("Populating %s...", (default_ext_path))
with open(default_ext_path, "w", encoding="utf-8") as outfile:
outfile.write(_DEFAULT_EXTENSION_INIT)
_DEFAULT_EXTENSION_INIT = "from openflexure_microscope.api.default_extensions import *"

View file

@ -1,62 +0,0 @@
import copy
import logging
from functools import wraps
from typing import Callable, Union
from labthings.extensions import BaseExtension
def clean_rule(rule: str):
while rule[0] == "/":
rule = rule[1:]
return f"{rule}"
def build_gui_from_dict(gui_description: dict, extension_object: BaseExtension):
# Make a working copy of GUI description
api_gui = copy.deepcopy(gui_description)
# Grab the extensions rules dictionary
# TODO: Public property should be added to LabThings
ext_rules = extension_object._rules # pylint: disable=protected-access
# Expand shorthand routes into full relative URLs
if "forms" in gui_description and isinstance(api_gui["forms"], list):
for form in api_gui["forms"]:
# Clean leading slashes from rule
if "route" in form:
form["route"] = clean_rule(form["route"])
# Match rule in extension object
if "route" in form and form["route"] in ext_rules.keys():
form["route"] = ext_rules[form["route"]]["urls"][0]
else:
logging.warning("No valid expandable route found for %s", form["route"])
# Inject extension information
api_gui["id"] = extension_object.name
api_gui["version"] = extension_object.version
return api_gui
def build_gui_from_func(func: Callable, extension_object: BaseExtension):
@wraps(func)
def wrapped(*args, **kwargs):
return build_gui_from_dict(func(*args, **kwargs), extension_object)
return wrapped
def build_gui(gui_description: Union[dict, Callable], extension_object: BaseExtension):
# If given a function that generates a GUI dictionary
if callable(gui_description):
# Wrap in the route expander
return build_gui_from_func(gui_description, extension_object)
# If given a dictionary directly
elif isinstance(gui_description, dict):
# Build a GUI generator function
def gui_description_func():
return gui_description
# Wrap in the route expander
return build_gui_from_func(gui_description_func, extension_object)
else:
raise RuntimeError("GUI description must be a function or a dictionary")

View file

@ -1,6 +0,0 @@
from .actions import enabled_root_actions
from .camera import *
from .captures import *
from .instrument import *
from .stage import *
from .streams import *

View file

@ -1,56 +0,0 @@
"""
Top-level representation of enabled actions
"""
from flask import url_for
from labthings import current_labthing
from labthings.utilities import description_from_view
from labthings.views import View
from . import camera, stage, system
_actions = {
"capture": {
"rule": "/camera/capture/",
"view_class": camera.CaptureAPI,
"conditions": True,
},
"ramCapture": {
"rule": "/camera/ram-capture/",
"view_class": camera.RAMCaptureAPI,
"conditions": True,
},
"previewStart": {
"rule": "/camera/preview/start",
"view_class": camera.GPUPreviewStartAPI,
"conditions": True,
},
"previewStop": {
"rule": "/camera/preview/stop",
"view_class": camera.GPUPreviewStopAPI,
"conditions": True,
},
"move": {
"rule": "/stage/move/",
"view_class": stage.MoveStageAPI,
"conditions": True,
},
"zeroStage": {
"rule": "/stage/zero/",
"view_class": stage.ZeroStageAPI,
"conditions": True,
},
"shutdown": {
"rule": "/system/shutdown/",
"view_class": system.ShutdownAPI,
"conditions": system.is_raspberrypi(),
},
"reboot": {
"rule": "/system/reboot/",
"view_class": system.RebootAPI,
"conditions": system.is_raspberrypi(),
},
}
def enabled_root_actions():
return {k: v for k, v in _actions.items() if v["conditions"]}

View file

@ -1,157 +0,0 @@
import io
import logging
from typing import Dict, Optional, Tuple
from flask import send_file
from labthings import Schema, fields, find_component
from labthings.views import ActionView
from openflexure_microscope.api.v2.views.captures import CaptureSchema
class CaptureResizeSchema(Schema):
width = fields.Integer(metadata={"example": 640}, required=True)
height = fields.Integer(metadata={"example": 480}, required=True)
class BasicCaptureArgs(Schema):
use_video_port = fields.Boolean(load_default=False)
bayer = fields.Boolean(
load_default=False,
metadata={"description": "Include raw bayer data in capture"},
)
resize = fields.Nested(CaptureResizeSchema(), required=False)
class FullCaptureArgs(BasicCaptureArgs):
filename = fields.String(metadata={"example": "MyFileName"})
temporary = fields.Boolean(
load_default=False, metadata={"description": "Delete capture on shutdown"}
)
annotations = fields.Dict(
load_default={}, metadata={"example": {"Client": "SwaggerUI"}}
)
tags = fields.List(fields.String, load_default=[], metadata={"example": ["docs"]})
class CaptureAPI(ActionView):
"""
Create a new image capture.
"""
args = FullCaptureArgs()
schema = CaptureSchema()
def post(self, args):
"""
Create a new capture
"""
microscope = find_component("org.openflexure.microscope")
resize_dict: Optional[Dict[str, int]] = args.get("resize", None)
if resize_dict:
resize: Optional[Tuple[int, int]] = (
int(resize_dict["width"]),
int(resize_dict["height"]),
) # Convert dict to tuple
else:
resize = None
# Explicitally acquire lock (prevents empty files being created if lock is unavailable)
with microscope.camera.lock:
return microscope.capture(
filename=args.get("filename"),
temporary=args.get("temporary"),
use_video_port=args.get("use_video_port"),
resize=resize,
bayer=args.get("bayer"),
annotations=args.get("annotations"),
tags=args.get("tags"),
)
class RAMCaptureAPI(ActionView):
"""Take a non-persistent image capture."""
args = BasicCaptureArgs()
responses = {
200: {
"content": {"image/jpeg": {}},
"description": "A JPEG image, representing the capture",
}
}
def post(self, args):
"""
Take a non-persistant image capture.
"""
microscope = find_component("org.openflexure.microscope")
resize_dict: Optional[Dict[str, int]] = args.get("resize", None)
if resize_dict:
resize: Optional[Tuple[int, int]] = (
int(resize_dict["width"]),
int(resize_dict["height"]),
) # Convert dict to tuple
else:
resize = None
# Open a BytesIO stream to be destroyed once request has returned
with microscope.camera.lock, io.BytesIO() as stream:
microscope.camera.capture(
stream,
use_video_port=args.get("use_video_port"),
resize=resize,
bayer=args.get("bayer"),
)
stream.seek(0)
return send_file(io.BytesIO(stream.getbuffer()), mimetype="image/jpeg")
class GPUPreviewStartAPI(ActionView):
"""
Start the onboard GPU preview.
Optional "window" parameter can be passed to control the position and size of the preview window,
in the format ``[x, y, width, height]``.
"""
args = {
"window": fields.List(
fields.Integer, load_default=[], metadata={"example": [0, 0, 640, 480]}
)
}
def post(self, args):
"""
Start the onboard GPU preview.
"""
microscope = find_component("org.openflexure.microscope")
# Get window argument from request
window_arg = args.get("window")
logging.debug(window_arg)
# Default to no window
fullscreen: bool = True
window: Optional[Tuple[int, int, int, int]] = None
# If request argument is well formed, use that
if len(window_arg) == 4:
fullscreen = False
window = (int(w) for w in window_arg)
microscope.camera.start_preview(fullscreen=fullscreen, window=window)
return microscope.state
class GPUPreviewStopAPI(ActionView):
def post(self):
"""
Stop the onboard GPU preview.
"""
microscope = find_component("org.openflexure.microscope")
microscope.camera.stop_preview()
return microscope.state

View file

@ -1,65 +0,0 @@
import logging
from labthings import fields, find_component
from labthings.views import ActionView
class MoveStageAPI(ActionView):
args = {
"absolute": fields.Boolean(
load_default=False,
metadata={"description": "Move to an absolute position", "example": False},
),
"x": fields.Int(load_default=None, metadata={"example": 100}, allow_none=True),
"y": fields.Int(load_default=None, metadata={"example": 100}, allow_none=True),
"z": fields.Int(load_default=None, metadata={"example": 20}, allow_none=True),
}
def post(self, args):
"""
Move the microscope stage in x, y, z
This action moves the stage. Any axes that are not specifed will not move.
If `absolute=True` is specified, the stage will move to the absolute
coordinates given. If not (the default), a relative move is made, i.e.
`x=0, y=0, z=0` corresponds to no motion.
"""
microscope = find_component("org.openflexure.microscope")
if not microscope.stage:
logging.warning("Unable to move. No stage found.")
return microscope.state["stage"]["position"]
absolute_move = args.get("absolute")
move = [0, 0, 0] # Default to no motion
for i, axis in enumerate(["x", "y", "z"]):
if axis in args and args[axis] is not None:
if absolute_move:
# We emulate absolute moves by calculating a relative move that
# will take us to the right position.
move[i] = args[axis] - microscope.stage.position[i]
else:
move[i] = args[axis]
logging.debug(f"Moving stage by {move}, request was {args}")
# Explicitly acquire lock with 1s timeout
with microscope.stage.lock(timeout=1):
microscope.stage.move_rel(move)
return microscope.state["stage"]["position"]
class ZeroStageAPI(ActionView):
def post(self):
"""
Zero the stage coordinates.
This action does not move the stage, but rather makes the current position read as [0, 0, 0]
"""
microscope = find_component("org.openflexure.microscope")
with microscope.stage.lock(timeout=1):
microscope.stage.zero_position()
return microscope.state["stage"]

View file

@ -1,50 +0,0 @@
import os
import subprocess
from labthings.views import ActionView
def is_raspberrypi() -> bool:
"""
Checks if Raspberry Pi.
"""
# I mean, if it works, it works...
return os.path.exists("/usr/bin/raspi-config")
class ShutdownAPI(ActionView):
"""
Attempt to shutdown the device
"""
def post(self):
"""
Attempt to shutdown the device
"""
p = subprocess.Popen(
["sudo", "shutdown", "-h", "now"],
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
)
out, err = p.communicate()
return {"out": out, "err": err}
class RebootAPI(ActionView):
"""
Attempt to reboot the device
"""
def post(self):
"""
Attempt to reboot the device
"""
p = subprocess.Popen(
["sudo", "shutdown", "-r", "now"],
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
)
out, err = p.communicate()
return {"out": out, "err": err}

View file

@ -1,51 +0,0 @@
import datetime
import logging
from io import BytesIO
import numpy as np
from flask import send_file
from labthings import find_component
from labthings.views import PropertyView
from PIL import Image
class LSTImageProperty(PropertyView):
"""Gets the lens-shading table as an image"""
responses = {
200: {
"content": {"image/jpeg": {}},
"description": "Lens-shading table in RGB format",
}
}
def get(self):
"""
Get the lens shading table as an image.
"""
microscope = find_component("org.openflexure.microscope")
# Return intentionally empty response if no LST is available
if microscope.get_configuration()["camera"]["type"] != "PiCameraStreamer":
logging.warning("Requested an LST from a non-PiCameraStreamer camera")
return ("", 204)
# Return intentionally empty response if we have a valid PiCamera but no LST
elif getattr(microscope.camera.camera, "lens_shading_table") is None:
logging.warning("PiCamera.lens_shading_table returned as None")
return ("", 204)
else:
# Get the LST Numpy array from the camera's memoryview
lst = np.asarray(microscope.camera.camera.lens_shading_table)
# Create array of R next to G1
top = np.concatenate(lst[:2], axis=1)
# Create array of G2 next to B
bottom = np.concatenate(lst[2:], axis=1)
# Create combined array of 2x2 grid of greyscale images
all_channels = np.concatenate((top, bottom), axis=0)
# Create a PNG
img_bytes: BytesIO = BytesIO()
Image.fromarray(np.uint8(all_channels), "L").save(img_bytes, "PNG")
img_bytes.seek(0)
# Return the image
fname: str = f"lst-{datetime.date.today().isoformat()}.png"
return send_file(img_bytes, as_attachment=True, attachment_filename=fname)

View file

@ -1,327 +0,0 @@
from io import BytesIO
from typing import List, Optional, Union
from uuid import UUID
from flask import abort, redirect, request, send_file, url_for
from labthings import Schema, fields, find_component
from labthings.marshalling import marshal_with, use_args
from labthings.utilities import description_from_view
from labthings.views import PropertyView, View
from marshmallow import pre_dump
from openflexure_microscope.api.utilities import get_bool
from openflexure_microscope.captures import CaptureObject
# SCHEMAS
class InstrumentSchema(Schema):
id = fields.UUID()
configuration = fields.Dict()
settings = fields.Dict()
state = fields.Dict()
class ImageSchema(Schema):
id = fields.UUID()
time = fields.String(metadata={"format": "date"})
format = fields.String()
name = fields.String()
tags = fields.List(fields.String())
annotations = fields.Dict(keys=fields.Str(), values=fields.Str())
class CaptureMetadataSchema(Schema):
# Full dataset dictionary will change depending on the type of
# dataset, so we can't make a specific schema in this case.
dataset = fields.Dict()
# Nested schema for Image data
image = fields.Nested(ImageSchema())
# Nested schema for instrument data
instrument = fields.Nested(InstrumentSchema())
class BasicDatasetSchema(Schema):
id = fields.UUID()
name = fields.String()
type = fields.String()
class CaptureSchema(ImageSchema):
"""
Schema containing only basic attributes required
for interacting with a capture. Additional attributes
are returned by using FullCaptureSchema
"""
# We need dataset information in the capture array
# so that client applications can sort data into folders
# without the server having to do a tonne of file IO
dataset = fields.Nested(BasicDatasetSchema())
file = fields.String(
data_key="path", metadata={"description": "Path of file on microscope device"}
)
# No need to make a schema for links as we only ever
# create the dictionary right here in `generate_links`
links = fields.Dict()
@pre_dump
def generate_links(self, data: Union[dict, CaptureObject], **_):
if isinstance(data, dict):
capture_id: Optional[Union[str, UUID]] = data.get("id")
capture_name: Optional[str] = data.get("name")
else:
capture_id = data.id
capture_name = data.name
links = {
"self": {
"href": url_for(CaptureView.endpoint, id_=capture_id, _external=True),
"mimetype": "application/json",
**description_from_view(CaptureView),
}
if CaptureView.endpoint
else {},
"tags": {
"href": url_for(CaptureTags.endpoint, id_=capture_id, _external=True),
"mimetype": "application/json",
**description_from_view(CaptureTags),
}
if CaptureTags.endpoint
else {},
"annotations": {
"href": url_for(
CaptureAnnotations.endpoint, id_=capture_id, _external=True
),
"mimetype": "application/json",
**description_from_view(CaptureAnnotations),
}
if CaptureAnnotations.endpoint
else {},
"download": {
"href": url_for(
CaptureDownload.endpoint,
id_=capture_id,
filename=capture_name,
_external=True,
),
"mimetype": "image/jpeg",
**description_from_view(CaptureDownload),
}
if CaptureDownload.endpoint
else {},
}
if isinstance(data, dict):
data["links"] = links
else:
setattr(data, "links", links)
return data
class FullCaptureSchema(CaptureSchema):
"""
Capture schema including metadata. We exclude this by default
since it can become huge due to complex settings including
lens shading tables and CSM matrices.
"""
metadata = fields.Nested(CaptureMetadataSchema())
# VIEWS
class CaptureList(PropertyView):
tags = ["captures"]
schema = CaptureSchema(many=True)
def get(self):
"""
List all image captures
"""
microscope = find_component("org.openflexure.microscope")
image_list: List[CaptureObject] = microscope.captures.images.values()
return image_list
CAPTURE_ID_PARAMETER = {
"name": "id_",
"in": "path",
"description": "The unique ID of the capture",
"required": True,
"schema": {"type": "string"},
"example": "eeae7ae9-0c0d-45a4-9ef2-7b84bb67a1d1",
}
class CaptureView(View):
tags = ["captures"]
parameters = [CAPTURE_ID_PARAMETER]
@marshal_with(FullCaptureSchema())
def get(self, id_):
"""
Description of a single image capture
"""
microscope = find_component("org.openflexure.microscope")
capture_obj: Optional[CaptureObject] = microscope.captures.images.get(id_)
if not capture_obj:
return abort(404) # 404 Not Found
return capture_obj
get.responses = {404: {"description": "Capture object was not found"}}
def delete(self, id_):
"""
Delete a single image capture
"""
microscope = find_component("org.openflexure.microscope")
capture_obj: Optional[CaptureObject] = microscope.captures.images.get(id_)
if not capture_obj:
return abort(404) # 404 Not Found
# Delete the capture file
capture_obj.delete()
# Delete from capture list
del microscope.captures.images[id_]
return "", 204
class CaptureDownload(View):
tags = ["captures"]
responses = {
200: {"content": {"image/jpeg": {}}, "description": "Image data in JPEG format"}
}
parameters = [
CAPTURE_ID_PARAMETER,
{
"name": "filename",
"in": "path",
"description": "The filename of the downloaded image.",
"required": False,
"schema": {"type": "string"},
"example": "myimage.jpeg",
},
]
def get(self, id_, filename: Optional[str]):
"""
Image data for a single image capture
"""
microscope = find_component("org.openflexure.microscope")
capture_obj: Optional[CaptureObject] = microscope.captures.images.get(id_)
if not capture_obj:
return abort(404) # 404 Not Found
thumbnail: bool = get_bool(request.args.get("thumbnail", ""))
# If no filename is specified, redirect to the capture's currently set filename
if not filename:
return redirect(
url_for(
"DownloadAPI",
id=id_,
filename=capture_obj.name,
thumbnail=thumbnail,
),
code=307,
)
# Download the image data using the requested filename
if thumbnail:
img: Optional[BytesIO] = capture_obj.thumbnail
else:
img = capture_obj.data
# If we can't get any data, return 404
if not img:
return abort(404) # 404 Not Found
return send_file(img, mimetype="image/jpeg")
class CaptureTags(View):
tags = ["captures"]
parameters = [CAPTURE_ID_PARAMETER]
def get(self, id_):
"""
Get tags associated with a single image capture
"""
microscope = find_component("org.openflexure.microscope")
capture_obj: Optional[CaptureObject] = microscope.captures.images.get(id_)
if not capture_obj:
return abort(404) # 404 Not Found
return capture_obj.tags
@use_args(fields.List(fields.String(), required=True))
def put(self, args, id_):
"""
Add tags to a single image capture
"""
microscope = find_component("org.openflexure.microscope")
capture_obj: Optional[CaptureObject] = microscope.captures.images.get(id_)
if not capture_obj:
return abort(404) # 404 Not Found
capture_obj.put_tags(args)
return capture_obj.tags
@use_args(fields.List(fields.String(), required=True))
def delete(self, args, id_):
"""
Delete tags from a single image capture
"""
microscope = find_component("org.openflexure.microscope")
capture_obj: Optional[CaptureObject] = microscope.captures.images.get(id_)
if not capture_obj:
return abort(404) # 404 Not Found
for tag in args:
capture_obj.delete_tag(str(tag))
return capture_obj.tags
class CaptureAnnotations(View):
tags = ["captures"]
parameters = [CAPTURE_ID_PARAMETER]
def get(self, id_):
"""
Get annotations associated with a single image capture
"""
microscope = find_component("org.openflexure.microscope")
capture_obj: Optional[CaptureObject] = microscope.captures.images.get(id_)
if not capture_obj:
return abort(404) # 404 Not Found
return capture_obj.annotations
@use_args(fields.Dict())
def put(self, args, id_):
"""
Update metadata for a single image capture
"""
microscope = find_component("org.openflexure.microscope")
capture_obj = microscope.captures.images.get(id_)
if not capture_obj:
return abort(404) # 404 Not Found
capture_obj.put_annotations(args)
return capture_obj.annotations

View file

@ -1,140 +0,0 @@
import logging
from typing import Any, List
from flask import abort
from labthings import fields, find_component
from labthings.marshalling import use_args
from labthings.utilities import create_from_path, get_by_path, set_by_path
from labthings.views import PropertyView, View
class SettingsProperty(PropertyView):
def get(self):
"""
Current microscope settings, including camera and stage
"""
microscope = find_component("org.openflexure.microscope")
return microscope.read_settings()
@use_args(fields.Dict())
def put(self, args):
"""
Update current microscope settings, including camera and stage
"""
microscope = find_component("org.openflexure.microscope")
logging.debug("Updating settings from PUT request:")
logging.debug(args)
microscope.update_settings(args)
microscope.save_settings()
return self.get()
ROUTE_PARAMETER = {
"name": "route",
"in": "path",
"description": (
"The location of a key or sub-dictionary. "
+ "This is formatted like a path, i.e. forward "
+ "slashes delimit components of the path."
),
"required": True,
"schema": {"type": "string"},
"example": "camera/exposure_time",
}
class NestedSettingsProperty(View):
tags = ["properties"]
responses = {404: {"description": "Settings key cannot be found"}}
parameters = [ROUTE_PARAMETER]
def get(self, route: str):
"""
Show a nested section of the current microscope settings
"""
microscope = find_component("org.openflexure.microscope")
keys: List[str] = route.split("/")
try:
value: Any = get_by_path(microscope.read_settings(), keys)
except KeyError:
return abort(404)
return value
@use_args(fields.Dict())
def put(self, args: Any, route: str):
"""
Update a nested section of the current microscope settings
"""
microscope = find_component("org.openflexure.microscope")
keys: List[str] = route.split("/")
dictionary: dict = create_from_path(keys)
set_by_path(dictionary, keys, args)
microscope.update_settings(dictionary)
microscope.save_settings()
return self.get(route)
class StateProperty(PropertyView):
def get(self):
"""
Show current read-only state of the microscope
"""
microscope = find_component("org.openflexure.microscope")
return microscope.state
class NestedStateProperty(View):
tags = ["properties"]
responses = {404: {"description": "Status key cannot be found"}}
parameters = [ROUTE_PARAMETER]
def get(self, route):
"""
Show a nested section of the current microscope state
"""
microscope = find_component("org.openflexure.microscope")
keys: List[str] = route.split("/")
try:
value: Any = get_by_path(microscope.state, keys)
except KeyError:
return abort(404)
return value
class ConfigurationProperty(PropertyView):
def get(self):
"""
Show current read-only state of the microscope
"""
microscope = find_component("org.openflexure.microscope")
return microscope.configuration
class NestedConfigurationProperty(View):
tags = ["properties"]
responses = {404: {"description": "Status key cannot be found"}}
parameters = [ROUTE_PARAMETER]
def get(self, route):
"""
Show a nested section of the current microscope state
"""
microscope = find_component("org.openflexure.microscope")
keys: List[str] = route.split("/")
try:
value: Any = get_by_path(microscope.configuration, keys)
except KeyError:
return abort(404)
return value

View file

@ -1,32 +0,0 @@
from labthings import fields, find_component
from labthings.views import PropertyView
from marshmallow import validate
class StageTypeProperty(PropertyView):
"""The type of the stage"""
schema = fields.String(
load_default="SangaStage",
validate=validate.OneOf(["SangaStage", "SangaDeltaStage"]),
metadata={
"description": "The translation stage geometry",
"example": "SangaStage",
},
allow_none=False,
)
def get(self):
"""
Get the stage geometry.
"""
microscope = find_component("org.openflexure.microscope")
return microscope.configuration["stage"]["type"]
def put(self, stage_type):
"""
Set the stage geometry.
"""
microscope = find_component("org.openflexure.microscope")
microscope.set_stage(stage_type=stage_type)
return microscope.configuration["stage"]["type"]

View file

@ -1,71 +0,0 @@
from flask import Response
from labthings import find_component
from labthings.views import PropertyView
def gen(camera):
"""Video streaming generator function."""
while True:
# the obtained frame is a jpeg
frame: bytes = camera.stream.getframe()
yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n")
class MjpegStream(PropertyView):
"""
Real-time MJPEG stream from the microscope camera
"""
responses = {
200: {
"content": {"multipart/x-mixed-replace": {}},
"description": (
"An MJPEG stream of camera images.\n\n"
"This endpoint will serve JPEG images sequentially, \n"
"with each frame separated by `--frame` and a \n"
"`Content-Type: image/jpeg` header.\n"
"Using this endpoint as the `src` of an HTML `<img>` \n"
"tag will result in the video stream displaying without \n"
"further effort.\n\n"
"If you save the stream to disk (e.g. with `curl`), be \n"
"aware that the text in between frames may confuse some \n"
"video players."
),
}
}
def get(self):
"""
MJPEG stream from the microscope camera.
Note: While the code actually getting frame data from a camera and storing it in
camera.frame runs in a thread, the gen(microscope.camera) generator does not.
This response is therefore blocking. The generator just yields the most recent
frame from the camera object, passed to the Flask response, and then repeats until
the connection is closed.
Without monkey patching, or using a native threaded server, the stream
will block all proceeding requests.
"""
microscope = find_component("org.openflexure.microscope")
return Response(
gen(microscope.camera), mimetype="multipart/x-mixed-replace; boundary=frame"
)
class SnapshotStream(PropertyView):
"""
Single JPEG snapshot from the camera stream
"""
responses = {200: {"content": {"image/jpeg": {}}, "description": "Snapshot taken"}}
def get(self):
"""
Single snapshot from the camera stream
"""
microscope = find_component("org.openflexure.microscope")
return Response(microscope.camera.stream.getframe(), mimetype="image/jpeg")

View file

@ -1,217 +0,0 @@
# -*- coding: utf-8 -*-
import io
import logging
import time
from abc import ABCMeta, abstractmethod
from types import TracebackType
from typing import BinaryIO, List, NamedTuple, Optional, Tuple, Type, Union
from labthings import ClientEvent, StrictLock
JPEG_END_BYTES: bytes = b"\xff\xd9"
# Class to store a frames metadata
class TrackerFrame(NamedTuple):
size: int
time: float
class FrameStream(io.BytesIO):
"""
A file-like object used to analyse and stream MJPEG frames.
Instead of analysing a load of real MJPEG frames
after they've been stored in a BytesIO stream,
we tell the camera to write frames to this class instead.
We then do analysis as the frames are written, and discard
old frames as each new frame is written.
"""
def __init__(self, *args, **kwargs):
# Array of TrackerFrame objects
io.BytesIO.__init__(self, *args, **kwargs)
# Array of TrackerFramer objects
self.frames: List[TrackerFrame] = []
# Last acquired TrackerFramer object
self.last: Optional[TrackerFrame] = None
# Are we currently tracking frame sizes?
self.tracking: bool = False
# Event to track if a new frame is available since the last getvalue() call
# We use a ClientEvent so that each thread can call getvalue() independantly
self.new_frame: ClientEvent = ClientEvent()
self._bad_frame: bool = False
def __enter__(self):
self.start_tracking()
return super().__enter__()
def __exit__(
self,
t: Optional[Type[BaseException]],
value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> None:
self.stop_tracking()
return super().__exit__(t, value, traceback)
def start_tracking(self):
"""Start tracking frame sizes"""
if not self.tracking:
logging.debug("Started tracking frame data")
self.tracking = True
def stop_tracking(self):
"""Stop tracking frame sizes"""
if self.tracking:
logging.debug("Stopped tracking frame data")
self.tracking = False
def reset_tracking(self):
"""Empty the array of tracked frame sizes"""
self.frames = []
def write(self, s):
"""
Write a new frame to the FrameStream. Does a few things:
1. If tracking frame size, store the size in self.frames
2. Rewind and truncate the stream (delete previous frame)
3. Store the new frame image
4. Set the new_frame event
"""
# If we get a bad frame, and the last frame was good
if s[-2:] != JPEG_END_BYTES and not self._bad_frame:
# TODO: Handle this more cleverly. Automatically lower bitrate to compensate?
# Log error
logging.error(
"Incomplete frame data recieved. Camera bandwidth may have been exceeded. Consider lowing resolution, framerate, or target bitrate."
)
# Record that last frame was bad
self._bad_frame = True
# If the last frame was bad, but this frame was good
elif self._bad_frame and s[-2:] == JPEG_END_BYTES:
# Clear the bad frame record
self._bad_frame = False
# If we're tracking frame size
if self.tracking:
frame = TrackerFrame(size=len(s), time=time.time())
self.frames.append(frame)
self.last = frame
# Reset the stream for the next frame
super().seek(0)
super().truncate()
# Write the new frame
super().write(s)
# Set the new frame event
self.new_frame.set()
def getvalue(self) -> bytes:
"""Clear tne new_frame event and return frame data"""
self.new_frame.clear()
return super().getvalue()
def getframe(self) -> bytes:
"""Wait for a new frame to be available, then return it"""
self.new_frame.wait()
return self.getvalue()
class BaseCamera(metaclass=ABCMeta):
"""
Base implementation of StreamingCamera.
"""
def __init__(self):
#: :py:class:`labthings.StrictLock`: Access lock for the camera
self.lock: StrictLock = StrictLock(name="Camera", timeout=None)
#: :py:class:`FrameStream`: Streaming and analysis frame buffer
self.stream: FrameStream = FrameStream()
self.stream_active: bool = False
self.record_active: bool = False
self.preview_active: bool = False
self.image_resolution: Tuple[int, int] = (1312, 976)
self.stream_resolution: Tuple[int, int] = (640, 480)
@property
@abstractmethod
def configuration(self):
"""The current camera configuration."""
@property
@abstractmethod
def state(self):
"""The current read-only camera state."""
@property
def settings(self):
return self.read_settings()
@abstractmethod
def start_stream(self):
"""Ensure the frame stream is actively running"""
@abstractmethod
def stop_stream(self):
"""Stop the active stream, if possible"""
@abstractmethod
def update_settings(self, config: dict):
"""Update settings from a config dictionary"""
@abstractmethod
def read_settings(self) -> dict:
"""Return the current settings as a dictionary"""
@abstractmethod
def capture(
self,
output: Union[str, BinaryIO],
fmt: str = "jpeg",
use_video_port: bool = False,
resize: Optional[Tuple[int, int]] = None,
bayer: bool = True,
thumbnail: Optional[Tuple[int, int, int]] = None,
):
"""
Perform a basic capture to output
Args:
output: String or file-like object to write capture data to
fmt: Format of the capture.
use_video_port: Capture from the video port used for streaming. Lower resolution, faster.
resize: Resize the captured image.
bayer: Store raw bayer data in capture
thumbnail: Dimensions and quality (x, y, quality) of a thumbnail to generate, if supported
"""
def start_worker(self, **_) -> bool:
"""Start the background camera thread if it isn't running yet."""
logging.warning(
"`start_worker` method has been deprecated and is no longer required. Please avoid calling this method."
)
return True
def get_frame(self) -> bytes:
"""
Return the current camera frame.
Just an alias of self.stream.getframe()
"""
return self.stream.getframe()
def __enter__(self):
"""Create camera on context enter."""
return self
def __exit__(self, exc_type, exc_value, traceback):
"""Close camera stream on context exit."""
self.close()
def close(self):
"""Close the BaseCamera and all attached StreamObjects."""
logging.info("Closed %s", (self))

View file

@ -1,248 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import division
import logging
import threading
import time
from datetime import datetime
# Type hinting
from typing import BinaryIO, Optional, Tuple, Union
from PIL import Image, ImageDraw
from openflexure_microscope.camera.base import BaseCamera
# PIL spams the logger with debug-level information. This is a pain when debugging api.app.
# We override the logging settings in api.app by setting a level for PIL here.
pil_logger = logging.getLogger("PIL")
pil_logger.setLevel(logging.INFO)
# MAIN CLASS
class MissingCamera(BaseCamera):
def __init__(self):
# Run BaseCamera init
BaseCamera.__init__(self)
# Update config properties
self.image_resolution: Tuple[int, int] = (1312, 976)
self.stream_resolution: Tuple[int, int] = (640, 480)
self.numpy_resolution: Tuple[int, int] = (1312, 976)
self.jpeg_quality: int = 75
self.framerate: int = 10
# Generate an initial dummy image
self.generate_new_dummy_image()
# Start streaming
self.stop: bool = False # Used to indicate that the stream loop should break
self.start_worker()
# Wait until frames are available
logging.info("Waiting for frames")
self.stream.new_frame.wait()
def generate_new_dummy_image(self):
# Create a dummy image to serve in the stream
image = Image.new(
"RGB",
(self.stream_resolution[0], self.stream_resolution[1]),
color=(0, 0, 0),
)
draw = ImageDraw.Draw(image)
draw.text(
(20, 70),
"Camera disconnected: {}".format(
datetime.now().strftime("%d/%m/%Y, %H:%M:%S")
),
)
image.save(self.stream, format="JPEG")
def start_worker(self, **_) -> bool:
"""Start the background camera thread if it isn't running yet."""
self.stop = False
if not self.stream_active:
# Start background frame thread
self.thread = threading.Thread(target=self._thread)
self.thread.daemon = True
self.thread.start()
return True
def stop_worker(self, timeout: int = 5) -> bool:
"""Flag worker thread for stop. Waits for thread close or timeout."""
logging.debug("Stopping worker thread")
timeout_time = time.time() + timeout
if self.stream_active:
self.stop = True
self.thread.join() # Wait for stream thread to exit
logging.debug("Waiting for stream thread to exit.")
while self.stream_active:
if time.time() > timeout_time:
logging.debug("Timeout waiting for worker thread close.")
raise TimeoutError("Timeout waiting for worker thread close.")
else:
time.sleep(0.1)
return True
def _thread(self):
"""Camera background thread."""
# Set the camera object's frame iterator
logging.debug("Entering worker thread.")
self.stream_active = True
while True:
# Only serve frames at 1fps
time.sleep(1)
# Generate new dummy image
self.generate_new_dummy_image()
try:
if self.stop is True:
logging.debug("Worker thread flagged for stop.")
break
except AttributeError:
pass
logging.debug("BaseCamera worker thread exiting...")
# Set stream_activate state
self.stream_active = False
@property
def configuration(self):
"""The current camera configuration."""
return {}
@property
def state(self):
"""The current read-only camera state."""
return {}
def close(self):
"""Close the Raspberry Pi PiCameraStreamer."""
# Run BaseCamera close method
BaseCamera.close(self)
# HANDLE SETTINGS
def read_settings(self) -> dict:
"""
Return config dictionary of the PiCameraStreamer.
"""
# Get config items from the base class
conf_dict = {
"stream_resolution": self.stream_resolution,
"image_resolution": self.image_resolution,
"numpy_resolution": self.numpy_resolution,
"jpeg_quality": self.jpeg_quality,
}
return conf_dict
def update_settings(self, config: dict):
"""
Write a config dictionary to the PiCameraStreamer config.
The passed dictionary may contain other parameters not relevant to
camera config. Eg. Passing a general config file will work fine.
Args:
config (dict): Dictionary of config parameters.
"""
logging.debug("MockStreamer: Applying config:")
logging.debug(config)
with self.lock:
# Apply valid config params to camera object
if not self.record_active: # If not recording a video
for key, value in config.items(): # For each provided setting
if hasattr(self, key):
setattr(self, key, value)
else:
raise Exception(
"Cannot update camera config while recording is active."
)
def set_zoom(self, *_, **__) -> None:
"""
Change the camera zoom, handling re-centering and scaling.
"""
logging.info("Zoom not implemented in mock camera")
def start_stream(self):
pass
def stop_stream(self):
pass
def start_preview(self, *_, **__):
"""Start the on board GPU camera preview."""
logging.info("GPU preview not implemented in mock camera")
def stop_preview(self):
"""Stop the on board GPU camera preview."""
logging.info("GPU preview not implemented in mock camera")
def start_recording(self, *_, **__):
"""Start recording.
Start a new video recording, writing to a output object.
Args:
output: String or file-like object to write capture data to
fmt (str): Format of the capture.
quality (int): Video recording quality.
Returns:
output_object (str/BytesIO): Target object.
"""
with self.lock:
# Start recording method only if a current recording is not running
logging.warning("Recording not implemented in mock camera")
def stop_recording(self):
"""Stop the last started video recording on splitter port 2."""
with self.lock:
logging.warning("Recording not implemented in mock camera")
def capture(
self,
output: Union[str, BinaryIO],
fmt: str = "jpeg",
use_video_port: bool = False,
resize: Optional[Tuple[int, int]] = None,
bayer: bool = True,
thumbnail: Optional[Tuple[int, int, int]] = None,
):
"""
Capture a still image to a StreamObject.
Defaults to JPEG format.
Target object can be overridden for development purposes.
Args:
output: String or file-like object to write capture data to
"""
with self.lock:
if isinstance(output, str):
output = open(output, "wb")
output.write(self.stream.getvalue())
if isinstance(output, str):
output.close()
else:
output.flush()

View file

@ -1,528 +0,0 @@
# -*- coding: utf-8 -*-
"""
Raspberry Pi camera implementation of the PiCameraStreamer class.
**NOTES:**
Still port used for image capture.
Preview port reserved for onboard GPU preview.
Video port:
* Splitter port 0: Image capture (if `use_video_port == True`)
* Splitter port 1: Streaming frames
* Splitter port 2: Video capture
* Splitter port 3: [Currently unused]
PiCameraStreamer streams at video_resolution
Camera capture resolution set to stream_resolution in frames()
Video port uses that resolution for everything. If a different resolution
is specified for video capture, this is handled by the resizer.
Still capture (if use_video_port == False) uses pause_stream
to temporarily increase the capture resolution.
"""
import logging
import time
# Type hinting
from typing import BinaryIO, Optional, Tuple, Union
import numpy as np
# Pi camera
import picamerax
import picamerax.array
from openflexure_microscope.camera.base import BaseCamera
from openflexure_microscope.utilities import json_to_ndarray, ndarray_to_json
# Richard's fix gain
from .set_picamera_gain import set_analog_gain, set_digital_gain
# MAIN CLASS
class PiCameraStreamer(BaseCamera):
"""Raspberry Pi camera implementation of PiCameraStreamer."""
picamera_settings_keys = [
"exposure_mode",
"analog_gain",
"digital_gain",
"shutter_speed",
"awb_gains",
"awb_mode",
"framerate",
"saturation",
"iso",
"brightness",
"contrast",
"crop",
"drc_strength",
"exposure_compensation",
"image_effect",
"meter_mode",
"sharpness",
"annotate_text",
"annotate_text_size",
"zoom",
]
def __init__(self):
# Run BaseCamera init
BaseCamera.__init__(self)
#: :py:class:`picamerax.PiCamera`: Attached Picamera object
self.picamera: picamerax.PiCamera = picamerax.PiCamera()
# Store state of PiCameraStreamer
self.preview_active: bool = False
# Reset variable states
self.set_zoom(1.0)
#: tuple: Resolution for image captures
self.image_resolution: Tuple[int, int] = tuple(self.picamera.MAX_RESOLUTION)
#: tuple: Resolution for stream and video captures
self.stream_resolution: Tuple[int, int] = (832, 624)
#: tuple: Resolution for numpy array captures
self.numpy_resolution: Tuple[int, int] = (1312, 976)
self.jpeg_quality: int = 100 #: int: JPEG quality
self.mjpeg_quality: int = 75 #: int: MJPEG quality
self.mjpeg_bitrate: int = -1 #: int: MJPEG quality
# Solid bitrate options:
# -1: Maximum
# 25000000: High
# 17000000: Normal
# 5000000: Low (may impact fast AF)
# 2500000: Very low (may impact fast AF)
# Start stream recording (and set resolution)
self.start_stream()
# Wait until frames are available
logging.debug("Waiting for frames...")
self.stream.new_frame.wait()
logging.debug("Camera initialised")
@property
def camera(self):
logging.warning(
"PiCameraStreamer.camera is deprecated. Replace with PiCameraStreamer.picamera"
)
return self.picamera
@property
def configuration(self) -> dict:
"""The current camera configuration."""
return {"board": self.picamera.revision}
@property
def state(self) -> dict:
"""The current read-only camera state."""
return {}
def close(self):
"""Close the Raspberry Pi PiCameraStreamer."""
# Stop stream recording
self.stop_stream()
# Run BaseCamera close method
super().close()
# Detach Pi camera
if self.picamera:
self.picamera.close()
# HANDLE SETTINGS
def read_settings(self) -> dict:
"""
Return config dictionary of the PiCameraStreamer.
"""
conf_dict: dict = {
"stream_resolution": self.stream_resolution,
"image_resolution": self.image_resolution,
"numpy_resolution": self.numpy_resolution,
"jpeg_quality": self.jpeg_quality,
"mjpeg_quality": self.mjpeg_quality,
"mjpeg_bitrate": self.mjpeg_bitrate,
"picamera": {},
}
# Include a subset of picamera properties. Excludes lens shading table
for key in PiCameraStreamer.picamera_settings_keys:
try:
value = getattr(self.picamera, key)
logging.debug("Reading PiCamera().%s: %s", key, value)
conf_dict["picamera"][key] = value
except AttributeError:
logging.debug("Unable to read PiCamera attribute %s", (key))
# Include a serialised lens shading table
if (
hasattr(self.picamera, "lens_shading_table")
and getattr(self.picamera, "lens_shading_table") is not None
):
conf_dict["picamera"]["lens_shading_table"] = ndarray_to_json(
getattr(self.picamera, "lens_shading_table")
)
return conf_dict
def update_settings(self, config: dict):
"""
Write a config dictionary to the PiCameraStreamer config.
The passed dictionary may contain other parameters not relevant to
camera config. Eg. Passing a general config file will work fine.
Args:
config (dict): Dictionary of config parameters.
"""
paused_stream = False
logging.debug("PiCameraStreamer: Applying config:")
logging.debug(config)
with self.lock(timeout=None):
# Apply valid config params to Picamera object
if not self.record_active: # If not recording a video
# Pause stream while changing settings
if self.stream_active: # If stream is active
logging.info("Pausing stream to update config.")
self.stop_stream() # Pause stream
paused_stream = True # Remember to unpause stream when done
# PiCamera parameters
if "picamera" in config: # If new settings are given
self.apply_picamera_settings(
config["picamera"], pause_for_effect=True
)
# Handle lens shading if camera supports it
if (
hasattr(self.picamera, "lens_shading_table")
and "lens_shading_table" in config["picamera"]
):
try:
self.picamera.lens_shading_table = json_to_ndarray(
config["picamera"].get("lens_shading_table")
)
except KeyError as e:
logging.error(e)
# PiCameraStreamer parameters
for key, value in config.items(): # For each provided setting
if (key != "picamera") and hasattr(self, key):
setattr(self, key, value)
# If stream was paused to update config, unpause
if paused_stream:
logging.info("Resuming stream.")
self.start_stream()
else:
raise Exception(
"Cannot update camera config while recording is active."
)
def apply_picamera_settings(
self, settings_dict: dict, pause_for_effect: bool = True
):
"""
Args:
settings_dict (dict): Dictionary of properties to apply to the :py:class:`picamerax.PiCamera`: object
pause_for_effect (bool): Pause tactically to reduce risk of timing issues
"""
# Set exposure mode
if "exposure_mode" in settings_dict:
logging.debug(
"Applying exposure_mode: %s", (settings_dict["exposure_mode"])
)
self.picamera.exposure_mode = settings_dict["exposure_mode"]
# Apply gains and let them settle
if "analog_gain" in settings_dict:
logging.debug("Applying analog_gain: %s", (settings_dict["analog_gain"]))
set_analog_gain(self.picamera, float(settings_dict["analog_gain"]))
if "digital_gain" in settings_dict:
logging.debug("Applying digital_gain: %s", (settings_dict["digital_gain"]))
set_digital_gain(self.picamera, float(settings_dict["digital_gain"]))
# Apply shutter speed
if "shutter_speed" in settings_dict:
logging.debug(
"Applying shutter_speed: %s", (settings_dict["shutter_speed"])
)
self.picamera.shutter_speed = int(settings_dict["shutter_speed"])
time.sleep(0.2) # Let gains settle
# Handle AWB in a half-smart way
if "awb_gains" in settings_dict:
logging.debug("Applying awb_mode: off")
self.picamera.awb_mode = "off"
logging.debug("Applying awb_gains: %s", (settings_dict["awb_gains"]))
self.picamera.awb_gains = settings_dict["awb_gains"]
elif "awb_mode" in settings_dict:
logging.debug("Applying awb_mode: %s", (settings_dict["awb_mode"]))
self.picamera.awb_mode = settings_dict["awb_mode"]
# Handle some properties that can be quickly applied
batched_keys = ["framerate", "saturation"]
for key in batched_keys:
if (key in settings_dict) and hasattr(self.picamera, key):
logging.debug("Applying %s: %s", key, settings_dict[key])
setattr(self.picamera, key, settings_dict[key])
# Final optional pause to settle
if pause_for_effect:
time.sleep(0.2)
def set_zoom(self, zoom_value: Union[float, int] = 1.0) -> None:
"""
Change the camera zoom, handling re-centering and scaling.
"""
with self.lock(timeout=None):
self.zoom_value = float(zoom_value)
if self.zoom_value < 1:
self.zoom_value = 1
# Richard's code for zooming !
fov = self.picamera.zoom
centre = np.array([fov[0] + fov[2] / 2.0, fov[1] + fov[3] / 2.0])
size = 1.0 / self.zoom_value
# If the new zoom value would be invalid, move the centre to
# keep it within the camera's sensor (this is only relevant
# when zooming out, if the FoV is not centred on (0.5, 0.5)
for i in range(2):
if np.abs(centre[i] - 0.5) + size / 2 > 0.5:
centre[i] = 0.5 + (1.0 - size) / 2 * np.sign(centre[i] - 0.5)
logging.info("setting zoom, centre %s, size %s", centre, size)
new_fov = (centre[0] - size / 2, centre[1] - size / 2, size, size)
self.picamera.zoom = new_fov
def start_preview(
self,
fullscreen: bool = True,
window: Optional[Tuple[int, int, int, int]] = None,
):
"""Start the on board GPU camera preview."""
with self.lock(timeout=1):
try:
if not self.picamera.preview:
logging.debug("Starting preview")
self.picamera.start_preview(fullscreen=fullscreen, window=window)
else:
logging.debug("Resizing preview")
if window:
self.picamera.preview.window = window
if fullscreen:
self.picamera.preview.fullscreen = fullscreen
self.preview_active = True
except picamerax.exc.PiCameraMMALError as e:
logging.error(
"Suppressed a MMALError in start_preview. Exception: %s", (e)
)
except picamerax.exc.PiCameraValueError as e:
logging.error(
"Suppressed a ValueError exception in start_preview. Exception: %s",
(e),
)
def stop_preview(self):
"""Stop the on board GPU camera preview."""
with self.lock(timeout=1):
if self.picamera.preview:
self.picamera.stop_preview()
self.preview_active = False
def start_recording(
self, output: Union[str, BinaryIO], fmt: str = "h264", quality: int = 15
):
"""Start recording.
Start a new video recording, writing to a output object.
Args:
output: String or file-like object to write capture data to
fmt (str): Format of the capture.
quality (int): Video recording quality.
Returns:
output_object (str/BytesIO): Target object.
"""
with self.lock(timeout=5):
# Start recording method only if a current recording is not running
if not self.record_active:
# Start the camera video recording on port 2
logging.info("Recording to %s", (output))
self.picamera.start_recording(
output,
format=fmt,
splitter_port=2,
resize=self.stream_resolution,
quality=quality,
)
# Update state
self.record_active = True
return output
else:
logging.warning(
"Cannot start a new recording\
until the current recording has stopped."
)
return None
def stop_recording(self):
"""Stop the last started video recording on splitter port 2."""
with self.lock(timeout=5):
# Stop the camera video recording on port 2
logging.info("Stopping recording")
self.picamera.stop_recording(splitter_port=2)
logging.info("Recording stopped")
# Update state
self.record_active = False
def start_stream(self) -> None:
"""
Sets the camera resolution to the video/stream resolution, and starts recording if the stream should be active.
"""
with self.lock(timeout=None):
# Reduce the resolution for video streaming
try:
self.picamera._check_recording_stopped() # pylint: disable=W0212
except picamerax.exc.PiCameraRuntimeError:
logging.info(
"Error while changing resolution: Recording already running."
)
else:
self.picamera.resolution = self.stream_resolution
# Sprinkled a sleep to prevent camera getting confused by rapid commands
time.sleep(0.2)
# If the stream should be active
try:
# Start recording on stream port
self.picamera.start_recording(
self.stream,
format="mjpeg",
quality=self.mjpeg_quality,
bitrate=self.mjpeg_bitrate, # RWB: disable bitrate control
# (bitrate control makes JPEG size less good as a focus
# metric)
splitter_port=1,
)
except picamerax.exc.PiCameraAlreadyRecording:
logging.info("Error while starting preview: Recording already running.")
else:
self.stream_active = True
logging.debug(
"Started MJPEG stream at %s on port %s", self.stream_resolution, 1
)
def stop_stream(self) -> None:
"""
Sets the camera resolution to the still-image resolution, and stops recording if the stream is active.
Args:
splitter_port (int): Splitter port to stop recording on
"""
with self.lock:
# Stop the camera video recording on port 1
try:
self.picamera.stop_recording(splitter_port=1)
except picamerax.exc.PiCameraNotRecording:
logging.info("Not recording on splitter_port %s", (1))
else:
self.stream_active = False
logging.info(
"Stopped MJPEG stream on port %s. Switching to %s.",
1,
self.image_resolution,
)
# Increase the resolution for taking an image
time.sleep(
0.2
) # Sprinkled a sleep to prevent camera getting confused by rapid commands
self.picamera.resolution = self.image_resolution
def capture(
self,
output: Union[str, BinaryIO],
fmt: str = "jpeg",
use_video_port: bool = False,
resize: Optional[Tuple[int, int]] = None,
bayer: bool = True,
thumbnail: Optional[Tuple[int, int, int]] = None,
):
"""
Capture a still image to a StreamObject.
Defaults to JPEG format.
Target object can be overridden for development purposes.
Args:
output: String or file-like object to write capture data to
fmt: Format of the capture.
use_video_port: Capture from the video port used for streaming. Lower resolution, faster.
resize: Resize the captured image.
bayer: Store raw bayer data in capture
thumbnail: Dimensions and quality (x, y, quality) of a thumbnail to generate, if supported
Returns:
output_object (str/BytesIO): Target object.
"""
with self.lock:
logging.info("Capturing to %s", (output))
# Set resolution and stop stream recording if necessary
if not use_video_port:
self.stop_stream()
self.picamera.capture(
output,
format=fmt,
quality=self.jpeg_quality,
resize=resize,
bayer=(not use_video_port) and bayer,
use_video_port=use_video_port,
thumbnail=thumbnail,
)
# Set resolution and start stream recording if necessary
if not use_video_port:
self.start_stream()
return output
def array(self, use_video_port: bool = True) -> np.ndarray:
"""Capture an uncompressed still RGB image to a Numpy array.
Args:
use_video_port (bool): Capture from the video port used for streaming. Lower resolution, faster.
resize ((int, int)): Resize the captured image.
Returns:
output_array (np.ndarray): Output array of capture
"""
with self.lock:
logging.debug("Creating PiRGBArray")
with picamerax.array.PiRGBArray(self.picamera) as output:
logging.info("Capturing to %s", (output))
self.picamera.capture(
output, format="rgb", use_video_port=use_video_port
)
return output.array

View file

@ -1,73 +0,0 @@
from __future__ import print_function
import logging
import time
from typing import Union
import picamerax
from picamerax import exc, mmal
from picamerax.mmalobj import to_rational
MMAL_PARAMETER_ANALOG_GAIN: int = mmal.MMAL_PARAMETER_GROUP_CAMERA + 0x59
MMAL_PARAMETER_DIGITAL_GAIN: int = mmal.MMAL_PARAMETER_GROUP_CAMERA + 0x5A
def set_gain(camera: picamerax.PiCamera, gain: int, value: Union[int, float]):
"""Set the analog gain of a PiCamera.
camera: the picamerax.PiCamera() instance you are configuring
gain: either MMAL_PARAMETER_ANALOG_GAIN or MMAL_PARAMETER_DIGITAL_GAIN
value: a numeric value that can be converted to a rational number.
"""
if gain not in [MMAL_PARAMETER_ANALOG_GAIN, MMAL_PARAMETER_DIGITAL_GAIN]:
raise ValueError("The gain parameter was not valid")
ret = mmal.mmal_port_parameter_set_rational(
camera._camera.control._port, gain, to_rational(value) # pylint: disable=W0212
)
if ret == 4:
raise exc.PiCameraMMALError(
ret,
"Are you running the latest version of the userland libraries? Gain setting was introduced in late 2017.",
)
elif ret != 0:
raise exc.PiCameraMMALError(ret)
def set_analog_gain(camera: picamerax.PiCamera, value: Union[int, float]):
"""Set the gain of a PiCamera object to a given value."""
set_gain(camera, MMAL_PARAMETER_ANALOG_GAIN, value)
def set_digital_gain(camera: picamerax.PiCamera, value: Union[int, float]):
"""Set the digital gain of a PiCamera object to a given value."""
set_gain(camera, MMAL_PARAMETER_DIGITAL_GAIN, value)
if __name__ == "__main__":
with picamerax.PiCamera() as cam:
cam.start_preview(fullscreen=False, window=(0, 50, 640, 480))
time.sleep(2)
# fix the auto white balance gains at their current values
g = cam.awb_gains
cam.awb_mode = "off"
cam.awb_gains = g
# fix the shutter speed
cam.shutter_speed = cam.exposure_speed
logging.info("Current a/d gains: %s, %s", cam.analog_gain, cam.digital_gain)
logging.info("Attempting to set analogue gain to 1")
set_analog_gain(cam, 1)
logging.info("Attempting to set digital gain to 1")
set_digital_gain(cam, 1)
try:
while True:
logging.info(
"Current a/d gains: %s, %s", cam.analog_gain, cam.digital_gain
)
time.sleep(1)
except KeyboardInterrupt:
logging.info("Stopping...")

View file

@ -1,11 +0,0 @@
from . import capture, capture_manager
from .capture import THUMBNAIL_SIZE, CaptureObject
from .capture_manager import CaptureManager
__all__ = [
"capture",
"capture_manager",
"THUMBNAIL_SIZE",
"CaptureObject",
"CaptureManager",
]

View file

@ -1,395 +0,0 @@
import datetime
import glob
import io
import json
import logging
import os
import uuid
from collections import OrderedDict
from typing import Callable, Dict, List, Optional
import dateutil.parser
import piexif
from piexif import InvalidImageDataError
from PIL import Image
from openflexure_microscope.json import JSONEncoder
EXIF_FORMATS = ["JPG", "JPEG", "TIF", "TIFF"]
THUMBNAIL_SIZE = (200, 150)
def make_file_list(directory, formats):
files = []
for fmt in formats:
files.extend(
glob.glob("{}/**/*.{}".format(directory, fmt.lower()), recursive=True)
)
logging.info("%s capture files found on disk", (len(files)))
return files
def build_captures_from_exif(capture_path):
logging.debug("Reloading captures from %s...", (capture_path))
files = make_file_list(capture_path, EXIF_FORMATS)
captures = OrderedDict()
for f in files:
logging.debug("Reloading capture %s...", (f))
capture = capture_from_path(f)
if capture:
captures[str(capture.id)] = capture
logging.info("%s capture files successfully reloaded", (len(captures)))
return captures
def capture_from_path(path):
# Create a placeholder capture
capture = CaptureObject(filepath=path)
# Build file path information
capture.split_file_path(capture.file)
# Check and sync basic metadata
try:
capture.sync_basic_metadata()
return capture
except (InvalidImageDataError, json.decoder.JSONDecodeError):
logging.error("Invalid metadata at %s.", (path))
return None
class CaptureObject(object):
"""
File-like object used to store and process on-disk capture data, and metadata.
Serves to simplify modifying properties of on-disk capture data.
"""
def __init__(self, filepath: str) -> None:
"""Create a new StreamObject, to manage capture data."""
# Stream for buffering capture data
self.stream: io.BytesIO = io.BytesIO()
# Store a nice ID
self.id: uuid.UUID = uuid.uuid4() #: str: Unique capture ID
logging.debug("Created CaptureObject %s", (self.id))
self.time: datetime.datetime = datetime.datetime.now()
# Create file name. Default to UUID
self.file: str = filepath
# Placeholders for split file info
self.format: str = ""
self.basename: str = ""
self.filefolder: str = ""
self.name: str = ""
# Replace placeholders with actual split file info
self.split_file_path(self.file)
if not os.path.exists(self.filefolder):
os.makedirs(self.filefolder)
# Dataset information
self.dataset: Dict[str, str] = {}
# Dictionary for storing custom annotations
# Can be modified via the web API
self.annotations: Dict[str, str] = {}
# List for storing tags
# Can be modified via the web API
self.tags: List[str] = []
# On-delete callback
self.on_delete: Optional[Callable] = None
def write(self, s):
logging.debug("Writing to %s", self)
self.stream.write(s)
def flush(self):
logging.debug("Writing image data to disk %s", self.file)
with open(self.file, "wb") as outfile:
outfile.write(self.stream.getbuffer())
self.stream.close()
logging.debug("Writing metadata to disk %s", self.file)
self._init_metadata()
logging.debug("Finished writing to disk %s", self.file)
def open(self, mode):
# We don't specify an encoding because this will be a binary file.
return open(self.file, mode) # pylint: disable=unspecified-encoding
def split_file_path(self, filepath: str):
"""
Take a full file path, and split it into separated class properties.
Args:
filepath (str): String of the full file path, including file format extension
"""
# Split the full file path into a folder and a name
self.filefolder, self.name = os.path.split(filepath)
# Split the name out from it's file extension
self.basename = os.path.splitext(self.name)[0]
self.format = self.name.split(".")[-1]
def _read_exif(self) -> dict:
return piexif.load(self.file)
def _decode_usercomment(self, exif_dict: dict) -> dict:
if "Exif" not in exif_dict:
raise InvalidImageDataError
if piexif.ExifIFD.UserComment not in exif_dict["Exif"]:
return {}
return json.loads(exif_dict["Exif"][piexif.ExifIFD.UserComment].decode())
def _init_metadata(self):
self.put_and_save(
metadata={
"image": {
"id": self.id,
"name": self.name,
"time": self.time.isoformat(),
"format": self.format,
"tags": self.tags,
"annotations": self.annotations,
}
}
)
def sync_basic_metadata(self):
exif_dict: dict = self._read_exif()
metadata_dict: dict = self._decode_usercomment(exif_dict) or {}
self.dataset = metadata_dict.get("dataset")
image_metadata: Optional[dict] = metadata_dict.get("image")
if not image_metadata:
raise InvalidImageDataError("No capture metadata found")
self.id = uuid.UUID(image_metadata.get("id"))
self.format = image_metadata.get("format")
self.time = dateutil.parser.isoparse(
image_metadata.get("time") or image_metadata.get("acquisitionDate")
)
self.tags = image_metadata.get("tags")
self.annotations = image_metadata.get("annotations")
def read_full_metadata(self) -> dict:
logging.debug("Reading full capture metadata from %s...", self.file)
exif_dict = self._read_exif()
return self._decode_usercomment(exif_dict)
@property
def exists(self) -> bool:
"""Check if capture data file exists on disk."""
if os.path.isfile(self.file):
return True
else:
return False
# HANDLE TAGS
def put_tags(self, tags: List[str]):
"""
Add a new tag to the ``tags`` list attribute.
Args:
tags (list): List of tags to be added
"""
self.put_and_save(tags=tags)
def delete_tag(self, tag: str):
"""
Remove a tag from the ``tags`` list attribute, if it exists.
Args:
tag (str): Tag to be removed
"""
# Update in-memory tag list
if tag in self.tags:
self.tags = [new_tag for new_tag in self.tags if new_tag != tag]
# Write in-memory metadata to file
self.put_and_save()
# HANDLE ANNOTATIONS
def put_annotations(self, data: Dict[str, str]):
"""
Merge annotations from a passed dictionary into the capture metadata, and saves.
Args:
data (dict): Dictionary of metadata to be added
"""
self.put_and_save(annotations=data)
def delete_annotation(self, key: str):
# Update in-memory annotations list
if key in self.annotations:
del self.annotations[key]
# Write in-memory metadata to file
self.put_and_save()
# HANDLE METADATA
def put_metadata(self, data: dict):
"""
Merge root metadata from a passed dictionary into the capture metadata, and saves.
Args:
data (dict): Dictionary of metadata to be added
"""
self.put_and_save(metadata=data)
# HANDLE DATASET
def put_dataset(self, data: dict):
self.put_and_save(dataset=data)
# BULK OPERATIONS
def put_and_save(
self,
tags: Optional[List[str]] = None,
annotations: Optional[Dict[str, str]] = None,
dataset: Optional[Dict[str, str]] = None,
metadata: Optional[dict] = None,
):
"""
Batch-write tags, metadata, and annotations in a single disk operation
"""
if not tags:
tags = []
if not annotations:
annotations = {}
if not dataset:
dataset = {}
if not metadata:
metadata = {}
# Update in-memory tags array
for tag in tags:
if tag not in self.tags:
self.tags.append(tag)
# Update in-memory annotations dictionary
self.annotations.update(annotations)
# Update in-memory dataset dictionary
self.dataset.update(dataset)
# Write new data to file EXIF, if supported
if self.format.upper() in EXIF_FORMATS and self.exists:
logging.debug("Writing Exif data to %s", self.file)
# Extract current Exif data
exif_dict = self._read_exif()
metadata_dict = self._decode_usercomment(exif_dict) or {}
# Add new tags to exif dictionary
metadata_dict.get("image", {})["tags"] = self.tags
# Add new annotations to exif dictionary
metadata_dict.get("image", {})["annotations"] = self.annotations
# Set new dataset info in exif dictionary
metadata_dict["dataset"] = self.dataset
# Add new custom metadata to exif dictionary
metadata_dict.update(metadata)
# Serialize metadata
metadata_string = json.dumps(metadata_dict, cls=JSONEncoder)
logging.debug("Saving metadata string to file: %s", metadata_string)
# Insert metadata into exif_dict
exif_dict["Exif"][piexif.ExifIFD.UserComment] = metadata_string.encode()
# Convert new exif dict to exif bytes
exif_bytes = piexif.dump(exif_dict)
# Insert exif into file
piexif.insert(exif_bytes, self.file)
logging.debug("Finished writing Exif data to %s", self.file)
# PROPERTIES
@property
def metadata(self) -> dict:
"""
Create basic metadata dictionary from basic capture data,
and any added custom metadata and tags.
"""
# Add custom metadata to dictionary
return self.read_full_metadata()
@property
def data(self) -> Optional[io.BytesIO]:
"""
Return a BytesIO object of the capture data.
"""
if self.exists: # If data file exists
logging.debug("Opening from file %s", (self.file))
with open(self.file, "rb") as f:
d = io.BytesIO(f.read()) # Load bytes from file
d.seek(0) # Rewind loaded bytestream
# Create a copy of the bytestream bytes
return io.BytesIO(d.getbuffer())
else:
return None
@property
def binary(self) -> Optional[bytes]:
"""Return a byte string of the capture data."""
if self.data:
return self.data.getvalue()
else:
return None
@property
def thumbnail(self) -> io.BytesIO:
"""
Returns a thumbnail of the capture data, for supported image formats.
"""
exif_dict = piexif.load(self.file)
thumbnail = exif_dict.pop("thumbnail")
if thumbnail:
return io.BytesIO(thumbnail)
# If no thumbnail exists, make and save one
thumb_bytes = io.BytesIO()
thumb_im = Image.open(self.data)
thumb_im.thumbnail(THUMBNAIL_SIZE)
thumb_im.save(thumb_bytes, "jpeg")
thumbnail = thumb_bytes.getvalue()
exif_dict["thumbnail"] = thumbnail
exif_bytes = piexif.dump(exif_dict)
piexif.insert(exif_bytes, self.file)
return io.BytesIO(thumbnail)
# FILE MANAGEMENT
def save(self):
"""Write stream to file, and save/update metadata file"""
# If a stream OR file exists, save the metadata file
if self.exists:
self.put_and_save()
def delete(self) -> bool:
"""If the StreamObject has been saved, delete the file."""
if os.path.isfile(self.file):
logging.info("Deleting file %s", (self.file))
os.remove(self.file)
if self.on_delete:
self.on_delete(self, self.id)
return True
else:
return False
def close(self):
pass

View file

@ -1,228 +0,0 @@
import datetime
import logging
import os
import shutil
from collections import OrderedDict
from typing import Dict, List, MutableMapping, Optional, Union, ValuesView
from uuid import UUID
from labthings import StrictLock
from openflexure_microscope.paths import data_file_path
from .capture import CaptureObject, build_captures_from_exif
BASE_CAPTURE_PATH = data_file_path("micrographs")
TEMP_CAPTURE_PATH = os.path.join(BASE_CAPTURE_PATH, "tmp")
class CaptureManager:
def __init__(self):
self.paths: Dict[str, str] = {
"default": BASE_CAPTURE_PATH,
"temp": TEMP_CAPTURE_PATH,
}
self.lock: StrictLock = StrictLock(timeout=1, name="Captures")
# Capture data
self.images: MutableMapping[str, CaptureObject] = OrderedDict()
self.videos: MutableMapping[str, CaptureObject] = OrderedDict()
# FILE MANAGEMENT
def __enter__(self):
"""Create camera on context enter."""
return self
def __exit__(self, exc_type, exc_value, traceback):
"""Close camera stream on context exit."""
self.close()
def close(self):
logging.info("Closing %s", (self))
# Close all StreamObjects
for capture_list in [self.images.values(), self.videos.values()]:
for stream_object in capture_list:
stream_object.close()
# Empty temp directory
self.clear_tmp()
def clear_tmp(self):
"""
Removes all files in the temporary capture directories
"""
if os.path.isdir(self.paths["temp"]):
logging.info("Clearing %s...", (self.paths["temp"]))
shutil.rmtree(self.paths["temp"])
logging.debug("Cleared %s.", (self.paths["temp"]))
def rebuild_captures(self):
self.images = build_captures_from_exif(self.paths["default"])
def update_settings(self, config: dict):
"""Update settings from a config dictionary"""
with self.lock:
# Apply valid config params to camera object
for key, value in config.items(): # For each provided setting
if hasattr(self, key): # If the instance has a matching property
setattr(self, key, value) # Set to the target value
def read_settings(self) -> dict:
"""Return the current settings as a dictionary"""
return {"paths": self.paths}
# RETURNING CAPTURES
def image_from_id(self, image_id: Union[str, int, UUID]) -> Optional[CaptureObject]:
"""Return an image StreamObject with a matching ID."""
logging.warning("image_from_id is deprecated. Access captures as a dictionary.")
return entry_by_uuid(image_id, self.images)
def video_from_id(self, video_id: Union[str, int, UUID]) -> Optional[CaptureObject]:
"""Return a video StreamObject with a matching ID."""
logging.warning("video_from_id is deprecated. Access captures as a dictionary.")
return entry_by_uuid(video_id, self.videos)
# CREATING NEW CAPTURES
def _new_output(self, temporary: bool, filename: str, folder: str, fmt: str):
filename = "{}.{}".format(filename, fmt)
# Generate folder
base_folder: str = self.paths["temp"] if temporary else self.paths["default"]
folder = os.path.join(base_folder, folder)
# Generate file path
filepath: str = os.path.join(folder, filename)
# Create capture object
output = CaptureObject(filepath=filepath)
# Insert a temporary tag if temporary
if temporary:
output.put_tags(["temporary"])
return output
def new_image(
self,
temporary: bool = True,
filename: Optional[str] = None,
folder: str = "",
fmt: str = "jpeg",
):
"""
Create a new image capture object.
Args:
temporary (bool): Should the data be deleted after session ends.
Creating the capture with a content manager sets this to true.
filename (str): Name of the stored file. Defaults to timestamp.
folder (str): Name of the folder in which to store the capture.
fmt (str): Format of the capture.
"""
# Generate file name
if not filename:
filename = generate_numbered_basename(self.images.values())
# Create a new output object
output = self._new_output(temporary, filename, folder, fmt)
# Add an on-delete callback
output.on_delete = self.remove_image
# Update capture list
logging.debug("Adding image %s with key %s", output, output.id)
self.images[str(output.id)] = output
return output
def new_video(
self,
temporary: bool = False,
filename: Optional[str] = None,
folder: str = "",
fmt: str = "h264",
) -> CaptureObject:
"""
Create a new video capture object.
Args:
temporary (bool): Should the data be deleted after session ends.
Creating the capture with a content manager sets this to true.
filename (str): Name of the stored file. Defaults to timestamp.
folder (str): Name of the folder in which to store the capture.
fmt (str): Format of the capture.
"""
# Generate file name
if not filename:
filename = generate_numbered_basename(self.videos.values())
# Create a new output object
output = self._new_output(temporary, filename, folder, fmt)
# Add an on-delete callback
output.on_delete = self.remove_video
# Update capture list
logging.debug("Adding video %s with key %s", output, output.id)
self.videos[str(output.id)] = output
return output
def remove_image(self, capture_obj: CaptureObject, capture_id: str):
logging.info("Deleting capture %s", capture_id)
if capture_id in self.images:
logging.debug("Deleting capture object %s", capture_obj)
del self.images[capture_id]
def remove_video(self, capture_obj: CaptureObject, capture_id: str):
logging.info("Deleting capture %s", capture_id)
if capture_id in self.images:
logging.debug("Deleting capture object %s", capture_obj)
del self.videos[capture_id]
def entry_by_uuid(
entry_id: Union[str, int, UUID], object_dict: MutableMapping[str, CaptureObject]
) -> Optional[CaptureObject]:
"""Return an object from a list, if <object>.id matches id argument."""
if isinstance(entry_id, str):
key: str = entry_id
elif isinstance(entry_id, UUID):
key = str(entry_id)
elif isinstance(entry_id, int):
key = str(UUID(int=entry_id))
else:
raise TypeError("Argument entry_id must be a string, integer, or UUID object.")
return object_dict.get(key)
def generate_basename() -> str:
"""Return a default filename based on the capture datetime"""
return datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
def generate_numbered_basename(
obj_list: Union[ValuesView[CaptureObject], List[CaptureObject]]
) -> str:
"""
This function prevents rapid captures from having clashing generated names.
Our generated names are a datetime string going as far as seconds,
so if you create 2 captures within 1 second then they would have a name
clash. This method handles appending sequential integers to names
that would clash.
"""
initial_basename = generate_basename()
basename = initial_basename
# Handle clashing
iterator = 1
while basename in [obj.basename for obj in obj_list]:
basename = initial_basename + "_{}".format(iterator)
iterator += 1
return basename

View file

@ -1,168 +0,0 @@
import errno
import json
import logging
import os
import shutil
from typing import Optional
from .json import JSONEncoder
from .paths import CONFIGURATION_FILE_PATH, SETTINGS_FILE_PATH
class OpenflexureSettingsFile:
"""
An object to handle expansion, conversion, and saving of the microscope configuration.
Args:
config_path (str): Path to the config JSON file (None falls back to default location)
expand (bool): Expand paths to valid auxillary config files.
"""
def __init__(self, path: str, defaults: Optional[dict] = None):
defaults = defaults or {}
# Set arguments
self.path = path
# Initialise basic config file with defaults if it doesn't exist
initialise_file(
self.path,
# Populate with default dictionary, or empty JSON if empty
populate=json.dumps(defaults, cls=JSONEncoder, indent=2, sort_keys=True)
or "{}\n",
)
def load(self) -> dict:
"""
Loads settings from a file on-disk.
"""
# Unexpanded config dictionary (used at load/save time)
loaded_config = load_json_file(self.path)
logging.debug("Reading settings from disk")
return loaded_config
def save(self, config: dict, backup: bool = True):
"""
Save settings to a file on-disk.
Args:
config (dict): Dictionary of new settings
backup (bool): Back up previous settings file
"""
save_settings = config
if backup:
if os.path.isfile(self.path):
shutil.copyfile(self.path, self.path + ".bk")
logging.debug("Saving settings dictionary to disk")
save_json_file(self.path, save_settings)
def merge(self, config: dict) -> dict:
"""
Merge settings dictionary with settings loaded from file on-disk.
Args:
config (dict): Dictionary of new settings
"""
logging.debug("Merging settings with file on disk")
settings = self.load()
settings.update(config)
return settings
# HANDLE BASIC LOADING AND SAVING OF SETTINGS FILES
def load_json_file(config_path) -> dict:
"""
Open a .json config file
Args:
config_path (str): Path to the config JSON file. If `None`, defaults to `DEFAULT_CONFIG_PATH`
"""
config_path = os.path.expanduser(config_path)
logging.info("Loading %s...", config_path)
with open(config_path, encoding="utf-8") as config_file:
try:
config_data = json.load(config_file)
except json.decoder.JSONDecodeError as e:
logging.error(e)
config_data = {}
# Return loaded config dictionary
return config_data
def save_json_file(config_path: str, config_dict: dict):
"""
Save a .json config file
Args:
config_dict (dict): Dictionary of config data to save.
config_path (str): Path to the config JSON file.
"""
config_path = os.path.expanduser(config_path)
logging.info("Saving %s...", config_path)
logging.debug(config_dict)
with open(config_path, "w", encoding="utf-8") as outfile:
json.dump(config_dict, outfile, cls=JSONEncoder, indent=2, sort_keys=True)
def create_file(config_path):
"""
Creates an empty file, and all folder structure currently nonexistant.
Args:
config_path: Path to the (possibly) new file
"""
if not os.path.exists(os.path.dirname(config_path)):
try:
os.makedirs(os.path.dirname(config_path))
except OSError as exc: # Guard against race condition
if exc.errno != errno.EEXIST:
raise
def initialise_file(config_path, populate: str = "{}\n"):
"""
Check if a file exists, and if not, create it
and optionally populate it with content
Args:
config_path (str): Path to the file.
populate (str): String to dump to the file, if it is being newly created
"""
config_path = os.path.expanduser(config_path)
logging.debug("Initialising %s", (config_path))
logging.debug("Exists: %s", (os.path.exists(config_path)))
if not os.path.exists(config_path): # If user config file doesn't exist
logging.warning("No config file found at %s. Creating...", (config_path))
create_file(config_path)
logging.info("Populating %s...", (config_path))
with open(config_path, "w", encoding="utf-8") as outfile:
outfile.write(populate)
#: Default user settings object
user_settings = OpenflexureSettingsFile(path=SETTINGS_FILE_PATH)
#: Default user settings object
user_configuration = OpenflexureSettingsFile(
path=CONFIGURATION_FILE_PATH,
defaults={
"camera": {"type": "PiCamera"},
"stage": {"type": "SangaStage", "port": None},
},
)

View file

@ -1,24 +0,0 @@
"""
Convenience imports for developers.
Here we include some classes used frequently in plugin development,
as well as some Flask imports to simplify API route development
"""
# Flask things
from flask import Response, abort, escape, request
# Task management
from labthings import current_action as current_task
from labthings import update_action_data as update_task_data
from labthings import update_action_progress as update_task_progress
__all__ = [
"current_task",
"update_task_progress",
"update_task_data",
"abort",
"escape",
"Response",
"request",
]

View file

@ -1,33 +0,0 @@
from fractions import Fraction
from uuid import UUID
import numpy as np
from labthings.json import LabThingsJSONEncoder
__all__ = ["JSONEncoder", "LabThingsJSONEncoder"]
class JSONEncoder(LabThingsJSONEncoder):
"""
A custom JSON encoder, with type conversions for PiCamera fractions, Numpy integers, and Numpy arrays
"""
def default(self, o):
if isinstance(o, UUID):
return str(o)
# PiCamera fractions
elif isinstance(o, Fraction):
return float(o)
# Numpy integers
elif isinstance(o, np.integer):
return int(o)
# Numpy floats are just Python floats
elif isinstance(o, float):
return float(o)
# Numpy arrays
elif isinstance(o, np.ndarray):
return o.tolist()
else:
# call base class implementation which takes care of
# raising exceptions for unsupported types
return LabThingsJSONEncoder.default(self, o)

View file

@ -1,414 +0,0 @@
# -*- coding: utf-8 -*-
"""
Defines a microscope object, binding a camera and stage with basic functionality.
"""
import logging
import uuid
from typing import Dict, List, Optional, Tuple, Union
import pkg_resources
from expiringdict import ExpiringDict
from openflexure_microscope.camera.base import BaseCamera
from openflexure_microscope.camera.mock import MissingCamera
from openflexure_microscope.captures import THUMBNAIL_SIZE, CaptureManager
from openflexure_microscope.config import OpenflexureSettingsFile
from openflexure_microscope.stage.base import BaseStage
from openflexure_microscope.stage.mock import MissingStage
from openflexure_microscope.stage.sanga import SangaDeltaStage, SangaStage
try:
from openflexure_microscope.camera.pi import PiCameraStreamer
except Exception as exc: # pylint: disable=W0703
logging.error(exc)
logging.warning("Unable to import PiCameraStreamer")
from labthings import CompositeLock
from openflexure_microscope.config import user_configuration, user_settings
class Microscope:
"""
A basic microscope object.
The camera and stage objects may already be initialised, and can be passed as arguments.
"""
def __init__(self, settings=user_settings, configuration=user_configuration):
self.id: str = f"openflexure:microscope:{uuid.uuid4()}"
self.name: str = self.id
self.captures: CaptureManager = CaptureManager()
# Store settings and configuration files
self.settings_file: OpenflexureSettingsFile = settings
self.configuration_file: OpenflexureSettingsFile = configuration
self.extension_settings: dict = {}
# Initialise with an empty composite lock
#: :py:class:`labthings.CompositeLock`: Composite lock for locking both camera and stage
self.lock: CompositeLock = CompositeLock([])
self.camera: BaseCamera = None #: Currently connected camera object
self.stage: BaseStage = None #: Currently connected stage object
self.setup(self.configuration_file.load()) # Attach components
# Apply settings loaded from file
self.update_settings(self.settings_file.load())
# Data cache
# Sometimes, like when doing large scans, we don't need to read the hardware
# state for every single capture. We can get the data once at the start,
# cache it, and reuse that to save on IO. Cached data is stored here.
self.configuration_cache: Union[dict, ExpiringDict] = ExpiringDict(
max_len=100, max_age_seconds=3600
)
self.metadata_cache: Union[dict, ExpiringDict] = ExpiringDict(
max_len=100, max_age_seconds=3600
)
def __enter__(self):
"""Create microscope on context enter."""
return self
def __exit__(self, exc_type, exc_value, traceback):
"""Close microscope on context exit."""
self.close()
def close(self):
"""Shut down the microscope hardware."""
logging.info("Closing %s", (self))
if self.camera:
try:
self.camera.close()
except TimeoutError as e:
logging.error(e)
if self.stage:
try:
self.stage.close()
except TimeoutError as e:
logging.error(e)
self.captures.close()
logging.info("Closed %s", (self))
def setup(self, configuration: dict):
"""
Attach microscope components based on initially passed configuration file
"""
### Detector
logging.info("Creating camera")
if configuration.get("camera"):
camera_type = configuration["camera"].get("type")
if camera_type in ("PiCamera", "PiCameraStreamer"):
try:
self.camera = PiCameraStreamer()
except Exception as e: # pylint: disable=W0703
logging.error(e)
logging.warning("No compatible camera hardware found.")
### Stage
self.set_stage(configuration=configuration)
logging.info("Handling fallbacks")
### Fallbacks
if not self.camera:
self.camera = MissingCamera()
if not self.stage:
self.stage = MissingStage()
### Locks
logging.info("Creating locks")
if hasattr(self.camera, "lock"):
self.lock.locks.append(self.camera.lock)
if hasattr(self.stage, "lock"):
self.lock.locks.append(self.stage.lock)
def set_stage(
self, configuration: Optional[dict] = None, stage_type: Optional[str] = None
):
"""
Set or change the stage geometry
"""
configuration = configuration or self.configuration
if stage_type:
if stage_type == configuration["stage"].get("type"):
logging.info("Stage already set to that stage type")
return
else:
stage_type = configuration["stage"].get("type")
### Close any existing stages
if self.stage:
stage_port = getattr(self.stage, "port")
self.stage.close()
logging.info("Setting stage")
stage_port = configuration["stage"].get("port")
if stage_type in ("SangaBoard", "SangaStage"):
try:
logging.info("Trying SangaStage")
self.stage = SangaStage(port=stage_port)
logging.info("Saving new SangaStage type configuration")
configuration["stage"]["type"] = stage_type
self.configuration_file.save(configuration)
except Exception as e: # pylint: disable=W0703
logging.error(e)
logging.warning("No compatible Sangaboard hardware found.")
elif stage_type in ("SangaDeltaStage",):
try:
logging.info("Trying SangaDeltaStage")
self.stage = SangaDeltaStage(port=stage_port)
logging.info("Saving new SangaDeltaStage type configuration")
configuration["stage"]["type"] = stage_type
self.configuration_file.save(configuration)
except Exception as e: # pylint: disable=W0703
logging.error(e)
logging.warning("No compatible Sangaboard hardware found.")
elif stage_type in ("MissingStage",):
logging.warning(
"The stage is set to MissingStage in "
"configuration, which disables any physical stage."
)
self.stage = MissingStage()
configuration["stage"]["type"] = "MissingStage"
self.configuration_file.save(configuration)
else:
logging.warning("The stage type is incorrectly defined.")
def has_real_stage(self) -> bool:
"""
Check if a real (non-mock) stage is currently attached.
"""
if hasattr(self, "stage") and not isinstance(self.stage, MissingStage):
return True
else:
return False
def has_real_camera(self) -> bool:
"""
Check if a real (non-mock) camera is currently attached.
"""
if hasattr(self, "camera") and not isinstance(self.camera, MissingCamera):
return True
else:
return False
# Create unified state
@property
def state(self) -> dict:
"""Dictionary of the basic microscope state.
Return:
dict: Dictionary containing complete microscope state
"""
return {"camera": self.camera.state, "stage": self.stage.state}
def update_settings(self, settings: dict):
"""
Applies a settings dictionary to the microscope. Missing parameters will be left untouched.
"""
with self.lock:
logging.debug("Microscope: Applying settings: %s", (settings))
# If attached to a camera
if ("camera" in settings) and self.camera:
self.camera.update_settings(settings.pop("camera", {}))
# If attached to a stage
if ("stage" in settings) and self.stage:
self.stage.update_settings(settings.pop("stage", {}))
# Capture manager
self.captures.update_settings(settings.pop("captures", {}))
# Microscope settings
if "id" in settings:
self.id = settings.pop("id")
if "name" in settings:
self.name = settings.pop("name")
# Extension settings
if "extensions" in settings:
self.extension_settings.update(settings.pop("extensions"))
# Warn about any superfluous keys
for key in settings.keys():
logging.warning("Key %s is unused and was ignored", key)
def read_settings(self, full: bool = True) -> dict:
"""
Get an updated settings dictionary.
Reads current attributes and properties from connected hardware,
then merges those with the currently saved settings.
This is to ensure that settings for currently disconnected hardware
don't get removed from the settings file.
"""
settings_current = {
"id": self.id,
"name": self.name,
"extensions": self.extension_settings,
}
# If attached to a camera
if self.camera:
settings_current_camera = self.camera.read_settings()
settings_current["camera"] = settings_current_camera
# If attached to a stage
if self.stage:
settings_current_stage = self.stage.read_settings()
settings_current["stage"] = settings_current_stage
# Capture manager
settings_current_captures = self.captures.read_settings()
settings_current["captures"] = settings_current_captures
settings_full = self.settings_file.merge(settings_current)
if full:
return settings_full
else:
return settings_current
def save_settings(self):
"""
Merges the current settings back to disk
"""
# Read curent config
current_config = self.read_settings()
# Save config to file
self.settings_file.save(current_config, backup=True)
def force_get_configuration(self) -> dict:
initial_configuration = self.configuration_file.load()
current_configuration = {
"application": {
"name": "openflexure-microscope-server",
"version": pkg_resources.get_distribution(
"openflexure-microscope-server"
).version,
},
"stage": {
"type": self.stage.__class__.__name__,
**self.stage.configuration,
},
"camera": {
"type": self.camera.__class__.__name__,
**self.camera.configuration,
},
}
initial_configuration.update(current_configuration)
return initial_configuration
def get_configuration(self, cache_key: Optional[str] = None) -> dict:
if cache_key:
cached_config = self.configuration_cache.get(cache_key, None)
if cached_config:
return cached_config
else:
full_config = self.force_get_configuration()
self.configuration_cache[cache_key] = full_config
return full_config
return self.force_get_configuration()
@property
def configuration(self):
return self.get_configuration()
def force_get_metadata(self) -> dict:
"""
Read cachable bits of microscope metadata.
Currently ID, settings, and configuration can be cached
"""
system_metadata = {
"id": self.id,
"settings": self.read_settings(full=False),
"configuration": self.get_configuration(),
}
return system_metadata
def get_metadata(self, cache_key: Optional[str] = None):
"""
Read microscope metadata, with partial caching
"""
metadata = {}
# Load cached bits of metadata
if cache_key:
logging.debug("Reading cached microscope metadata: %s", cache_key)
metadata = self.metadata_cache.get(cache_key, None)
if not metadata:
logging.debug("Building and caching microscope metadata: %s", cache_key)
metadata = self.force_get_metadata()
self.metadata_cache[cache_key] = metadata
else:
logging.debug("Building microscope metadata: %s", cache_key)
metadata = self.force_get_metadata()
# Keys that should never be cached
metadata.update({"state": self.state})
return metadata
@property
def metadata(self) -> dict:
return self.get_metadata()
def capture(
self,
filename: Optional[str] = None,
folder: str = "",
temporary: bool = False,
use_video_port: bool = False,
resize: Optional[Tuple[int, int]] = None,
bayer: bool = True,
fmt: str = "jpeg",
annotations: Optional[Dict[str, str]] = None,
tags: Optional[List[str]] = None,
dataset: Optional[Dict[str, str]] = None,
metadata: Optional[dict] = None,
cache_key: Optional[str] = None,
):
logging.debug("Microscope capturing to %s", filename)
if not annotations:
annotations = {}
if not metadata:
metadata = {}
if not tags:
tags = []
# Read metadata for capture
full_metadata = {"instrument": self.get_metadata(cache_key), **metadata}
# Do capture
with self.camera.lock:
# Create output object
output = self.captures.new_image(
temporary=temporary, filename=filename, folder=folder, fmt=fmt
)
# Capture to output object
logging.info("Starting microscope capture %s", output.file)
self.camera.capture(
output,
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
fmt=fmt,
thumbnail=(*THUMBNAIL_SIZE, 85),
)
output.put_and_save(tags, annotations, dataset, full_metadata)
logging.debug("Finished capture to %s", output.file)
return output

View file

@ -1,72 +0,0 @@
import os
# UTILITIES
def check_rw(path: str) -> bool:
return os.access(path, os.W_OK) and os.access(path, os.R_OK)
def settings_file_path(filename: str) -> str:
"""Generate a full file path for a filename to be stored in server settings folder"""
settings_dir = os.path.join(OPENFLEXURE_VAR_PATH, "settings")
if not os.path.exists(settings_dir):
os.makedirs(settings_dir)
return os.path.join(settings_dir, filename)
def data_file_path(filename: str) -> str:
"""Generate a full file path for a filename to be stored in server data folder"""
data_dir = os.path.join(OPENFLEXURE_VAR_PATH, "data")
if not os.path.exists(data_dir):
os.makedirs(data_dir)
return os.path.join(data_dir, filename)
def extensions_file_path(filename: str) -> str:
"""Generate a full file path for a folder to be stored in server extensions"""
ext_dir = os.path.join(OPENFLEXURE_VAR_PATH, "extensions")
if not os.path.exists(ext_dir):
os.makedirs(ext_dir)
return os.path.join(ext_dir, filename)
def logs_file_path(filename: str) -> str:
"""Generate a full file path for a filename to be stored in server logs"""
logs_dir = os.path.join(OPENFLEXURE_VAR_PATH, "logs")
if not os.path.exists(logs_dir):
os.makedirs(logs_dir)
return os.path.join(logs_dir, filename)
# BASE PATHS
if os.name == "nt":
PREFERRED_VAR_PATH: str = os.getenv("PROGRAMDATA") or "C:\\ProgramData"
FALLBACK_VAR_PATH: str = os.path.expanduser("~")
else:
PREFERRED_VAR_PATH = "/var"
FALLBACK_VAR_PATH = os.path.expanduser("~")
PREFERRED_OPENFLEXURE_VAR_PATH: str = os.path.join(PREFERRED_VAR_PATH, "openflexure")
FALLBACK_OPENFLEXURE_VAR_PATH: str = os.path.join(FALLBACK_VAR_PATH, "openflexure")
if not os.path.exists(PREFERRED_OPENFLEXURE_VAR_PATH) and check_rw(PREFERRED_VAR_PATH):
os.makedirs(PREFERRED_OPENFLEXURE_VAR_PATH)
if check_rw(PREFERRED_OPENFLEXURE_VAR_PATH):
OPENFLEXURE_VAR_PATH = PREFERRED_OPENFLEXURE_VAR_PATH
else:
if not os.path.exists(FALLBACK_OPENFLEXURE_VAR_PATH):
os.makedirs(FALLBACK_OPENFLEXURE_VAR_PATH)
OPENFLEXURE_VAR_PATH = FALLBACK_OPENFLEXURE_VAR_PATH
# SERVER PATHS
#: Path of microscope settings file
SETTINGS_FILE_PATH: str = settings_file_path("microscope_settings.json")
#: Path of microscope configuration file
CONFIGURATION_FILE_PATH: str = settings_file_path("microscope_configuration.json")
#: Path of microscope extensions directory
OPENFLEXURE_EXTENSIONS_PATH: str = extensions_file_path("microscope_extensions")

View file

@ -1,92 +0,0 @@
import logging
import os
import platform
import sys
import pkg_resources
from openflexure_microscope.paths import (
FALLBACK_OPENFLEXURE_VAR_PATH,
PREFERRED_OPENFLEXURE_VAR_PATH,
)
from . import (
check_capture_reload,
check_picamera,
check_sangaboard,
check_settings,
check_system,
)
from .error_sources import bcolors
# Paths for suggestions
LOGS_PATHS = [
os.path.join(PREFERRED_OPENFLEXURE_VAR_PATH, "logs"),
os.path.join(FALLBACK_OPENFLEXURE_VAR_PATH, "logs"),
]
SETTINGS_PATHS = [
os.path.join(var_path, "settings", "microscope_settings.json")
for var_path in (PREFERRED_OPENFLEXURE_VAR_PATH, FALLBACK_OPENFLEXURE_VAR_PATH)
]
CONFIG_PATHS = [
os.path.join(var_path, "settings", "microscope_configuration.json")
for var_path in (PREFERRED_OPENFLEXURE_VAR_PATH, FALLBACK_OPENFLEXURE_VAR_PATH)
]
DATA_PATHS = [
os.path.join(PREFERRED_OPENFLEXURE_VAR_PATH, "data"),
os.path.join(FALLBACK_OPENFLEXURE_VAR_PATH, "data"),
]
# Look for debug flag
logger = logging.getLogger()
if "-d" in sys.argv or "--debug" in sys.argv:
logger.setLevel(logging.DEBUG)
logging.debug("Testing debug logger. One two one two.")
else:
logger.setLevel(logging.WARNING)
def main():
print()
print(bcolors.HEADER + "OpenFlexure Rescue" + bcolors.ENDC)
print()
print(
"This script attempts to identify common issues for a microscope not working properly."
)
print(
"It is not designed to identify bugs in the code, but rather configuration or setup issues."
)
print()
print("Any identified warnings [?] or errors [!] will be reported.")
print()
error_sources = []
error_sources.extend(check_system.main())
error_sources.extend(check_settings.main())
error_sources.extend(check_picamera.main())
error_sources.extend(check_capture_reload.main())
error_sources.extend(check_sangaboard.main())
dist = pkg_resources.get_distribution("openflexure-microscope-server")
print()
print(f"Server Version: {dist.version}")
print(f"Platform: {platform.platform()}")
if not error_sources:
print()
print(bcolors.OKGREEN + "No issues found!" + bcolors.ENDC)
print(
"That's not to say everything is fine, only that our automatic diagnostics couldn't find much."
)
print(f"You can check through the server logs at {LOGS_PATHS}")
else:
print()
for err in error_sources:
print(err.message)
if __name__ == "__main__":
main()

View file

@ -1,62 +0,0 @@
import logging
from openflexure_microscope.captures.capture import (
EXIF_FORMATS,
build_captures_from_exif,
make_file_list,
)
from openflexure_microscope.captures.capture_manager import BASE_CAPTURE_PATH
from openflexure_microscope.config import user_settings
from openflexure_microscope.rescue.monitor_timeout import launch_timeout_test_process
from .error_sources import ErrorSource, WarningSource
def check_capture_reload(cap_path):
logging.info("Starting capture reload with a 60 second timeout...")
passed_timeout_test = launch_timeout_test_process(
build_captures_from_exif, args=(cap_path,), timeout=60
)
return passed_timeout_test
def main():
error_sources = []
logging.info("Loading user settings...")
settings = user_settings.load()
cap_path = str(settings.get("captures", {}).get("paths", {}).get("default"))
logging.info("Capture path found: %s", cap_path)
if not cap_path:
logging.error(
"No capture path defined in settings. This is unusual for anything other than a first-run. \nFalling back to default path."
)
cap_path = BASE_CAPTURE_PATH
# Check number of captures being restored
files = make_file_list(cap_path, EXIF_FORMATS)
if len(files) >= 10000:
error_sources.append(
WarningSource(
(
"Over 10000 captures are being restored. This may slow down server startup.",
f"Consider moving your captures from {cap_path} to another location.",
)
)
)
# Check restore time of captures
passed_timeout = check_capture_reload(cap_path)
if not passed_timeout:
error_sources.append(
ErrorSource(
(
"Capture database rebuilding took a long time. This may not cause catastrophic errors, but rather will cause the server to hang for a while.",
f"To fix, consider moving your captures from {cap_path} to another location.",
)
)
)
return error_sources

View file

@ -1,41 +0,0 @@
import logging
from .error_sources import ErrorSource
# Error/exception messages we have enumerated and expect
PICAMERA_ERROR_MAP = {
"Camera is not enabled. Try running 'sudo raspi-config' and ensure that the camera has been enabled.": ErrorSource(
"Camera is not enabled. Try running 'sudo raspi-config' and ensure that the camera has been enabled."
),
"Failed to enable connection: Out of resources": ErrorSource(
"Camera already in use by another application. Please reboot your microscope."
),
}
# Basic import error. Usually damaged or disconnected camera
PICAMERA_IMPORT_ERROR = ErrorSource(
(
"Picamera module could not be imported. Check physical connections to the camera as it may be damaged or disconnected.",
)
)
def main():
error_sources = []
logging.info("Attempting to import picamera...")
try:
import picamerax
except Exception as _e: # pylint: disable=W0703
error_sources.append(PICAMERA_IMPORT_ERROR)
else:
try:
_cam = picamerax.PiCamera()
except picamerax.PiCameraError as e:
msg = e.args[0]
if msg in PICAMERA_ERROR_MAP:
error_sources.append(PICAMERA_ERROR_MAP[msg])
else:
error_sources.append(ErrorSource("Unenumerated exception: " + msg))
return error_sources

View file

@ -1,45 +0,0 @@
from openflexure_microscope.config import user_configuration
from .error_sources import ErrorSource
def main():
error_sources = []
try:
from sangaboard import Sangaboard
except ImportError as e:
error_sources.append(ErrorSource(str(e)))
else:
configuration = user_configuration.load()
stage_type = configuration["stage"].get("type")
stage_port = configuration["stage"].get("port")
# If any Sangaboard-based stage is configured for use
if stage_type in ("SangaBoard", "SangaStage", "SangaDeltaStage"):
# Try connecting on the specified port
try:
stage = Sangaboard(stage_port)
except FileNotFoundError as _:
if stage_port:
error_sources.append(
ErrorSource(
f"No {stage_type} device was found on the configured port {stage_port}."
)
)
else:
error_sources.append(
ErrorSource(
f"No {stage_type} device was found during port scanning."
)
)
else:
stage.close()
else:
error_sources.append(
ErrorSource(
f"Invalid stage type {stage_type} specified in configuration file."
)
)
return error_sources

View file

@ -1,64 +0,0 @@
import json
import logging
from .error_sources import ErrorSource
def trace_config_exceptions():
error_sources = []
from openflexure_microscope.paths import CONFIGURATION_FILE_PATH, SETTINGS_FILE_PATH
try:
default_config = json.load(CONFIGURATION_FILE_PATH)
if not default_config:
error_sources.append(
ErrorSource(
"Configuration file is missing or empty. This may occur if the server has never been started."
)
)
except Exception as e: # pylint: disable=W0703
logging.error("Error parsing config:")
logging.error(e)
error_sources.append(
ErrorSource(
f"Configuration file is malformed. You can reset to the default configuration by deleting {CONFIGURATION_FILE_PATH}."
)
)
try:
default_settings = json.load(SETTINGS_FILE_PATH)
if not default_settings:
error_sources.append(
ErrorSource(
"Settings file is missing or empty. This may occur if the server has never been started."
)
)
except Exception as e: # pylint: disable=W0703
logging.error("Error parsing settings:")
logging.error(e)
error_sources.append(
ErrorSource(
f"Settings file is malformed. You can reset to the default settings by deleting {SETTINGS_FILE_PATH}."
)
)
return error_sources
def main():
error_sources = []
logging.info("Attempting default settings and config import...")
try:
from openflexure_microscope import config as _
except Exception as e: # pylint: disable=W0703
error_sources.append(
ErrorSource(
"Error importing configuration submodule. This could be an error in our code."
)
)
logging.error("Error importing config:")
logging.error(e)
error_sources.extend(trace_config_exceptions())
return error_sources

View file

@ -1,69 +0,0 @@
from urllib.error import URLError
from urllib.request import urlopen
import psutil
from .error_sources import WarningSource
def internet_on():
test_urls = (
"https://build.openflexure.org", # Bath server
"https://openflexure.org", # GitHub pages
)
for url in test_urls:
try:
urlopen(url, timeout=10)
# If any test passes, return True
return True
except URLError:
pass
# If no test passed, return False
return False
def main():
error_sources = []
# Check internet
if not internet_on():
error_sources.append(
WarningSource(
"No internet connection detected. Updates may not be available."
)
)
# Check memory
mem = psutil.virtual_memory()
total_gb = mem.total / 1e9
if total_gb <= 1:
error_sources.append(
WarningSource(
(
"Less than 1GB total memory available.",
"For small scans, or control from another device, this is usually fine.",
"\n More complex usage may require additional resources.",
)
)
)
# Check disks
data_partitions = [
disk
for disk in psutil.disk_partitions()
if "rw" in disk.opts and disk.mountpoint != "/boot"
]
for part in data_partitions:
usage = psutil.disk_usage(part.mountpoint)
if int(usage.percent) >= 90:
error_sources.append(
WarningSource(
(
f"Disk {part.device}, at {part.mountpoint} is {int(usage.percent)}% full.",
"Captures may fail to save soon.",
)
)
)
return error_sources

View file

@ -1,33 +0,0 @@
class bcolors:
HEADER = "\033[95m"
OKBLUE = "\033[94m"
OKGREEN = "\033[92m"
WARNING = "\033[93m"
FAIL = "\033[91m"
ENDC = "\033[0m"
BOLD = "\033[1m"
UNDERLINE = "\033[4m"
class Source:
def __init__(self, message):
if isinstance(message, tuple):
self._message = " ".join(message)
else:
self._message = message
@property
def message(self):
return self._message
class WarningSource(Source):
@property
def message(self):
return "[?] " + bcolors.WARNING + self._message + bcolors.ENDC
class ErrorSource(Source):
@property
def message(self):
return "[!] " + bcolors.FAIL + self._message + bcolors.ENDC

View file

@ -1,58 +0,0 @@
#!/bin/python
#
# Copyright 2016 Flavio Garcia
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Usage: monitor_process.py <service_name>
#
# Example(crontab, every 5 minutes):
# */5 * * * * /root/bin/monitor_service.py prosody > /dev/null 2>&1
#
import subprocess
class ServiceMonitor(object):
def __init__(self, service):
self.service = service
def is_active(self):
"""Return True if service is running"""
for line in self.status():
if "Active:" in line:
if "(running)" in line:
return True
return False
def status(self):
cmd = f"/bin/systemctl status {self.service}.service"
proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
stdout_list = proc.communicate()[0].decode().split("\n")
return stdout_list
def start(self):
cmd = f"/bin/systemctl start {self.service}.service"
proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
proc.communicate()
def stop(self):
cmd = f"/bin/systemctl stop {self.service}.service"
proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
proc.communicate()
def log(self, n_lines: int = 100):
cmd = f"/bin/journalctl -u {self.service}.service -n {n_lines} --no-pager"
proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
stdout_list = proc.communicate()[0].decode().split("\n")
return stdout_list

View file

@ -1,42 +0,0 @@
import logging
import multiprocessing
import time
# bar
def test_long_fn():
for _ in range(100):
print("Tick")
time.sleep(1)
def launch_timeout_test_process(target, args=(), kwargs=None, timeout=10):
if not kwargs:
kwargs = {}
# Start target as a process
p = multiprocessing.Process(target=target, args=args, kwargs=kwargs)
p.start()
# Wait for 10 seconds or until process finishes
p.join(timeout)
# If thread is still active
if p.is_alive():
logging.error(
"Function %s reached timeout after %s seconds. Terminating.",
target,
timeout,
)
# Terminate
p.terminate()
p.join()
return False
else:
return True
if __name__ == "__main__":
launch_timeout_test_process(test_long_fn, timeout=5)

View file

@ -1,3 +0,0 @@
__all__ = ["base", "mock", "sanga"]
from . import base, mock, sanga

View file

@ -1,131 +0,0 @@
from abc import ABCMeta, abstractmethod
from typing import Dict, List, Optional, Tuple, Union
import numpy as np
from labthings import StrictLock
from typing_extensions import Literal
CoordinateType = Tuple[int, int, int]
class BaseStage(metaclass=ABCMeta):
"""
Attributes:
lock (:py:class:`labthings.StrictLock`): Strict lock controlling thread
access to camera hardware
"""
def __init__(self):
self.lock = StrictLock(name="Stage", timeout=None)
@abstractmethod
def update_settings(self, config: dict):
"""Update settings from a config dictionary"""
@abstractmethod
def read_settings(self):
"""Return the current settings as a dictionary"""
@property
@abstractmethod
def state(self):
"""The general state dictionary of the board."""
@property
@abstractmethod
def configuration(self):
"""The general stage configuration."""
@property
@abstractmethod
def n_axes(self):
"""The number of axes this stage has."""
@property
@abstractmethod
def position(self) -> CoordinateType:
"""The current position, as a list"""
@property
def position_map(self) -> Dict[str, int]:
return {"x": self.position[0], "y": self.position[1], "z": self.position[2]}
@property
@abstractmethod
def backlash(self):
"""Get the distance used for backlash compensation."""
@backlash.setter
def backlash(self):
"""Set the distance used for backlash compensation."""
# See: https://github.com/python/mypy/issues/4165
# Since we can't also decorate this with abstract method we want to be
# sure that the setter doesn't actually get used as a noop.
raise NotImplementedError
@abstractmethod
def move_rel(
self,
displacement: Union[int, CoordinateType],
axis: Optional[Literal["x", "y", "z"]] = None,
backlash: bool = True,
):
"""Make a relative move, optionally correcting for backlash.
displacement: integer or array/list of 3 integers
backlash: (default: True) whether to correct for backlash.
"""
@abstractmethod
def move_abs(self, final: CoordinateType, **kwargs):
"""Make an absolute move to a position"""
@abstractmethod
def zero_position(self):
"""Set the current position to zero"""
@abstractmethod
def close(self):
"""Cleanly close communication with the stage"""
def scan_linear(
self,
rel_positions: List[CoordinateType],
backlash: bool = True,
return_to_start: bool = True,
):
"""
Scan through a list of (relative) positions (generator fn)
rel_positions should be an nx3-element array (or list of 3 element arrays).
Positions should be relative to the starting position - not a list of relative moves.
backlash argument is passed to move_rel
if return_to_start is True (default) we return to the starting position after a
successful scan. NB we always attempt to return to the starting position if an
exception occurs during the scan..
"""
starting_position = self.position
rel_positions_array: np.ndarray = np.array(rel_positions)
assert rel_positions_array.shape[1] == 3, ValueError(
"Positions should be 3 elements long."
)
try:
self.move_rel(rel_positions_array[0], backlash=backlash)
yield 0
for i, step in enumerate(np.diff(rel_positions_array, axis=0)):
self.move_rel(step, backlash=backlash)
yield i + 1
except Exception as e:
return_to_start = True # always return to start if it went wrong.
raise e
finally:
if return_to_start:
self.move_abs(starting_position, backlash=backlash)
def scan_z(self, dz: List[int], **kwargs):
"""Scan through a list of (relative) z positions (generator fn)
This function takes a 1D numpy array of Z positions, relative to
the position at the start of the scan, and converts it into an
array of 3D positions with x=y=0. This, along with all the
keyword arguments, is then passed to ``scan_linear``.
"""
return self.scan_linear([(0, 0, z) for z in dz], **kwargs)

View file

@ -1,109 +0,0 @@
import logging
import time
from collections.abc import Iterable
from typing import Optional, Tuple, Union
import numpy as np
from typing_extensions import Literal
from openflexure_microscope.stage.base import BaseStage
from openflexure_microscope.utilities import axes_to_array
class MissingStage(BaseStage):
def __init__(self, *args, **kwargs): # pylint: disable=unused-argument
BaseStage.__init__(self)
self._position = [0, 0, 0]
self._n_axis = 3
self._backlash = None
@property
def state(self):
"""The general state dictionary of the board."""
state = {"position": self.position_map}
return state
@property
def configuration(self):
return {}
def update_settings(self, config: dict):
"""Update settings from a config dictionary"""
# Set backlash. Expects a dictionary with axis labels
if "backlash" in config:
# Construct backlash array
backlash = axes_to_array(config["backlash"], ["x", "y", "z"], [0, 0, 0])
self.backlash = backlash
def read_settings(self) -> dict:
"""Return the current settings as a dictionary"""
blsh = self.backlash.tolist()
config = {"backlash": {"x": blsh[0], "y": blsh[1], "z": blsh[2]}}
return config
@property
def n_axes(self):
return self._n_axis
@property
def position(self):
return self._position
@property
def backlash(self):
if self._backlash is not None:
return self._backlash
else:
return np.array([0] * self.n_axes)
@backlash.setter
def backlash(self, blsh):
if blsh is None:
self._backlash = None
elif isinstance(blsh, Iterable):
assert len(blsh) == self.n_axes
self._backlash = np.array(blsh)
else:
self._backlash = np.array([int(blsh)] * self.n_axes, dtype=np.int)
def move_rel(
self,
displacement: Union[int, Tuple[int, int, int]],
axis: Optional[Literal["x", "y", "z"]] = None,
backlash: bool = True,
):
time.sleep(0.5)
if axis:
# Displacement MUST be an integer if axis name is specified
if not isinstance(displacement, int):
raise TypeError(
"Displacement must be an integer when axis is specified"
)
# Axis name MUST be x, y, or z
if axis not in ("x", "y", "z"):
raise ValueError("axis must be one of x, y, or z")
move = (
displacement if axis == "x" else 0,
displacement if axis == "y" else 0,
displacement if axis == "z" else 0,
)
displacement = move
initial_move = np.array(displacement, dtype=np.integer)
self._position = list(np.array(self._position) + np.array(initial_move))
logging.debug(np.array(self._position) + np.array(initial_move))
logging.debug("New position: %s", self._position)
def move_abs(self, final, **kwargs):
time.sleep(0.5)
self._position = list(final)
logging.debug("New position: %s", self._position)
def zero_position(self):
"""Set the current position to zero"""
self._position = [0, 0, 0]
def close(self):
pass

View file

@ -1,367 +0,0 @@
import logging
import time
from collections.abc import Iterable
from types import GeneratorType
from typing import Optional, Tuple, Union
import numpy as np
from sangaboard import Sangaboard
from typing_extensions import Literal
from openflexure_microscope.stage.base import BaseStage
from openflexure_microscope.utilities import axes_to_array
def _displacement_to_array(
displacement: int, axis: Literal["x", "y", "z"]
) -> np.ndarray:
# Create the displacement array
return np.array(
[
displacement if axis == "x" else 0,
displacement if axis == "y" else 0,
displacement if axis == "z" else 0,
]
)
class SangaStage(BaseStage):
"""
Sangaboard v0.2 and v0.3 powered Stage object
Args:
port (str): Serial port on which to open communication
Attributes:
board (:py:class:`openflexure_microscope.stage.sangaboard.Sangaboard`): Parent Sangaboard object.
_backlash (list): 3-element (element-per-axis) list of backlash compensation in steps.
"""
def __init__(self, port=None, **kwargs):
"""Class managing serial communications with the motors for an Openflexure stage"""
BaseStage.__init__(self)
self.port = port
self.board = Sangaboard(port, **kwargs)
# Initialise backlash storage, used by property setter/getter
self._backlash = None
self.settle_time = 0.2 # Default move settle time
self._position_on_enter = None
@property
def state(self):
"""The general state dictionary of the board."""
return {"position": self.position_map}
@property
def configuration(self):
return {
"port": self.port,
"board": self.board.board,
"firmware": self.board.firmware,
}
@property
def n_axes(self):
"""The number of axes this stage has."""
return 3
@property
def position(self) -> Tuple[int, int, int]:
return self.board.position
@property
def backlash(self) -> np.ndarray:
"""The distance used for backlash compensation.
Software backlash compensation is enabled by setting this property to a value
other than `None`. The value can either be an array-like object (list, tuple,
or numpy array) with one element for each axis, or a single integer if all axes
are the same.
The property will always return an array with the same length as the number of
axes.
The backlash compensation algorithm is fairly basic - it ensures that we always
approach a point from the same direction. For each axis that's moving, the
direction of motion is compared with ``backlash``. If the direction is opposite,
then the stage will overshoot by the amount in ``-backlash[i]`` and then move
back by ``backlash[i]``. This is computed per-axis, so if some axes are moving
in the same direction as ``backlash``, they won't do two moves.
"""
if isinstance(self._backlash, np.ndarray):
return self._backlash
elif isinstance(self._backlash, list):
return np.array(self._backlash)
elif isinstance(self._backlash, int):
return np.array([self._backlash] * self.n_axes)
else:
return np.array([0] * self.n_axes)
@backlash.setter
def backlash(self, blsh):
logging.debug("Setting backlash to %s", (blsh))
if blsh is None:
self._backlash = None
elif isinstance(blsh, Iterable):
assert len(blsh) == self.n_axes
self._backlash = np.array(blsh)
else:
self._backlash = np.array([int(blsh)] * self.n_axes, dtype=np.int)
def update_settings(self, config: dict):
"""Update settings from a config dictionary"""
# Set backlash. Expects a dictionary with axis labels
if "backlash" in config:
# Construct backlash array
backlash = axes_to_array(config["backlash"], ["x", "y", "z"], [0, 0, 0])
self.backlash = np.array(backlash)
if "settle_time" in config:
self.settle_time = config.get("settle_time")
def read_settings(self) -> dict:
"""Return the current settings as a dictionary"""
if self.backlash is not None:
blsh = self.backlash.tolist()
else:
blsh = None
config = {
"backlash": {"x": blsh[0], "y": blsh[1], "z": blsh[2]},
"settle_time": self.settle_time,
}
return config
def move_rel(
self,
displacement: Union[int, Tuple[int, int, int], np.ndarray],
axis: Optional[Literal["x", "y", "z"]] = None,
backlash: bool = True,
):
"""Make a relative move, optionally correcting for backlash.
displacement: integer or array/list of 3 integers
axis: None (for 3-axis moves) or one of 'x','y','z'
backlash: (default: True) whether to correct for backlash.
Backlash Correction:
This backlash correction strategy ensures we're always approaching the
end point from the same direction, while minimising the amount of extra
motion. It's a good option if you're scanning in a line, for example,
as it will kick in when moving to the start of the line, but not for each
point on the line.
For each axis where we're moving in the *opposite*
direction to self.backlash, we deliberately overshoot:
"""
with self.lock:
logging.debug("Moving sangaboard by %s", displacement)
# If we specify an axis name and a displacement int, convert to a displacement tuple
if axis:
# Displacement MUST be an integer if axis name is specified
if not isinstance(displacement, int):
raise TypeError(
"Displacement must be an integer when axis is specified"
)
# Axis name MUST be x, y, or z
if axis not in ("x", "y", "z"):
raise ValueError("axis must be one of x, y, or z")
# Calculate displacement array
displacement_array: np.ndarray = _displacement_to_array(
displacement, axis
)
elif isinstance(displacement, np.ndarray):
displacement_array = displacement
elif isinstance(displacement, (list, tuple, GeneratorType)):
# Convert our displacement tuple/generator into a numpy array
displacement_array = np.array(list(displacement))
else:
raise TypeError(f"Unsupported displacement type {type(displacement)}")
# Handle simple case, no backlash
if not backlash or self.backlash is None:
return self.board.move_rel(displacement_array)
# Handle move with backlash correction
# Calculate main movement
initial_move: np.ndarray = np.copy(displacement_array)
initial_move -= np.where(
self.backlash * displacement_array < 0,
self.backlash,
np.zeros(self.n_axes, dtype=self.backlash.dtype),
)
# Make the main movement
self.board.move_rel(initial_move)
# Handle backlash if required
if np.any(displacement_array - initial_move != 0):
# If backlash correction has kicked in and made us overshoot, move
# to the correct end position (i.e. the move we were asked to make)
self.board.move_rel(displacement_array - initial_move)
# Settle outside of the stage lock so that another move request
# can just take over before settling
time.sleep(self.settle_time)
def move_abs(self, final: Union[Tuple[int, int, int], np.ndarray], **kwargs):
"""Make an absolute move to a position
"""
with self.lock:
logging.debug("Moving sangaboard to %s", final)
self.board.move_abs(final, **kwargs)
# Settle outside of the stage lock so that another move request
# can just take over before settling
time.sleep(self.settle_time)
def zero_position(self):
"""Set the current position to zero"""
with self.lock:
self.board.zero_position()
def close(self):
"""Cleanly close communication with the stage"""
if hasattr(self, "board"):
self.board.close()
# Methods specific to Sangaboard
def release_motors(self):
"""De-energise the stepper motor coils"""
self.board.release_motors()
def __enter__(self):
"""When we use this in a with statement, remember where we started."""
self._position_on_enter = self.position
return self
def __exit__(self, type_, value, traceback):
"""The end of the with statement. Reset position if it went wrong.
NB the instrument is closed when the object is deleted, so we don't
need to worry about that here.
"""
if type_ is not None:
print(
"An exception occurred inside a with block, resetting position \
to its value at the start of the with block"
)
try:
time.sleep(0.5)
self.move_abs(self._position_on_enter)
except Exception as e: # pylint: disable=W0703
print(
"A further exception occurred when resetting position: {}".format(e)
)
print("Move completed, raising exception...")
raise value # Propagate the exception
class SangaDeltaStage(SangaStage):
def __init__(
self,
port: Optional[str] = None,
flex_h: int = 80,
flex_a: int = 50,
flex_b: int = 50,
camera_angle: float = 0,
**kwargs,
):
self.flex_h: int = flex_h
self.flex_a: int = flex_a
self.flex_b: int = flex_b
# Set up camera rotation relative to stage
camera_theta: float = (camera_angle / 180) * np.pi
self.R_camera: np.ndarray = np.array(
[
[np.cos(camera_theta), -np.sin(camera_theta), 0],
[np.sin(camera_theta), np.cos(camera_theta), 0],
[0, 0, 1],
]
)
logging.debug(self.R_camera)
# Transformation matrix converting delta into cartesian
x_fac: float = -1 * np.multiply(
np.divide(2, np.sqrt(3)), np.divide(self.flex_b, self.flex_h)
)
y_fac: float = -1 * np.divide(self.flex_b, self.flex_h)
z_fac: float = np.multiply(np.divide(1, 3), np.divide(self.flex_b, self.flex_a))
self.Tvd: np.ndarray = np.array(
[
[-x_fac, x_fac, 0],
[0.5 * y_fac, 0.5 * y_fac, -y_fac],
[z_fac, z_fac, z_fac],
]
)
logging.debug(self.Tvd)
self.Tdv: np.ndarray = np.linalg.inv(self.Tvd)
logging.debug(self.Tdv)
SangaStage.__init__(self, port=port, **kwargs)
@property
def raw_position(self) -> Tuple[int, int, int]:
return self.board.position
@property
def position(self):
# TODO: Account for camera rotation
position: np.ndarray = np.dot(self.Tvd, self.raw_position)
position: np.ndarray = np.dot(np.linalg.inv(self.R_camera), position)
return [int(p) for p in position]
def move_rel(
self,
displacement: Union[int, Tuple[int, int, int], np.ndarray],
axis: Optional[Literal["x", "y", "z"]] = None,
backlash: bool = True,
):
# If we specify an axis name and a displacement int, convert to a displacement tuple
if axis:
# Displacement MUST be an integer if axis name is specified
if not isinstance(displacement, int):
raise TypeError(
"Displacement must be an integer when axis is specified"
)
# Axis name MUST be x, y, or z
if axis not in ("x", "y", "z"):
raise ValueError("axis must be one of x, y, or z")
# Calculate displacement array
cartesian_displacement_array: np.ndarray = _displacement_to_array(
displacement, axis
)
elif isinstance(displacement, np.ndarray):
cartesian_displacement_array = displacement
elif isinstance(displacement, (list, tuple, GeneratorType)):
# Convert our displacement tuple/generator into a numpy array
cartesian_displacement_array = np.array(list(displacement))
else:
raise TypeError(f"Unsupported displacement type {type(displacement)}")
# Transform into camera coordinates
camera_displacement_array: np.ndarray = np.dot(
self.R_camera, cartesian_displacement_array
)
# Transform into delta coordinates
delta_displacement_array: np.ndarray = np.dot(
self.Tdv, camera_displacement_array
)
logging.debug("Delta displacement: %s", (delta_displacement_array))
# Do the move
SangaStage.move_rel(
self, delta_displacement_array, axis=None, backlash=backlash
)
def move_abs(self, final: Union[Tuple[int, int, int], np.ndarray], **kwargs):
# Transform into camera coordinates
camera_final_array: np.ndarray = np.dot(self.R_camera, final)
# Transform into delta coordinates
delta_final_array: np.ndarray = np.dot(self.Tdv, camera_final_array)
logging.debug("Delta final: %s", (final))
# Do the move
SangaStage.move_abs(self, delta_final_array, **kwargs)

View file

@ -1,149 +0,0 @@
import base64
import copy
import logging
import sys
import time
from contextlib import contextmanager
from typing import Dict, List, Optional, Sequence, Tuple, Type, Union
import numpy as np
# TypedDict was added to typing in 3.8. Use typing_extensions for <3.8
if sys.version_info >= (3, 8):
from typing import TypedDict # pylint: disable=no-name-in-module
else:
from typing_extensions import TypedDict
class Timer(object):
def __init__(self, name: str):
self.name: str = name
self.start: Optional[float] = None
self.end: Optional[float] = None
def __enter__(self):
self.start = time.time()
def __exit__(self, type_, value, traceback):
self.end = time.time()
logging.debug("%s time: %s", self.name, self.end - self.start)
JSONArrayType = TypedDict(
"JSONArrayType",
{"@type": str, "base64": str, "dtype": str, "shape": Tuple[int, ...]},
)
def deserialise_array_b64(
b64_string: str, dtype: Union[Type[np.dtype], str], shape: Tuple[int, ...]
):
flat_arr: np.ndarray = np.frombuffer(base64.b64decode(b64_string), dtype)
return flat_arr.reshape(shape)
def serialise_array_b64(npy_arr: np.ndarray) -> Tuple[str, str, Tuple[int, ...]]:
b64_string: str = base64.b64encode(npy_arr.tobytes()).decode("ascii")
dtype: str = str(npy_arr.dtype)
shape: Tuple[int, ...] = npy_arr.shape
return b64_string, dtype, shape
def ndarray_to_json(arr: np.ndarray) -> JSONArrayType:
if isinstance(arr, memoryview):
# We can transparently convert memoryview objects to arrays
# This comes in very handy for the lens shading table.
arr = np.array(arr)
b64_string, dtype, shape = serialise_array_b64(arr)
return {"@type": "ndarray", "dtype": dtype, "shape": shape, "base64": b64_string}
def json_to_ndarray(json_dict: JSONArrayType):
if json_dict.get("@type") != "ndarray":
logging.warning("No valid @type attribute found. Conversion may fail.")
for required_param in ("dtype", "shape", "base64"):
if not json_dict.get(required_param):
raise KeyError(f"Missing required key {required_param}")
b64_string: Optional[str] = json_dict.get("base64")
dtype: Optional[str] = json_dict.get("dtype")
shape: Optional[Tuple[int, ...]] = json_dict.get("shape")
if b64_string and dtype and shape:
return deserialise_array_b64(b64_string, dtype, shape)
else:
raise ValueError("Required parameters for decoding are missing")
@contextmanager
def set_properties(obj, **kwargs):
"""A context manager to set, then reset, certain properties of an object.
The first argument is the object, subsequent keyword arguments are properties
of said object, which are set initially, then reset to their previous values.
"""
saved_properties = {}
for k in kwargs.keys():
try:
saved_properties[k] = getattr(obj, k)
except AttributeError:
print(
"Warning: could not get {} on {}. This property will not be restored!".format(
k, obj
)
)
for k, v in kwargs.items():
setattr(obj, k, v)
try:
yield
finally:
for k, v in saved_properties.items():
setattr(obj, k, v)
def axes_to_array(
coordinate_dictionary: Dict[str, Optional[int]],
axis_keys: Sequence[str] = ("x", "y", "z"),
base_array: Optional[List[int]] = None,
asint: bool = True,
) -> List[int]:
"""Takes key-value pairs of a JSON value, and maps onto an array
This is designed to take a dictionary like `{"x": 1, "y":2, "z":3}`
and return a list like `[1, 2, 3]` to convert between the argument
format expected by most of our stages, and the usual argument
format in JSON.
`axis_keys` is an ordered sequence of key names to extract from
the input dictionary.
`base_array` specifies a default value for each axis. It must
have the same length as `axis_keys`.
`asint` casts values to integers if it is `True` (default).
Missing keys, or keys that have a `None` value will be left
at the specified default value, or zero if none is specified.
"""
# If no base array is given
if not base_array:
# Create an array of zeros
base_array = [0] * len(axis_keys)
else:
# Create a copy of the passed base_array
base_array = copy.copy(base_array)
# Do the mapping
for axis, key in enumerate(axis_keys):
if key in coordinate_dictionary:
value = coordinate_dictionary[key]
if value is None:
# Values set to None should be treated as if they
# are missing
# i.e. we leave the default value in place.
break
if asint:
value = int(value)
base_array[axis] = value
return base_array

View file

@ -1,5 +1,39 @@
[project]
name = "openflexure-microscope-server"
version = "3.0.0.dev0"
authors = [
{ name="Richard Bowman", email="richard.bowman@cantab.net" },
{ name="Joel Collins" },
{ name="Julian Stirling" },
]
description = "Back-end code for the OpenFlexure Microscope"
readme = "README.md"
requires-python = ">=3.9"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: OS Independent",
]
dependencies = [
"labthings-fastapi",
"labthings-sangaboard",
"labthings-picamera2",
"numpy ~= 1.20",
"scipy ~= 1.6.1",
]
[project.optional-dependencies]
dev = [
"labthings-fastapi[dev]",
]
[project.urls]
"Homepage" = "https://openflexure.org/"
"Bug Tracker" = "https://gitlab.com/openflexure/openflexure-microscope-server/-/issues"
"Source" = "https://gitlab.com/openflexure/openflexure-microscope-server/"
[build-system] [build-system]
requires = ["setuptools", "wheel"] requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
# The [tool.poetry] metadata has been removed from this file, because # The [tool.poetry] metadata has been removed from this file, because
@ -34,6 +68,12 @@ line_length = 88
disable = "fixme,C,R,W1203" disable = "fixme,C,R,W1203"
max-line-length = 88 max-line-length = 88
[tool.mypy]
plugins = ["pydantic.mypy"]
[tool.ruff]
target-version = "py39"
[tool.poe.executor] [tool.poe.executor]
type = "virtualenv" type = "virtualenv"
location = ".venv" location = ".venv"
@ -41,13 +81,15 @@ location = ".venv"
[tool.poe.tasks] [tool.poe.tasks]
black = "black ." black = "black ."
black_check = "black --check ." black_check = "black --check ."
ruff = "ruff . --fix"
ruff_check = "ruff ."
isort = "isort openflexure_microscope" isort = "isort openflexure_microscope"
pylint = "pylint openflexure_microscope" pylint = "pylint openflexure_microscope"
mypy = "mypy --cobertura-xml-report openflexure_microscope openflexure_microscope" mypy = "mypy --cobertura-xml-report openflexure_microscope openflexure_microscope"
test = "pytest . --junitxml=pytest_report.xml" test = "pytest . --junitxml=pytest_report.xml"
serve = "python -m openflexure_microscope.api.app" serve = "python -m openflexure_microscope.api.app"
format = ["black", "isort"] format = ["black", "isort", "ruff"]
lint = ["pylint", "mypy"] lint = ["ruff_check", "pylint", "mypy"]
check = ["format", "lint", "test"] check = ["format", "lint", "test"]

View file

@ -1,98 +0,0 @@
"""A setuptools based setup module for the OpenFlexure Microscope server
This file was prepared according to:
https://packaging.python.org/guides/distributing-packages-using-setuptools/
"""
from os import path
# the following imports, from the guide above, prefer `setuptools` to `distutils`
from setuptools import find_packages, setup
here = path.abspath(path.dirname(__file__))
# Get the long description from the README file
with open(path.join(here, "README.md"), encoding="utf-8") as f:
long_description = f.read()
# Not all arguments to `setup()` are required for upload to PyPI.
# See the tutorial linked to at the top for which ones are needed.
setup(
name="openflexure-microscope-server",
version="2.11.0",
description="Python module, and Flask-based web API, to run the OpenFlexure Microscope.",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://www.openflexure.org", # "home-page" metadata field
author="Joel Collins, Richard Bowman, Julian Stirling",
author_email="contact@openflexure.org",
# For a list of valid classifiers, see https://pypi.org/classifiers/
# NB declaring Python versions here doesn't affect dependency resolution - it's for info only.
classifiers=["License :: OSI Approved :: GNU General Public License v3 (GPLv3)"],
keywords="raspberry pi arduino microscope", # Optional
packages=find_packages(exclude=["contrib", "docs", "tests", "*node_modules*"]),
python_requires="== 3.7.*",
# This field lists other packages that your project depends on to run.
# Any package you put here will be installed by pip when your project is
# installed, so they must be valid existing projects.
#
# These dependencies specify relatively flexible versions; Pipenv then resolves these
# to specific versions, and locks those versions in ``Pipfile``. Usually, when you
# set up the project for development, you use those specific packages, rather than
# the looser specifications given here.
install_requires=[
"apispec[validation]", # We need the extra to validate the spec
"Flask ~= 1.0",
"Pillow ~= 7.2.0",
"numpy ~= 1.20",
"scipy ~= 1.6.1",
"python-dateutil ~= 2.8",
"psutil ~= 5.6.7", # Autostorage extension
"markupsafe ~= 2.0.0", # 2.1.x breaks jinja2
"opencv-python-headless ~= 4.5.1",
"sangaboard ~= 0.3.3",
"expiringdict ~= 1.2.1",
"camera-stage-mapping == 0.1.4",
"picamerax ~= 20.9.1",
"pyyaml ~= 5.4.0",
"pytest-cov ~= 2.10.1",
"piexif ~= 1.1.3",
"labthings ~= 1.3.0",
"typing-extensions ~= 4.3.0", # Needed for some type-hints in Python < 3.8 (e.g. Literal)
"RPi.GPIO ~= 0.7.0; platform_machine == 'armv7l'",
],
# "dev" specifies extra packages used for development (linting, testing, etc.)
# As with install_requires, these are relatively loose versions - Pipfile will then lock
# them to specific versions to enable consistent builds and testing.
extras_require={
"dev": [
"sphinx < 4.0", # Currently httpdomain isn't ready for 4.0
"sphinxcontrib-openapi ~= 0.7",
"sphinx_rtd_theme ~=0.5.2",
"pylint ~= 2.14.0", # 2.9.2 crashes and I've not yet figured out why.
"pytest ~= 7.1.2",
"mypy ~= 0.971",
"types-python-dateutil",
"types-setuptools",
"poethepoet ~= 0.16.0",
"freezegun ~= 1.2.1",
"lxml ~= 4.9",
"black == 18.9b0",
]
},
dependency_links=[],
# We create some "entry points" to make it easier to run important modules
entry_points={
"console_scripts": [
"ofm-serve=openflexure_microscope.api.app:ofm_serve",
"ofm-rescue=openflexure_microscope.rescue.auto:main",
"ofm-generate-openapi=openflexure_microscope.api.app:generate_openapi",
]
},
project_urls={
"Source": "https://gitlab.com/openflexure/openflexure-microscope-server"
},
)

View file

@ -0,0 +1,15 @@
from __future__ import annotations
import logging
from labthings_fastapi.thing_server import ThingServer
from labthings_sangaboard import SangaboardThing
from labthings_picamera2.thing import StreamingPiCamera2
logging.basicConfig(level=logging.INFO)
thing_server = ThingServer()
camera = StreamingPiCamera2()
thing_server.add_thing(camera, "/camera")
stage = SangaboardThing()
thing_server.add_thing(stage, "/stage")
app = thing_server.app