From 081654533f8e3e00b8f8a0469d75c379bab36988 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 29 Aug 2023 17:02:16 +0100 Subject: [PATCH] First images on labthings-fastapi We now have very basic camera and stage support. --- docs/source/api.rst | 12 - docs/source/basecamera.rst | 5 - docs/source/basestage.rst | 5 - docs/source/camera.rst | 35 - docs/source/capture.rst | 11 - docs/source/conf.py | 253 ------- docs/source/config.rst | 21 - docs/source/extensions/actions.rst | 56 -- docs/source/extensions/ev_gui.rst | 223 ------ .../example_extension/01_basic_structure.py | 36 - .../example_extension/02_adding_views.py | 65 -- .../example_extension/03_marshaling_data.py | 73 -- .../example_extension/04_properties.py | 89 --- .../example_extension/05_actions.py | 123 --- .../example_extension/06_tasks_locks.py | 87 --- .../extensions/example_extension/07_ev_gui.py | 120 --- docs/source/extensions/introduction.rst | 30 - docs/source/extensions/lifecycle_hooks.rst | 53 -- docs/source/extensions/marshaling.rst | 171 ----- docs/source/extensions/properties.rst | 114 --- docs/source/extensions/structure.rst | 28 - docs/source/extensions/tasks_locks.rst | 107 --- docs/source/extensions/views.rst | 44 -- docs/source/index.rst | 23 - docs/source/microscope.rst | 11 - docs/source/picamera.rst | 5 - docs/source/plugins.rst | 15 - docs/source/quickstart.rst | 30 - docs/source/sangastage.rst | 5 - docs/source/stage.rst | 8 - docs/source/webapp/index.rst | 22 - docs/source/webapp/pane_capture.png | 3 - docs/source/webapp/pane_capture.rst | 23 - docs/source/webapp/pane_gallery.png | 3 - docs/source/webapp/pane_gallery.rst | 10 - docs/source/webapp/pane_navigate.png | 3 - docs/source/webapp/pane_navigate.rst | 10 - docs/source/webapp/pane_settings.png | 3 - docs/source/webapp/pane_settings.rst | 14 - docs/source/webapp/pane_view.png | 3 - docs/source/webapp/scan_dialog.png | 3 - openflexure_microscope/api/__init__.py | 0 openflexure_microscope/api/app.py | 273 ------- .../api/default_extensions/__init__.py | 49 -- .../api/default_extensions/autofocus.py | 710 ------------------ .../api/default_extensions/autostorage.py | 274 ------- .../camera_stage_mapping.py | 398 ---------- .../picamera_autocalibrate/__init__.py | 1 - .../picamera_autocalibrate/extension.py | 244 ------ .../recalibrate_utils.py | 408 ---------- .../api/default_extensions/scan.py | 646 ---------------- .../api/default_extensions/zip_builder.py | 230 ------ .../api/dev_extensions/__init__.py | 3 - .../api/dev_extensions/tools.py | 40 - openflexure_microscope/api/openapi.py | 36 - .../api/utilities/__init__.py | 88 --- openflexure_microscope/api/utilities/gui.py | 62 -- openflexure_microscope/api/v2/__init__.py | 0 .../api/v2/views/__init__.py | 6 - .../api/v2/views/actions/__init__.py | 56 -- .../api/v2/views/actions/camera.py | 157 ---- .../api/v2/views/actions/stage.py | 65 -- .../api/v2/views/actions/system.py | 50 -- openflexure_microscope/api/v2/views/camera.py | 51 -- .../api/v2/views/captures.py | 327 -------- .../api/v2/views/instrument.py | 140 ---- openflexure_microscope/api/v2/views/stage.py | 32 - .../api/v2/views/streams.py | 71 -- openflexure_microscope/camera/__init__.py | 0 openflexure_microscope/camera/base.py | 217 ------ openflexure_microscope/camera/mock.py | 248 ------ openflexure_microscope/camera/pi.py | 528 ------------- .../camera/set_picamera_gain.py | 73 -- openflexure_microscope/captures/__init__.py | 11 - openflexure_microscope/captures/capture.py | 395 ---------- .../captures/capture_manager.py | 228 ------ openflexure_microscope/config.py | 168 ----- openflexure_microscope/devel/__init__.py | 24 - openflexure_microscope/json.py | 33 - openflexure_microscope/microscope.py | 414 ---------- openflexure_microscope/paths.py | 72 -- openflexure_microscope/rescue/__init__.py | 0 openflexure_microscope/rescue/auto.py | 92 --- .../rescue/check_capture_reload.py | 62 -- .../rescue/check_picamera.py | 41 - .../rescue/check_sangaboard.py | 45 -- .../rescue/check_settings.py | 64 -- openflexure_microscope/rescue/check_system.py | 69 -- .../rescue/error_sources.py | 33 - .../rescue/monitor_service.py | 58 -- .../rescue/monitor_timeout.py | 42 -- openflexure_microscope/stage/__init__.py | 3 - openflexure_microscope/stage/base.py | 131 ---- openflexure_microscope/stage/mock.py | 109 --- openflexure_microscope/stage/sanga.py | 367 --------- openflexure_microscope/utilities.py | 149 ---- pyproject.toml | 48 +- setup.py | 98 --- .../__init__.py | 0 src/openflexure_microscope_server/server.py | 15 + 100 files changed, 60 insertions(+), 10149 deletions(-) delete mode 100644 docs/source/api.rst delete mode 100644 docs/source/basecamera.rst delete mode 100644 docs/source/basestage.rst delete mode 100644 docs/source/camera.rst delete mode 100644 docs/source/capture.rst delete mode 100644 docs/source/conf.py delete mode 100644 docs/source/config.rst delete mode 100644 docs/source/extensions/actions.rst delete mode 100644 docs/source/extensions/ev_gui.rst delete mode 100644 docs/source/extensions/example_extension/01_basic_structure.py delete mode 100644 docs/source/extensions/example_extension/02_adding_views.py delete mode 100644 docs/source/extensions/example_extension/03_marshaling_data.py delete mode 100644 docs/source/extensions/example_extension/04_properties.py delete mode 100644 docs/source/extensions/example_extension/05_actions.py delete mode 100644 docs/source/extensions/example_extension/06_tasks_locks.py delete mode 100644 docs/source/extensions/example_extension/07_ev_gui.py delete mode 100644 docs/source/extensions/introduction.rst delete mode 100644 docs/source/extensions/lifecycle_hooks.rst delete mode 100644 docs/source/extensions/marshaling.rst delete mode 100644 docs/source/extensions/properties.rst delete mode 100644 docs/source/extensions/structure.rst delete mode 100644 docs/source/extensions/tasks_locks.rst delete mode 100644 docs/source/extensions/views.rst delete mode 100644 docs/source/index.rst delete mode 100644 docs/source/microscope.rst delete mode 100644 docs/source/picamera.rst delete mode 100644 docs/source/plugins.rst delete mode 100644 docs/source/quickstart.rst delete mode 100644 docs/source/sangastage.rst delete mode 100644 docs/source/stage.rst delete mode 100644 docs/source/webapp/index.rst delete mode 100644 docs/source/webapp/pane_capture.png delete mode 100644 docs/source/webapp/pane_capture.rst delete mode 100644 docs/source/webapp/pane_gallery.png delete mode 100644 docs/source/webapp/pane_gallery.rst delete mode 100644 docs/source/webapp/pane_navigate.png delete mode 100644 docs/source/webapp/pane_navigate.rst delete mode 100644 docs/source/webapp/pane_settings.png delete mode 100644 docs/source/webapp/pane_settings.rst delete mode 100644 docs/source/webapp/pane_view.png delete mode 100644 docs/source/webapp/scan_dialog.png delete mode 100644 openflexure_microscope/api/__init__.py delete mode 100644 openflexure_microscope/api/app.py delete mode 100644 openflexure_microscope/api/default_extensions/__init__.py delete mode 100644 openflexure_microscope/api/default_extensions/autofocus.py delete mode 100644 openflexure_microscope/api/default_extensions/autostorage.py delete mode 100644 openflexure_microscope/api/default_extensions/camera_stage_mapping.py delete mode 100644 openflexure_microscope/api/default_extensions/picamera_autocalibrate/__init__.py delete mode 100644 openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py delete mode 100644 openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py delete mode 100644 openflexure_microscope/api/default_extensions/scan.py delete mode 100644 openflexure_microscope/api/default_extensions/zip_builder.py delete mode 100644 openflexure_microscope/api/dev_extensions/__init__.py delete mode 100644 openflexure_microscope/api/dev_extensions/tools.py delete mode 100644 openflexure_microscope/api/openapi.py delete mode 100644 openflexure_microscope/api/utilities/__init__.py delete mode 100644 openflexure_microscope/api/utilities/gui.py delete mode 100644 openflexure_microscope/api/v2/__init__.py delete mode 100644 openflexure_microscope/api/v2/views/__init__.py delete mode 100644 openflexure_microscope/api/v2/views/actions/__init__.py delete mode 100644 openflexure_microscope/api/v2/views/actions/camera.py delete mode 100644 openflexure_microscope/api/v2/views/actions/stage.py delete mode 100644 openflexure_microscope/api/v2/views/actions/system.py delete mode 100644 openflexure_microscope/api/v2/views/camera.py delete mode 100644 openflexure_microscope/api/v2/views/captures.py delete mode 100644 openflexure_microscope/api/v2/views/instrument.py delete mode 100644 openflexure_microscope/api/v2/views/stage.py delete mode 100644 openflexure_microscope/api/v2/views/streams.py delete mode 100644 openflexure_microscope/camera/__init__.py delete mode 100644 openflexure_microscope/camera/base.py delete mode 100644 openflexure_microscope/camera/mock.py delete mode 100644 openflexure_microscope/camera/pi.py delete mode 100644 openflexure_microscope/camera/set_picamera_gain.py delete mode 100644 openflexure_microscope/captures/__init__.py delete mode 100644 openflexure_microscope/captures/capture.py delete mode 100644 openflexure_microscope/captures/capture_manager.py delete mode 100644 openflexure_microscope/config.py delete mode 100644 openflexure_microscope/devel/__init__.py delete mode 100644 openflexure_microscope/json.py delete mode 100644 openflexure_microscope/microscope.py delete mode 100644 openflexure_microscope/paths.py delete mode 100644 openflexure_microscope/rescue/__init__.py delete mode 100644 openflexure_microscope/rescue/auto.py delete mode 100644 openflexure_microscope/rescue/check_capture_reload.py delete mode 100644 openflexure_microscope/rescue/check_picamera.py delete mode 100644 openflexure_microscope/rescue/check_sangaboard.py delete mode 100644 openflexure_microscope/rescue/check_settings.py delete mode 100644 openflexure_microscope/rescue/check_system.py delete mode 100644 openflexure_microscope/rescue/error_sources.py delete mode 100644 openflexure_microscope/rescue/monitor_service.py delete mode 100644 openflexure_microscope/rescue/monitor_timeout.py delete mode 100644 openflexure_microscope/stage/__init__.py delete mode 100644 openflexure_microscope/stage/base.py delete mode 100644 openflexure_microscope/stage/mock.py delete mode 100644 openflexure_microscope/stage/sanga.py delete mode 100644 openflexure_microscope/utilities.py delete mode 100644 setup.py rename {openflexure_microscope => src/openflexure_microscope_server}/__init__.py (100%) create mode 100644 src/openflexure_microscope_server/server.py diff --git a/docs/source/api.rst b/docs/source/api.rst deleted file mode 100644 index 5e0e9346..00000000 --- a/docs/source/api.rst +++ /dev/null @@ -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 \ No newline at end of file diff --git a/docs/source/basecamera.rst b/docs/source/basecamera.rst deleted file mode 100644 index a29e74a7..00000000 --- a/docs/source/basecamera.rst +++ /dev/null @@ -1,5 +0,0 @@ -Base Streaming Camera -======================================================= - -.. automodule:: openflexure_microscope.camera.base - :members: \ No newline at end of file diff --git a/docs/source/basestage.rst b/docs/source/basestage.rst deleted file mode 100644 index 6e0fed22..00000000 --- a/docs/source/basestage.rst +++ /dev/null @@ -1,5 +0,0 @@ -Base Microscope Stage -===================== - -.. automodule:: openflexure_microscope.stage.base - :members: \ No newline at end of file diff --git a/docs/source/camera.rst b/docs/source/camera.rst deleted file mode 100644 index af10f99d..00000000 --- a/docs/source/camera.rst +++ /dev/null @@ -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) \ No newline at end of file diff --git a/docs/source/capture.rst b/docs/source/capture.rst deleted file mode 100644 index a2d0846f..00000000 --- a/docs/source/capture.rst +++ /dev/null @@ -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: \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py deleted file mode 100644 index 9fcccac4..00000000 --- a/docs/source/conf.py +++ /dev/null @@ -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 diff --git a/docs/source/config.rst b/docs/source/config.rst deleted file mode 100644 index f2d8400d..00000000 --- a/docs/source/config.rst +++ /dev/null @@ -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: \ No newline at end of file diff --git a/docs/source/extensions/actions.rst b/docs/source/extensions/actions.rst deleted file mode 100644 index 481c66ed..00000000 --- a/docs/source/extensions/actions.rst +++ /dev/null @@ -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 diff --git a/docs/source/extensions/ev_gui.rst b/docs/source/extensions/ev_gui.rst deleted file mode 100644 index 79ca7a9d..00000000 --- a/docs/source/extensions/ev_gui.rst +++ /dev/null @@ -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 diff --git a/docs/source/extensions/example_extension/01_basic_structure.py b/docs/source/extensions/example_extension/01_basic_structure.py deleted file mode 100644 index 21301820..00000000 --- a/docs/source/extensions/example_extension/01_basic_structure.py +++ /dev/null @@ -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,) diff --git a/docs/source/extensions/example_extension/02_adding_views.py b/docs/source/extensions/example_extension/02_adding_views.py deleted file mode 100644 index 77b49aee..00000000 --- a/docs/source/extensions/example_extension/02_adding_views.py +++ /dev/null @@ -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,) diff --git a/docs/source/extensions/example_extension/03_marshaling_data.py b/docs/source/extensions/example_extension/03_marshaling_data.py deleted file mode 100644 index 6769874f..00000000 --- a/docs/source/extensions/example_extension/03_marshaling_data.py +++ /dev/null @@ -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,) diff --git a/docs/source/extensions/example_extension/04_properties.py b/docs/source/extensions/example_extension/04_properties.py deleted file mode 100644 index 1250b084..00000000 --- a/docs/source/extensions/example_extension/04_properties.py +++ /dev/null @@ -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,) diff --git a/docs/source/extensions/example_extension/05_actions.py b/docs/source/extensions/example_extension/05_actions.py deleted file mode 100644 index e1551efd..00000000 --- a/docs/source/extensions/example_extension/05_actions.py +++ /dev/null @@ -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,) diff --git a/docs/source/extensions/example_extension/06_tasks_locks.py b/docs/source/extensions/example_extension/06_tasks_locks.py deleted file mode 100644 index 25fb803f..00000000 --- a/docs/source/extensions/example_extension/06_tasks_locks.py +++ /dev/null @@ -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,) diff --git a/docs/source/extensions/example_extension/07_ev_gui.py b/docs/source/extensions/example_extension/07_ev_gui.py deleted file mode 100644 index 3f5f3196..00000000 --- a/docs/source/extensions/example_extension/07_ev_gui.py +++ /dev/null @@ -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,) diff --git a/docs/source/extensions/introduction.rst b/docs/source/extensions/introduction.rst deleted file mode 100644 index 464b83cd..00000000 --- a/docs/source/extensions/introduction.rst +++ /dev/null @@ -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 `_. 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. diff --git a/docs/source/extensions/lifecycle_hooks.rst b/docs/source/extensions/lifecycle_hooks.rst deleted file mode 100644 index 451618a4..00000000 --- a/docs/source/extensions/lifecycle_hooks.rst +++ /dev/null @@ -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 \ No newline at end of file diff --git a/docs/source/extensions/marshaling.rst b/docs/source/extensions/marshaling.rst deleted file mode 100644 index 2ed5e93a..00000000 --- a/docs/source/extensions/marshaling.rst +++ /dev/null @@ -1,171 +0,0 @@ -Marshaling data -=============== - -Introduction ------------- - -The OpenFlexure Microscope Server makes use of the `Marshmallow library `_ 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 `_, 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 \ No newline at end of file diff --git a/docs/source/extensions/properties.rst b/docs/source/extensions/properties.rst deleted file mode 100644 index fedc5055..00000000 --- a/docs/source/extensions/properties.rst +++ /dev/null @@ -1,114 +0,0 @@ -Thing Properties -================ - -Introduction ------------- - -As well as generating Swagger documentation, the server will generate a draft `W3C 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 diff --git a/docs/source/extensions/structure.rst b/docs/source/extensions/structure.rst deleted file mode 100644 index c05091f3..00000000 --- a/docs/source/extensions/structure.rst +++ /dev/null @@ -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() diff --git a/docs/source/extensions/tasks_locks.rst b/docs/source/extensions/tasks_locks.rst deleted file mode 100644 index 1f880271..00000000 --- a/docs/source/extensions/tasks_locks.rst +++ /dev/null @@ -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/``), and stopping or removing individual actions (DELETE ``/actions/``). - -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/`` 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. diff --git a/docs/source/extensions/views.rst b/docs/source/extensions/views.rst deleted file mode 100644 index ff2e5f57..00000000 --- a/docs/source/extensions/views.rst +++ /dev/null @@ -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 it’s 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 `_ - -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 `_ 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``. diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index d1d3a76d..00000000 --- a/docs/source/index.rst +++ /dev/null @@ -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` diff --git a/docs/source/microscope.rst b/docs/source/microscope.rst deleted file mode 100644 index 55a96cdb..00000000 --- a/docs/source/microscope.rst +++ /dev/null @@ -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: \ No newline at end of file diff --git a/docs/source/picamera.rst b/docs/source/picamera.rst deleted file mode 100644 index 4f7d8cdc..00000000 --- a/docs/source/picamera.rst +++ /dev/null @@ -1,5 +0,0 @@ -Raspberry Pi Streaming Camera -======================================================= - -.. automodule:: openflexure_microscope.camera.pi - :members: \ No newline at end of file diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst deleted file mode 100644 index bb865f26..00000000 --- a/docs/source/plugins.rst +++ /dev/null @@ -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 \ No newline at end of file diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst deleted file mode 100644 index a0f4ebd5..00000000 --- a/docs/source/quickstart.rst +++ /dev/null @@ -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. `_ 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 `_ 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 `_. - -This includes starting the server as a background service, as well as starting a development server with real-time debug logging. \ No newline at end of file diff --git a/docs/source/sangastage.rst b/docs/source/sangastage.rst deleted file mode 100644 index 941def8a..00000000 --- a/docs/source/sangastage.rst +++ /dev/null @@ -1,5 +0,0 @@ -Sangaboard Microscope Stage -=========================== - -.. automodule:: openflexure_microscope.stage.sanga - :members: \ No newline at end of file diff --git a/docs/source/stage.rst b/docs/source/stage.rst deleted file mode 100644 index dcf61e2f..00000000 --- a/docs/source/stage.rst +++ /dev/null @@ -1,8 +0,0 @@ -Stage Class -=========== - -.. toctree:: - :maxdepth: 2 - - sangastage.rst - basestage.rst diff --git a/docs/source/webapp/index.rst b/docs/source/webapp/index.rst deleted file mode 100644 index 7feb37fd..00000000 --- a/docs/source/webapp/index.rst +++ /dev/null @@ -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. - diff --git a/docs/source/webapp/pane_capture.png b/docs/source/webapp/pane_capture.png deleted file mode 100644 index 12aa7239..00000000 --- a/docs/source/webapp/pane_capture.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0dcbbfbc4d5ba77824083b497b015b4178ec47f375f8e0457cb103da5befd2cf -size 1971891 diff --git a/docs/source/webapp/pane_capture.rst b/docs/source/webapp/pane_capture.rst deleted file mode 100644 index dcbdbd42..00000000 --- a/docs/source/webapp/pane_capture.rst +++ /dev/null @@ -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`. \ No newline at end of file diff --git a/docs/source/webapp/pane_gallery.png b/docs/source/webapp/pane_gallery.png deleted file mode 100644 index d7f21d31..00000000 --- a/docs/source/webapp/pane_gallery.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4c5dc30ff335f782d9a5c9d5568e13787f63be7e5d0a46c73afd5d525e79d557 -size 1370769 diff --git a/docs/source/webapp/pane_gallery.rst b/docs/source/webapp/pane_gallery.rst deleted file mode 100644 index 415c360e..00000000 --- a/docs/source/webapp/pane_gallery.rst +++ /dev/null @@ -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. \ No newline at end of file diff --git a/docs/source/webapp/pane_navigate.png b/docs/source/webapp/pane_navigate.png deleted file mode 100644 index d8c816d2..00000000 --- a/docs/source/webapp/pane_navigate.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:400d4f25aef0fecddbab296d982fb881367f1c5b6cdbd83b592bbbd142f3ef04 -size 3184560 diff --git a/docs/source/webapp/pane_navigate.rst b/docs/source/webapp/pane_navigate.rst deleted file mode 100644 index d1f050bc..00000000 --- a/docs/source/webapp/pane_navigate.rst +++ /dev/null @@ -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 - - diff --git a/docs/source/webapp/pane_settings.png b/docs/source/webapp/pane_settings.png deleted file mode 100644 index 8bf0e7ce..00000000 --- a/docs/source/webapp/pane_settings.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ac21613d68c30c3e9038f71d738d41a266e6d99f3f93c9b92f833f1564ccd179 -size 668472 diff --git a/docs/source/webapp/pane_settings.rst b/docs/source/webapp/pane_settings.rst deleted file mode 100644 index f25ff83b..00000000 --- a/docs/source/webapp/pane_settings.rst +++ /dev/null @@ -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 \ No newline at end of file diff --git a/docs/source/webapp/pane_view.png b/docs/source/webapp/pane_view.png deleted file mode 100644 index 0d92f958..00000000 --- a/docs/source/webapp/pane_view.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:aca1cddcdd8038920748196f95307d8d0110ed702990dd4831b1566afe01dabd -size 1930201 diff --git a/docs/source/webapp/scan_dialog.png b/docs/source/webapp/scan_dialog.png deleted file mode 100644 index 06d74b02..00000000 --- a/docs/source/webapp/scan_dialog.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5bc4944c89be5f43b178145fc7ff8c414dac1716275dc83e02a4bc23493aa2fb -size 32284 diff --git a/openflexure_microscope/api/__init__.py b/openflexure_microscope/api/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py deleted file mode 100644 index ccd302bc..00000000 --- a/openflexure_microscope/api/app.py +++ /dev/null @@ -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/") -labthing.add_view(views.CaptureDownload, "/captures//download/") -labthing.add_view(views.CaptureTags, "/captures//tags") -labthing.add_view(views.CaptureAnnotations, "/captures//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/") -labthing.add_view(views.StateProperty, "/instrument/state") -labthing.add_view(views.NestedStateProperty, "/instrument/state/") -labthing.add_root_link(views.StateProperty, "instrumentState") -labthing.add_view(views.ConfigurationProperty, "/instrument/configuration") -labthing.add_view( - views.NestedConfigurationProperty, "/instrument/configuration/" -) -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/") -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() diff --git a/openflexure_microscope/api/default_extensions/__init__.py b/openflexure_microscope/api/default_extensions/__init__.py deleted file mode 100644 index 7dc4529c..00000000 --- a/openflexure_microscope/api/default_extensions/__init__.py +++ /dev/null @@ -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) diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py deleted file mode 100644 index 0f953c11..00000000 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ /dev/null @@ -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()} diff --git a/openflexure_microscope/api/default_extensions/autostorage.py b/openflexure_microscope/api/default_extensions/autostorage.py deleted file mode 100644 index aa67875d..00000000 --- a/openflexure_microscope/api/default_extensions/autostorage.py +++ /dev/null @@ -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 diff --git a/openflexure_microscope/api/default_extensions/camera_stage_mapping.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping.py deleted file mode 100644 index 532b431f..00000000 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping.py +++ /dev/null @@ -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 {} diff --git a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/__init__.py b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/__init__.py deleted file mode 100644 index fd920b86..00000000 --- a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .extension import LSTExtension diff --git a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py deleted file mode 100644 index 0049a83d..00000000 --- a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py +++ /dev/null @@ -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"]) diff --git a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py deleted file mode 100644 index 450584ed..00000000 --- a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py +++ /dev/null @@ -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 . - - 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) diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py deleted file mode 100644 index d3f83a50..00000000 --- a/openflexure_microscope/api/default_extensions/scan.py +++ /dev/null @@ -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" - ), - ) diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py deleted file mode 100644 index 077988fc..00000000 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ /dev/null @@ -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/", 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"}} diff --git a/openflexure_microscope/api/dev_extensions/__init__.py b/openflexure_microscope/api/dev_extensions/__init__.py deleted file mode 100644 index 0f098ecf..00000000 --- a/openflexure_microscope/api/dev_extensions/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .tools import DevToolsExtension - -LABTHINGS_EXTENSIONS = [DevToolsExtension] diff --git a/openflexure_microscope/api/dev_extensions/tools.py b/openflexure_microscope/api/dev_extensions/tools.py deleted file mode 100644 index 954b67e1..00000000 --- a/openflexure_microscope/api/dev_extensions/tools.py +++ /dev/null @@ -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)} diff --git a/openflexure_microscope/api/openapi.py b/openflexure_microscope/api/openapi.py deleted file mode 100644 index 006b518c..00000000 --- a/openflexure_microscope/api/openapi.py +++ /dev/null @@ -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) diff --git a/openflexure_microscope/api/utilities/__init__.py b/openflexure_microscope/api/utilities/__init__.py deleted file mode 100644 index c94c73f9..00000000 --- a/openflexure_microscope/api/utilities/__init__.py +++ /dev/null @@ -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 *" diff --git a/openflexure_microscope/api/utilities/gui.py b/openflexure_microscope/api/utilities/gui.py deleted file mode 100644 index b6d238c2..00000000 --- a/openflexure_microscope/api/utilities/gui.py +++ /dev/null @@ -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") diff --git a/openflexure_microscope/api/v2/__init__.py b/openflexure_microscope/api/v2/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openflexure_microscope/api/v2/views/__init__.py b/openflexure_microscope/api/v2/views/__init__.py deleted file mode 100644 index 5771e204..00000000 --- a/openflexure_microscope/api/v2/views/__init__.py +++ /dev/null @@ -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 * diff --git a/openflexure_microscope/api/v2/views/actions/__init__.py b/openflexure_microscope/api/v2/views/actions/__init__.py deleted file mode 100644 index 782f5342..00000000 --- a/openflexure_microscope/api/v2/views/actions/__init__.py +++ /dev/null @@ -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"]} diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py deleted file mode 100644 index c649d601..00000000 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ /dev/null @@ -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 diff --git a/openflexure_microscope/api/v2/views/actions/stage.py b/openflexure_microscope/api/v2/views/actions/stage.py deleted file mode 100644 index 20412a6f..00000000 --- a/openflexure_microscope/api/v2/views/actions/stage.py +++ /dev/null @@ -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"] diff --git a/openflexure_microscope/api/v2/views/actions/system.py b/openflexure_microscope/api/v2/views/actions/system.py deleted file mode 100644 index 246b0fec..00000000 --- a/openflexure_microscope/api/v2/views/actions/system.py +++ /dev/null @@ -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} diff --git a/openflexure_microscope/api/v2/views/camera.py b/openflexure_microscope/api/v2/views/camera.py deleted file mode 100644 index 9179ed10..00000000 --- a/openflexure_microscope/api/v2/views/camera.py +++ /dev/null @@ -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) diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py deleted file mode 100644 index 64fe536b..00000000 --- a/openflexure_microscope/api/v2/views/captures.py +++ /dev/null @@ -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 diff --git a/openflexure_microscope/api/v2/views/instrument.py b/openflexure_microscope/api/v2/views/instrument.py deleted file mode 100644 index 8bb2e37d..00000000 --- a/openflexure_microscope/api/v2/views/instrument.py +++ /dev/null @@ -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 diff --git a/openflexure_microscope/api/v2/views/stage.py b/openflexure_microscope/api/v2/views/stage.py deleted file mode 100644 index c6437a24..00000000 --- a/openflexure_microscope/api/v2/views/stage.py +++ /dev/null @@ -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"] diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py deleted file mode 100644 index 4f5ba9ff..00000000 --- a/openflexure_microscope/api/v2/views/streams.py +++ /dev/null @@ -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 `` \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") diff --git a/openflexure_microscope/camera/__init__.py b/openflexure_microscope/camera/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py deleted file mode 100644 index 12f51946..00000000 --- a/openflexure_microscope/camera/base.py +++ /dev/null @@ -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)) diff --git a/openflexure_microscope/camera/mock.py b/openflexure_microscope/camera/mock.py deleted file mode 100644 index 6b9b67ea..00000000 --- a/openflexure_microscope/camera/mock.py +++ /dev/null @@ -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() diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py deleted file mode 100644 index e46360fa..00000000 --- a/openflexure_microscope/camera/pi.py +++ /dev/null @@ -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 diff --git a/openflexure_microscope/camera/set_picamera_gain.py b/openflexure_microscope/camera/set_picamera_gain.py deleted file mode 100644 index cc952c94..00000000 --- a/openflexure_microscope/camera/set_picamera_gain.py +++ /dev/null @@ -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...") diff --git a/openflexure_microscope/captures/__init__.py b/openflexure_microscope/captures/__init__.py deleted file mode 100644 index c7d7b95f..00000000 --- a/openflexure_microscope/captures/__init__.py +++ /dev/null @@ -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", -] diff --git a/openflexure_microscope/captures/capture.py b/openflexure_microscope/captures/capture.py deleted file mode 100644 index 1cc6f05c..00000000 --- a/openflexure_microscope/captures/capture.py +++ /dev/null @@ -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 diff --git a/openflexure_microscope/captures/capture_manager.py b/openflexure_microscope/captures/capture_manager.py deleted file mode 100644 index d6d7e58a..00000000 --- a/openflexure_microscope/captures/capture_manager.py +++ /dev/null @@ -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 .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 diff --git a/openflexure_microscope/config.py b/openflexure_microscope/config.py deleted file mode 100644 index b2db8546..00000000 --- a/openflexure_microscope/config.py +++ /dev/null @@ -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}, - }, -) diff --git a/openflexure_microscope/devel/__init__.py b/openflexure_microscope/devel/__init__.py deleted file mode 100644 index fe8d387b..00000000 --- a/openflexure_microscope/devel/__init__.py +++ /dev/null @@ -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", -] diff --git a/openflexure_microscope/json.py b/openflexure_microscope/json.py deleted file mode 100644 index 56aba734..00000000 --- a/openflexure_microscope/json.py +++ /dev/null @@ -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) diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py deleted file mode 100644 index 83f8d591..00000000 --- a/openflexure_microscope/microscope.py +++ /dev/null @@ -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 diff --git a/openflexure_microscope/paths.py b/openflexure_microscope/paths.py deleted file mode 100644 index aab25c40..00000000 --- a/openflexure_microscope/paths.py +++ /dev/null @@ -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") diff --git a/openflexure_microscope/rescue/__init__.py b/openflexure_microscope/rescue/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openflexure_microscope/rescue/auto.py b/openflexure_microscope/rescue/auto.py deleted file mode 100644 index 9c195113..00000000 --- a/openflexure_microscope/rescue/auto.py +++ /dev/null @@ -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() diff --git a/openflexure_microscope/rescue/check_capture_reload.py b/openflexure_microscope/rescue/check_capture_reload.py deleted file mode 100644 index e50ae05b..00000000 --- a/openflexure_microscope/rescue/check_capture_reload.py +++ /dev/null @@ -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 diff --git a/openflexure_microscope/rescue/check_picamera.py b/openflexure_microscope/rescue/check_picamera.py deleted file mode 100644 index c9618f91..00000000 --- a/openflexure_microscope/rescue/check_picamera.py +++ /dev/null @@ -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 diff --git a/openflexure_microscope/rescue/check_sangaboard.py b/openflexure_microscope/rescue/check_sangaboard.py deleted file mode 100644 index e7b23b6b..00000000 --- a/openflexure_microscope/rescue/check_sangaboard.py +++ /dev/null @@ -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 diff --git a/openflexure_microscope/rescue/check_settings.py b/openflexure_microscope/rescue/check_settings.py deleted file mode 100644 index a2e185a6..00000000 --- a/openflexure_microscope/rescue/check_settings.py +++ /dev/null @@ -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 diff --git a/openflexure_microscope/rescue/check_system.py b/openflexure_microscope/rescue/check_system.py deleted file mode 100644 index 880ba87c..00000000 --- a/openflexure_microscope/rescue/check_system.py +++ /dev/null @@ -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 diff --git a/openflexure_microscope/rescue/error_sources.py b/openflexure_microscope/rescue/error_sources.py deleted file mode 100644 index 67910701..00000000 --- a/openflexure_microscope/rescue/error_sources.py +++ /dev/null @@ -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 diff --git a/openflexure_microscope/rescue/monitor_service.py b/openflexure_microscope/rescue/monitor_service.py deleted file mode 100644 index 6090ed7e..00000000 --- a/openflexure_microscope/rescue/monitor_service.py +++ /dev/null @@ -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 -# -# 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 diff --git a/openflexure_microscope/rescue/monitor_timeout.py b/openflexure_microscope/rescue/monitor_timeout.py deleted file mode 100644 index 49af1169..00000000 --- a/openflexure_microscope/rescue/monitor_timeout.py +++ /dev/null @@ -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) diff --git a/openflexure_microscope/stage/__init__.py b/openflexure_microscope/stage/__init__.py deleted file mode 100644 index b159fe5f..00000000 --- a/openflexure_microscope/stage/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -__all__ = ["base", "mock", "sanga"] - -from . import base, mock, sanga diff --git a/openflexure_microscope/stage/base.py b/openflexure_microscope/stage/base.py deleted file mode 100644 index 026ac642..00000000 --- a/openflexure_microscope/stage/base.py +++ /dev/null @@ -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) diff --git a/openflexure_microscope/stage/mock.py b/openflexure_microscope/stage/mock.py deleted file mode 100644 index 4268224c..00000000 --- a/openflexure_microscope/stage/mock.py +++ /dev/null @@ -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 diff --git a/openflexure_microscope/stage/sanga.py b/openflexure_microscope/stage/sanga.py deleted file mode 100644 index 28d13dda..00000000 --- a/openflexure_microscope/stage/sanga.py +++ /dev/null @@ -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) diff --git a/openflexure_microscope/utilities.py b/openflexure_microscope/utilities.py deleted file mode 100644 index 6f1deb16..00000000 --- a/openflexure_microscope/utilities.py +++ /dev/null @@ -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 diff --git a/pyproject.toml b/pyproject.toml index d5e21633..871dc5eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] -requires = ["setuptools", "wheel"] +requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" # The [tool.poetry] metadata has been removed from this file, because @@ -34,6 +68,12 @@ line_length = 88 disable = "fixme,C,R,W1203" max-line-length = 88 +[tool.mypy] +plugins = ["pydantic.mypy"] + +[tool.ruff] +target-version = "py39" + [tool.poe.executor] type = "virtualenv" location = ".venv" @@ -41,13 +81,15 @@ location = ".venv" [tool.poe.tasks] black = "black ." black_check = "black --check ." +ruff = "ruff . --fix" +ruff_check = "ruff ." isort = "isort openflexure_microscope" pylint = "pylint openflexure_microscope" mypy = "mypy --cobertura-xml-report openflexure_microscope openflexure_microscope" test = "pytest . --junitxml=pytest_report.xml" serve = "python -m openflexure_microscope.api.app" -format = ["black", "isort"] -lint = ["pylint", "mypy"] +format = ["black", "isort", "ruff"] +lint = ["ruff_check", "pylint", "mypy"] check = ["format", "lint", "test"] \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index d1ec7659..00000000 --- a/setup.py +++ /dev/null @@ -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" - }, -) diff --git a/openflexure_microscope/__init__.py b/src/openflexure_microscope_server/__init__.py similarity index 100% rename from openflexure_microscope/__init__.py rename to src/openflexure_microscope_server/__init__.py diff --git a/src/openflexure_microscope_server/server.py b/src/openflexure_microscope_server/server.py new file mode 100644 index 00000000..6692e975 --- /dev/null +++ b/src/openflexure_microscope_server/server.py @@ -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 \ No newline at end of file