diff --git a/README.md b/README.md index fce94990..c18cdd85 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,15 @@ This includes installing the server in a mode better suited for active developme # Developer guidelines +The Raspberry Pi image we use currently ships with Python 3.7.3. For local development, please use PyEnv or similar to make sure you're running on this version. For example, Windows users can use [Scoop](https://scoop.sh/) to install specific Python versions. ## Installation * `git clone https://gitlab.com/openflexure/openflexure-microscope-server.git` +* `cd openflexure-microscope-server` +* (Optional) Set local Python version + * `pyenv init` + * `pyenv install 3.7.3` + * `pyenv local 3.7.3` * `poetry install` * Building the static interface will require a valid Node.js installation * To build on a Raspberry Pi: diff --git a/docs/source/extensions/example_extension/01_basic_structure.py b/docs/source/extensions/example_extension/01_basic_structure.py index eec8f362..21301820 100644 --- a/docs/source/extensions/example_extension/01_basic_structure.py +++ b/docs/source/extensions/example_extension/01_basic_structure.py @@ -2,35 +2,35 @@ from labthings import find_component from labthings.extensions import BaseExtension -def identify(): - """ - Demonstrate access to Microscope.camera, and Microscope.stage - """ - microscope = find_component("org.openflexure.microscope") +# Create the extension class +class MyExtension(BaseExtension): + def __init__(self): + # Superclass init function + super().__init__("com.myname.myextension", version="0.0.0") - response = ( - f"My name is {microscope.name}. " - f"My parent camera is {microscope.camera}, " - f"and my parent stage is {microscope.stage}." - ) + def identify(self): + """ + Demonstrate access to Microscope.camera, and Microscope.stage + """ + microscope = find_component("org.openflexure.microscope") - return response + 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() -def rename(new_name): - """ - Rename the microscope - """ - - microscope = find_component("org.openflexure.microscope") - - microscope.name = new_name - microscope.save_settings() - - -# Create your extension object -my_extension = BaseExtension("com.myname.myextension", version="0.0.0") - -# Add methods to your extension -my_extension.add_method(identify, "identify") -my_extension.add_method(rename, "rename") +LABTHINGS_EXTENSIONS = (MyExtension,) diff --git a/docs/source/extensions/example_extension/01b_basic_structure_subclass.py b/docs/source/extensions/example_extension/01b_basic_structure_subclass.py deleted file mode 100644 index 9d1b93f3..00000000 --- a/docs/source/extensions/example_extension/01b_basic_structure_subclass.py +++ /dev/null @@ -1,41 +0,0 @@ -from labthings import find_component -from labthings.extensions import BaseExtension - - -# Create the extension class -class MyExtension(BaseExtension): - def __init__(self): - - # Create some instance variable - self.state_variable = "An example of a persistant instance variable" - - # Superclass init function - super().__init__("com.myname.myextension", version="0.0.0") - - def identify(self): - """ - Demonstrate access to Microscope.camera, and Microscope.stage - """ - microscope = find_component("org.openflexure.microscope") - - response = ( - f"My name is {microscope.name}. " - f"My parent camera is {microscope.camera}, " - f"and my parent stage is {microscope.stage}." - ) - - return response - - def rename(self, new_name): - """ - Rename the microscope - """ - - microscope = find_component("org.openflexure.microscope") - - microscope.name = new_name - microscope.save_settings() - - -# Create your extension object -my_extension = MyExtension() diff --git a/docs/source/extensions/example_extension/02_adding_views.py b/docs/source/extensions/example_extension/02_adding_views.py index d22a9ae2..be2bb820 100644 --- a/docs/source/extensions/example_extension/02_adding_views.py +++ b/docs/source/extensions/example_extension/02_adding_views.py @@ -2,42 +2,45 @@ from labthings import fields, find_component from labthings.extensions import BaseExtension from labthings.views import View -## Extension methods +# Create the extension class +class MyExtension(BaseExtension): + def __init__(self): + # Superclass init function + super().__init__("com.myname.myextension", version="0.0.0") -def identify(microscope): - """ - Demonstrate access to Microscope.camera, and Microscope.stage - """ + # Add our API Views (defined below MyExtension) + self.add_view(ExampleIdentifyView, "/identify") + self.add_view(ExampleRenameView, "/rename") - response = ( - f"My name is {microscope.name}. " - f"My parent camera is {microscope.camera}, " - f"and my parent stage is {microscope.stage}." - ) + 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 + return response - -def rename(microscope, new_name): - """ - Rename the microscope - """ - - microscope.name = new_name - microscope.save_settings() + 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 identify(microscope) + return self.extension.identify(microscope) class ExampleRenameView(View): @@ -53,21 +56,10 @@ class ExampleRenameView(View): microscope = find_component("org.openflexure.microscope") # Pass microscope and new name to our rename function - rename(microscope, new_name) + self.extension.rename(microscope, new_name) # Return our identify function's output - return identify(microscope) + return self.extension.identify(microscope) -## Create extension - -# Create your extension object -my_extension = BaseExtension("com.myname.myextension", version="0.0.0") - -# Add methods to your extension -my_extension.add_method(identify, "identify") -my_extension.add_method(rename, "rename") - -# Add API views to your extension -my_extension.add_view(ExampleIdentifyView, "/identify") -my_extension.add_view(ExampleRenameView, "/rename") +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 index aceefcea..2bf270fe 100644 --- a/docs/source/extensions/example_extension/03_marshaling_data.py +++ b/docs/source/extensions/example_extension/03_marshaling_data.py @@ -2,7 +2,23 @@ from labthings import Schema, fields, find_component from labthings.extensions import BaseExtension from labthings.views import View -## Extension methods + +# 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, @@ -15,18 +31,7 @@ class MicroscopeIdentifySchema(Schema): stage = fields.String() # Stage object (represented as a string) -def rename(microscope, new_name): - """ - Rename the microscope - """ - - microscope.name = new_name - microscope.save_settings() - - ## Extension views - - class ExampleIdentifyView(View): # Format our returned object using MicroscopeIdentifySchema schema = MicroscopeIdentifySchema() @@ -54,21 +59,11 @@ class ExampleRenameView(View): microscope = find_component("org.openflexure.microscope") # Pass microscope and new name to our rename function - rename(microscope, new_name) + self.extension.rename(microscope, new_name) # Return our microscope object, # let schema handle formatting the output return microscope -## Create extension - -# Create your extension object -my_extension = BaseExtension("com.myname.myextension", version="0.0.0") - -# Add methods to your extension -my_extension.add_method(rename, "rename") - -# Add API views to your extension -my_extension.add_view(ExampleIdentifyView, "/identify") -my_extension.add_view(ExampleRenameView, "/rename") +LABTHINGS_EXTENSIONS = (MyExtension,) diff --git a/docs/source/extensions/example_extension/04_properties.py b/docs/source/extensions/example_extension/04_properties.py index 3bedbad0..6df7069d 100644 --- a/docs/source/extensions/example_extension/04_properties.py +++ b/docs/source/extensions/example_extension/04_properties.py @@ -2,7 +2,23 @@ from labthings import Schema, fields, find_component from labthings.extensions import BaseExtension from labthings.views import PropertyView -## Extension methods + +# 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, @@ -15,16 +31,7 @@ class MicroscopeIdentifySchema(Schema): stage = fields.String() # Stage object (represented as a string) -def rename(microscope, new_name): - """ - Rename the microscope - """ - - microscope.name = new_name - microscope.save_settings() - - -## Extension views +## Extension viewss # Since we only have a GET method here, it'll register as a read-only property class ExampleIdentifyView(PropertyView): @@ -68,21 +75,11 @@ class ExampleRenameView(PropertyView): microscope = find_component("org.openflexure.microscope") # Pass microscope and new name to our rename function - rename(microscope, new_name) + self.extension.rename(microscope, new_name) # Return our microscope object, # let schema handle formatting the output return microscope -## Create extension - -# Create your extension object -my_extension = BaseExtension("com.myname.myextension", version="0.0.0") - -# Add methods to your extension -my_extension.add_method(rename, "rename") - -# Add API views to your extension -my_extension.add_view(ExampleIdentifyView, "/identify") -my_extension.add_view(ExampleRenameView, "/rename") +LABTHINGS_EXTENSIONS = (MyExtension,) diff --git a/docs/source/extensions/example_extension/05_actions.py b/docs/source/extensions/example_extension/05_actions.py index 52b1b024..0ac21880 100644 --- a/docs/source/extensions/example_extension/05_actions.py +++ b/docs/source/extensions/example_extension/05_actions.py @@ -5,7 +5,23 @@ from labthings import Schema, fields, find_component from labthings.extensions import BaseExtension from labthings.views import ActionView, PropertyView -## Extension methods + +# 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, @@ -18,15 +34,6 @@ class MicroscopeIdentifySchema(Schema): stage = fields.String() # Stage object (represented as a string) -def rename(microscope, new_name): - """ - Rename the microscope - """ - - microscope.name = new_name - microscope.save_settings() - - ## Extension views # Since we only have a GET method here, it'll register as a read-only property @@ -71,7 +78,7 @@ class ExampleRenameView(PropertyView): microscope = find_component("org.openflexure.microscope") # Pass microscope and new name to our rename function - rename(microscope, new_name) + self.extension.rename(microscope, new_name) # Return our microscope object, # let schema handle formatting the output @@ -109,15 +116,4 @@ class QuickCaptureAPI(ActionView): return send_file(io.BytesIO(stream.read()), mimetype="image/jpeg") -## Create extension - -# Create your extension object -my_extension = BaseExtension("com.myname.myextension", version="0.0.0") - -# Add methods to your extension -my_extension.add_method(rename, "rename") - -# Add API views to your extension -my_extension.add_view(ExampleIdentifyView, "/identify") -my_extension.add_view(ExampleRenameView, "/rename") -my_extension.add_view(QuickCaptureAPI, "/quick-capture") +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 index 710b4e73..79f9b1ef 100644 --- a/docs/source/extensions/example_extension/06_tasks_locks.py +++ b/docs/source/extensions/example_extension/06_tasks_locks.py @@ -7,52 +7,59 @@ from labthings.views import ActionView # Used in our timelapse function from openflexure_microscope.captures.capture_manager import generate_basename -## Extension methods +# Create the extension class +class TimelapseExtension(BaseExtension): + def __init__(self): + # Superclass init function + super().__init__("org.openflexure.timelapse-extension", version="0.0.0") -def timelapse(microscope, n_images, t_between): - """ - Save a set of images in a timelapse + # Add our API views + self.add_view(TimelapseAPIView, "/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) + def timelapse(self, microscope, n_images, t_between): + """ + Save a set of images in a timelapse - # 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 - ) + 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) - # Capture - microscope.camera.capture(output) + # 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 + ) - # Add system metadata - output.put_metadata(microscope.metadata, system=True) + # Capture + microscope.camera.capture(output) - # 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) + # Add system metadata + output.put_metadata(microscope.metadata, system=True) - # Wait for the specified time - time.sleep(t_between) + # Update task progress (only does anyting if the function is running in a LabThings task) + progress_pct = ((n + 1) / n_images) * 100 # Progress, in percent + update_action_progress(progress_pct) + + # Wait for the specified time + time.sleep(t_between) ## Extension views -class TimelapseAPI(ActionView): +class TimelapseAPIView(ActionView): """ Take a series of images in a timelapse """ @@ -71,16 +78,9 @@ class TimelapseAPI(ActionView): microscope = find_component("org.openflexure.microscope") # Start "timelapse" - return timelapse(microscope, args.get("n_images"), args.get("t_between")) + return self.extension.timelapse( + microscope, args.get("n_images"), args.get("t_between") + ) -## Create extension - -# Create your extension object -my_extension = BaseExtension("com.myname.timelapse-extension", version="0.0.0") - -# Add methods to your extension -my_extension.add_method(timelapse, "timelapse") - -# Add API views to your extension -my_extension.add_view(TimelapseAPI, "/timelapse") +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 index a71d9053..42b40751 100644 --- a/docs/source/extensions/example_extension/07_ev_gui.py +++ b/docs/source/extensions/example_extension/07_ev_gui.py @@ -10,54 +10,91 @@ from openflexure_microscope.api.utilities.gui import build_gui # Used in our timelapse function from openflexure_microscope.captures.capture_manager import generate_basename -## Extension methods +# Create the extension class +class TimelapseExtension(BaseExtension): + def __init__(self): + # Superclass init function + super().__init__("org.openflexure.timelapse-extension", version="0.0.0") -def timelapse(microscope, n_images, t_between): - """ - Save a set of images in a timelapse + # Add our API views + self.add_view(TimelapseAPIView, "/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) + # 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)) - # 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 - ) + def timelapse(self, microscope, n_images, t_between): + """ + Save a set of images in a timelapse - # Capture - microscope.camera.capture(output) + 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) - # Add system metadata - output.put_metadata(microscope.metadata, system=True) + # 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 + ) - # 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) + # Capture + microscope.camera.capture(output) - # Wait for the specified time - time.sleep(t_between) + # Add system metadata + output.put_metadata(microscope.metadata, system=True) + + # Update task progress (only does anyting if the function is running in a LabThings task) + progress_pct = ((n + 1) / n_images) * 100 # Progress, in percent + update_action_progress(progress_pct) + + # Wait for the specified time + time.sleep(t_between) ## Extension views - - -class TimelapseAPI(ActionView): +class TimelapseAPIView(ActionView): """ - Take a series of images in a timelapse, running as a background task + Take a series of images in a timelapse """ args = { @@ -73,53 +110,10 @@ class TimelapseAPI(ActionView): # Find our microscope component microscope = find_component("org.openflexure.microscope") - # Create and start "timelapse", running in a background task - return timelapse(microscope, args.get("n_images"), args.get("t_between")) + # Start "timelapse" + return self.extension.timelapse( + microscope, args.get("n_images"), args.get("t_between") + ) -## Extension GUI (OpenFlexure eV) -# Alternate form without any dynamic parts -extension_gui = { - "icon": "timelapse", # Name of an icon from https://material.io/resources/icons/ - "forms": [ # List of forms. Each form is a collapsible accordion panel - { - "name": "Start a timelapse", # Form title - "route": "/timelapse", # The URL rule (as given by "add_view") of your submission view - "isTask": True, # This forms submission starts a background task - "isCollapsible": False, # This form cannot be collapsed into an accordion - "submitLabel": "Start", # Label for the form submit button - "schema": [ # List of dictionaries. Each element is a form component. - { - "fieldType": "numberInput", - "name": "n_images", # Name of the view arg this value corresponds to - "label": "Number of images", - "min": 1, # HTML number input attribute - "default": 5, # HTML number input attribute - }, - { - "fieldType": "numberInput", - "name": "t_between", - "label": "Time (seconds) between images", - "min": 0.1, # HTML number input attribute - "step": 0.1, # HTML number input attribute - "default": 1, # HTML number input attribute - }, - ], - } - ], -} - - -## Create extension - -# Create your extension object -my_extension = BaseExtension("com.myname.timelapse-extension", version="0.0.0") - -# Add methods to your extension -my_extension.add_method(timelapse, "timelapse") - -# Add API views to your extension -my_extension.add_view(TimelapseAPI, "/timelapse") - -# Add OpenFlexure eV GUI to your extension -my_extension.add_meta("gui", build_gui(extension_gui, my_extension)) +LABTHINGS_EXTENSIONS = (TimelapseExtension,) diff --git a/docs/source/extensions/introduction.rst b/docs/source/extensions/introduction.rst index 265cf94d..464b83cd 100644 --- a/docs/source/extensions/introduction.rst +++ b/docs/source/extensions/introduction.rst @@ -16,6 +16,15 @@ 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 objects. +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/structure.rst b/docs/source/extensions/structure.rst index ee3ee0ce..c05091f3 100644 --- a/docs/source/extensions/structure.rst +++ b/docs/source/extensions/structure.rst @@ -1,9 +1,11 @@ Basic extension structure ========================= -An extension starts as a simple instance of :py:class:`labthings.extensions.BaseExtension`. +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: @@ -24,13 +26,3 @@ Once this extension is loaded, any other extensions will have access to your met # Call a function from your extension if my_found_extension: my_found_extension.identify() - - -Subclassing ``BaseExtension`` -------------------------------- - -The syntax used above allows novice programmers to easily start building extensions, without having to deal with subclassing. However, for more complex extensions which require persistent state, subclassing :py:class:`labthings.extensions.BaseExtension` is recommended. - -The same simple extension as seen above can be written using subclassing: - -.. literalinclude:: ./example_extension/01b_basic_structure_subclass.py diff --git a/docs/source/extensions/views.rst b/docs/source/extensions/views.rst index be8364fb..ff2e5f57 100644 --- a/docs/source/extensions/views.rst +++ b/docs/source/extensions/views.rst @@ -1,6 +1,14 @@ 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. @@ -13,6 +21,8 @@ Continuing our example on the previous page, and discussed below, adding API vie 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 diff --git a/openflexure_microscope/api/default_extensions/__init__.py b/openflexure_microscope/api/default_extensions/__init__.py index 561239af..7dc4529c 100644 --- a/openflexure_microscope/api/default_extensions/__init__.py +++ b/openflexure_microscope/api/default_extensions/__init__.py @@ -1,6 +1,11 @@ import logging import traceback from contextlib import contextmanager +from typing import List, Type + +from labthings.extensions import BaseExtension + +LABTHINGS_EXTENSIONS: List[Type[BaseExtension]] = [] @contextmanager @@ -17,14 +22,28 @@ def handle_extension_error(extension_name): with handle_extension_error("autofocus"): - from .autofocus import autofocus_extension_v2 + from .autofocus import AutofocusExtension + + LABTHINGS_EXTENSIONS.append(AutofocusExtension) with handle_extension_error("scan"): - from .scan import scan_extension_v2 + from .scan import ScanExtension + + LABTHINGS_EXTENSIONS.append(ScanExtension) with handle_extension_error("zip builder"): - from .zip_builder import zip_extension_v2 + from .zip_builder import ZipBuilderExtension + + LABTHINGS_EXTENSIONS.append(ZipBuilderExtension) with handle_extension_error("autostorage"): - from .autostorage import autostorage_extension_v2 -with handle_extension_error("camera stage mapping"): - from camera_stage_mapping.ofm_extension import csm_extension + from .autostorage import AutostorageExtension + + LABTHINGS_EXTENSIONS.append(AutostorageExtension) + with handle_extension_error("lens shading calibration"): - from .picamera_autocalibrate import lst_extension_v2 + 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 index 4ed1a672..339291ca 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -1,7 +1,7 @@ import logging import time from contextlib import contextmanager -from typing import Callable, List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple import numpy as np from labthings import current_action, fields, find_component @@ -39,7 +39,7 @@ class JPEGSharpnessMonitor: self.camera.stream.stop_tracking() self.camera.stream.reset_tracking() - def focus_rel(self, dz: int, backlash: bool = False, **kwargs): + 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()) @@ -62,22 +62,28 @@ class JPEGSharpnessMonitor: self.camera.stream.reset_tracking() # Index of the data for this movement - data_index = len(self.stage_positions) - 2 + data_index: int = len(self.stage_positions) - 2 # Final z position after move - final_z_position = self.stage_positions[-1][2] + 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): + 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.array(self.jpeg_times) - jpeg_sizes = np.array(self.jpeg_sizes) - stage_times = np.array(self.stage_times)[istart:istop] - stage_zs = np.array(self.stage_positions)[istart:istop, 2] + 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 = np.argmax(jpeg_times > stage_times[0]) - stop = np.argmax(jpeg_times > stage_times[1]) + 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( @@ -89,10 +95,12 @@ class JPEGSharpnessMonitor: stop = len(jpeg_times) logging.debug("changing stop to %s", (stop)) jpeg_times = jpeg_times[start:stop] - jpeg_zs = np.interp(jpeg_times, stage_times, stage_zs) + 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): + 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: @@ -101,7 +109,7 @@ class JPEGSharpnessMonitor: ) return jz[np.argmax(js)] - def data_dict(self): + 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"]: @@ -109,18 +117,28 @@ class JPEGSharpnessMonitor: return data -def sharpness_sum_lap2(rgb_image: np.ndarray): +@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) -> np.float: """Return an image sharpness metric: sum(laplacian(image)**")""" - image_bw = np.mean(rgb_image, 2) - image_lap = ndimage.filters.laplace(image_bw) + image_bw: np.float = np.mean(rgb_image, 2) + image_lap: np.float = ndimage.filters.laplace(image_bw) return np.mean(image_lap.astype(np.float) ** 4) -def sharpness_edge(image: np.ndarray): +def sharpness_edge(image: np.ndarray) -> np.float: """Return a sharpness metric optimised for vertical lines""" - gray = np.mean(image.astype(float), 2) - n = 20 - edge = np.array([[-1] * n + [1] * n]) + gray: np.float = np.mean(image.astype(float), 2) + n: int = 20 + edge: np.ndarray = np.array([[-1] * n + [1] * n]) return np.sum( [np.sum(ndimage.filters.convolve(gray, W) ** 2) for W in [edge, edge.T]] ) @@ -129,184 +147,203 @@ def sharpness_edge(image: np.ndarray): ### Autofocus extension -def measure_sharpness(microscope: Microscope, metric_fn: Callable = sharpness_sum_lap2): - """Measure the sharpness of the camera's current view.""" +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_view(AutofocusAPI, "/autofocus", endpoint="autofocus") + self.add_view(FastAutofocusAPI, "/fast_autofocus", endpoint="fast_autofocus") + self.add_view( + UpDownUpAutofocusAPI, "/updownup_autofocus", endpoint="updownup_autofocus" + ) - 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`") + self.add_view( + MoveAndMeasureAPI, "/move_and_measure", endpoint="move_and_measure" + ) + def measure_sharpness( + self, microscope: Microscope, metric_fn: Callable = sharpness_sum_lap2 + ) -> np.float: + """Measure the sharpness of the camera's current view.""" -def autofocus( - microscope: Microscope, - dz: List[int], - settle: float = 0.5, - metric_fn: Callable = sharpness_sum_lap2, -): - """Perform a simple autofocus routine. - The stage is moved to z positions (relative to current position) in dz, - and at each position an image is captured and the sharpness function - evaulated. We then move back to the position where the sharpness was - highest. No interpolation is performed. - dz is assumed to be in ascending order (starting at -ve values) - """ - camera = microscope.camera - stage = microscope.stage + 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`") - with set_properties(stage, backlash=256), stage.lock, camera.lock: - sharpnesses = [] - positions = [] + def autofocus( + self, + microscope: Microscope, + dz: List[int], + settle: float = 0.5, + metric_fn: Callable = sharpness_sum_lap2, + ) -> Tuple[List[int], List[np.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) + """ + camera: BaseCamera = microscope.camera + stage: BaseStage = microscope.stage - # Some cameras may not have annotate_text. Reset if it does - if getattr(camera, "annotate_text"): - setattr(camera, "annotate_text", "") + with set_properties(stage, backlash=256), stage.lock, camera.lock: + sharpnesses: List[np.float] = [] + positions: List[int] = [] - for _ in stage.scan_z(dz, return_to_start=False): - if current_action() and current_action().stopped: - return - positions.append(stage.position[2]) - time.sleep(settle) - sharpnesses.append(measure_sharpness(microscope, metric_fn)) + # Some cameras may not have annotate_text. Reset if it does + if getattr(camera, "annotate_text", None): + setattr(camera, "annotate_text", "") - newposition = positions[np.argmax(sharpnesses)] - stage.move_rel((0, 0, newposition - stage.position[2])) + 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)) - return positions, sharpnesses + newposition: int = positions[int(np.argmax(sharpnesses))] + stage.move_rel((0, 0, newposition - stage.position[2])) + return positions, sharpnesses -@contextmanager -def monitor_sharpness(microscope: Microscope): - m = JPEGSharpnessMonitor(microscope) - m.start() - try: - yield m - finally: - m.stop() - - -def move_and_find_focus(microscope: Microscope, dz: int): - """Make a relative Z move and return the peak sharpness position""" - with monitor_sharpness(microscope) as m: - m.focus_rel(dz) - return m.sharpest_z_on_move(0) - - -def move_and_measure(microscope: Microscope, dz: int): - """Make a relative Z move and return the sharpness data""" - with monitor_sharpness(microscope) as m: - m.focus_rel(dz) - return m.data_dict() - - -def fast_autofocus(microscope: Microscope, dz: int = 2000): - """Perform a down-up-down-up autofocus""" - with microscope.camera.lock, microscope.stage.lock: + def move_and_find_focus(self, microscope: Microscope, dz: int) -> int: + """Make a relative Z move and return the peak sharpness position""" 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 = 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 + m.focus_rel(dz) + return m.sharpest_z_on_move(0) + + def move_and_measure( + self, microscope: Microscope, dz: int + ) -> Dict[str, np.ndarray]: + """Make a relative Z move and return the sharpness data""" + with monitor_sharpness(microscope) as m: + m.focus_rel(dz) return m.data_dict() + def fast_autofocus( + self, microscope: Microscope, dz: int = 2000 + ) -> Dict[str, np.ndarray]: + """Perform a down-up-down-up autofocus""" + with 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() -def fast_up_down_up_autofocus( - microscope: Microscope, - dz: int = 2000, - target_z: int = 0, - initial_move_up: bool = True, - mini_backlash: int = 25, -): - """Autofocus by measuring on the way down, and moving back up with feedback. + def fast_up_down_up_autofocus( + self, + microscope: Microscope, + dz: int = 2000, + target_z: int = 0, + initial_move_up: bool = True, + mini_backlash: int = 25, + ) -> Dict[str, np.ndarray]: + """Autofocus by measuring on the way down, and moving back up with feedback. - This autofocus method is very efficient, as it only passes the peak once. - The sequence of moves it performs is: + 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) + 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. + 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. + 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. + 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. + Moving back to the target position in two steps allows us to correct for + backlash, by using the sharpness-vs-z curve as a rough encoder for Z. - Parameters: - dz: number of steps over which to scan (optional, default 2000) + 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. + 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. + initial_move_up: (optional, default True) set this to `False` to move down + from the starting position. Mostly useful if you're able to combine + the initial move with something else, e.g. moving to the next scan point. - mini_backlash: (optional, default 25) is a small extra move made in step - 3 to help counteract backlash. It should be small enough that you - would always expect there to be greater backlash than this. Too small - might slightly hurt accuracy, but is unlikely to be a big issue. Too big - may cause you to overshoot, which is a problem. - """ - with microscope.camera.lock, microscope.stage.lock: - with monitor_sharpness(microscope) as m: - # Ensure the MJPEG stream has started - microscope.camera.start_stream() + mini_backlash: (optional, default 25) is a small extra move made in step + 3 to help counteract backlash. It should be small enough that you + would always expect there to be greater backlash than this. Too small + might slightly hurt accuracy, but is unlikely to be a big issue. Too big + may cause you to overshoot, which is a problem. + """ + with 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, 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, js = m.move_data(i) - best_z = jz[np.argmax(js)] + 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 + # 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 = np.argmax(js) # we want to crop out just the bit below the peak - js = js[imax:] # NB z is in DECREASING order - jz = jz[imax:] - inow = np.argmax( - js < current_js - ) # use the curve we recorded to estimate our position + # 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 = 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() + # 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): @@ -316,7 +353,7 @@ class MeasureSharpnessAPI(View): if not microscope: abort(503, "No microscope connected. Unable to measure sharpness.") - return {"sharpness": measure_sharpness(microscope)} + return {"sharpness": self.extension.measure_sharpness(microscope)} class MoveAndMeasureAPI(ActionView): @@ -335,7 +372,7 @@ class MoveAndMeasureAPI(ActionView): if microscope.has_real_stage(): # Acquire microscope lock with 1s timeout with microscope.camera.lock, microscope.stage.lock: - return move_and_measure(microscope, dz=args.get("dz")) + return self.extension.move_and_measure(microscope, dz=args.get("dz")) else: abort(503, "No stage connected. Unable to autofocus.") @@ -361,7 +398,7 @@ class AutofocusAPI(ActionView): logging.debug("Running autofocus...") # return a handle on the autofocus task - return autofocus(microscope, dz) + return self.extension.autofocus(microscope, dz) else: abort(503, "No stage connected. Unable to autofocus.") @@ -385,7 +422,7 @@ class FastAutofocusAPI(ActionView): # Acquire microscope lock with 1s timeout with microscope.lock(timeout=1): # Run fast_autofocus - return fast_autofocus(microscope, dz=args.get("dz")) + return self.extension.fast_autofocus(microscope, dz=args.get("dz")) else: abort(503, "No stage connected. Unable to autofocus.") @@ -417,38 +454,9 @@ class UpDownUpAutofocusAPI(ActionView): # Acquire microscope lock with 1s timeout with microscope.lock(timeout=1): # Run fast_up_down_up_autofocus - return fast_up_down_up_autofocus( + return self.extension.fast_up_down_up_autofocus( microscope, dz=dz, mini_backlash=backlash ) else: abort(503, "No stage connected. Unable to autofocus.") - - -autofocus_extension_v2 = BaseExtension( - "org.openflexure.autofocus", - version="2.0.0", - description="Actions to move the microscope in Z and pick the point with the sharpest image.", -) - -autofocus_extension_v2.add_method(fast_autofocus, "fast_autofocus") -autofocus_extension_v2.add_method( - fast_up_down_up_autofocus, "fast_up_down_up_autofocus" -) -autofocus_extension_v2.add_method(autofocus, "autofocus") -autofocus_extension_v2.add_method(move_and_measure, "move_and_measure") - -autofocus_extension_v2.add_view( - MeasureSharpnessAPI, "/measure_sharpness", endpoint="measure_sharpness" -) -autofocus_extension_v2.add_view(AutofocusAPI, "/autofocus", endpoint="autofocus") -autofocus_extension_v2.add_view( - FastAutofocusAPI, "/fast_autofocus", endpoint="fast_autofocus" -) -autofocus_extension_v2.add_view( - UpDownUpAutofocusAPI, "/updownup_autofocus", endpoint="updownup_autofocus" -) - -autofocus_extension_v2.add_view( - MoveAndMeasureAPI, "/move_and_measure", endpoint="move_and_measure" -) diff --git a/openflexure_microscope/api/default_extensions/autostorage.py b/openflexure_microscope/api/default_extensions/autostorage.py index 7d97d766..ab32bf44 100644 --- a/openflexure_microscope/api/default_extensions/autostorage.py +++ b/openflexure_microscope/api/default_extensions/autostorage.py @@ -93,12 +93,43 @@ class AutostorageExtension(BaseExtension): # 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) @@ -204,21 +235,18 @@ class AutostorageExtension(BaseExtension): return self.key_to_title(self.get_preferred_key()) -autostorage_extension_v2 = AutostorageExtension() - - class GetLocationsView(PropertyView): def get(self): - autostorage_extension_v2.check_location() - return autostorage_extension_v2.get_locations() + self.extension.check_location() + return self.extension.get_locations() class PreferredLocationView(PropertyView): schema = fields.String(required=True, example="Default") def get(self): - autostorage_extension_v2.check_location() - return autostorage_extension_v2.get_preferred_key() + self.extension.check_location() + return self.extension.get_preferred_key() def post(self, new_path_key): microscope = find_component("org.openflexure.microscope") @@ -226,8 +254,8 @@ class PreferredLocationView(PropertyView): if not microscope: abort(503, "No microscope connected. Unable to autofocus.") - autostorage_extension_v2.check_location() - autostorage_extension_v2.set_preferred_key(new_path_key) + self.extension.check_location() + self.extension.set_preferred_key(new_path_key) microscope.save_settings() @@ -237,46 +265,10 @@ class PreferredLocationGUIView(View): new_path_title = args.get("new_path_title") logging.debug(new_path_title) - new_path_key = autostorage_extension_v2.title_to_key(new_path_title) + new_path_key = self.extension.title_to_key(new_path_title) logging.debug(new_path_key) - autostorage_extension_v2.check_location() - autostorage_extension_v2.set_preferred_key(new_path_key) + self.extension.check_location() + self.extension.set_preferred_key(new_path_key) return new_path_title - - -def dynamic_form(): - autostorage_extension_v2.check_location() - return { - "icon": "sd_storage", - "title": "Storage", - "viewPanel": "gallery", - "forms": [ - { - "name": "Autostorage", - "isCollapsible": False, - "isTask": False, - "route": "/location-from-title", - "emitOnResponse": "globalUpdateCaptures", - "submitLabel": "Set path", - "schema": [ - { - "fieldType": "selectList", - "name": "new_path_title", - "label": "Capture storage path", - "options": autostorage_extension_v2.get_titles(), - "value": autostorage_extension_v2.get_preferred_title(), - } - ], - } - ], - } - - -autostorage_extension_v2.add_view(GetLocationsView, "/list-locations") -autostorage_extension_v2.add_view(PreferredLocationView, "/location") -autostorage_extension_v2.add_view(PreferredLocationGUIView, "/location-from-title") -autostorage_extension_v2.add_meta( - "gui", build_gui(dynamic_form, autostorage_extension_v2) -) diff --git a/openflexure_microscope/api/default_extensions/camera_stage_mapping.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping.py new file mode 100644 index 00000000..ca479e97 --- /dev/null +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping.py @@ -0,0 +1,351 @@ +""" +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 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] + return get_by_path(self.microscope.read_settings(), keys) + + 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") 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.""" + displacement_matrix = self.get_settings().get("image_to_stage_displacement") + if not displacement_matrix: + raise ValueError("The microscope has not yet been calibrated.") + return np.array(displacement_matrix) + + 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, 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( + description="The number of pixels to move in X", required=True, example=100 + ), + "y": fields.Float( + description="The number of pixels to move in Y", required=True, example=100 + ), + } + + 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( + description="The number of pixels to move in X", required=True, example=100 + ), + "y": fields.Float( + description="The number of pixels to move in Y", required=True, example=100 + ), + } + + 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( + description="The number of pixels to move in X", required=True, example=100 + ), + "y_step": fields.Float( + description="The number of pixels to move in Y", required=True, example=100 + ), + "N": fields.Int( + description="The number of rings in the spiral scan", + required=True, + example=3, + ), + } + + 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 index 49d03097..fd920b86 100644 --- a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/__init__.py +++ b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/__init__.py @@ -1 +1 @@ -from .extension import lst_extension_v2 +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 index c10491c4..3b7de2ea 100644 --- a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py +++ b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py @@ -42,23 +42,43 @@ def pause_stream(scamera: BaseCamera): scamera.start_stream() -def recalibrate(microscope: Microscope): - """Reset the camera's settings. +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.", + ) - This generates new gains, exposure time, and lens shading - table such that the background is as uniform as possible - with a gray level of 230. It takes a little while to run. - """ - with pause_stream(microscope.camera) as scamera: - if hasattr(scamera, "picamera"): - picamera_obj: picamerax.PiCamera = getattr(scamera, "picamera") - auto_expose_and_freeze_settings(picamera_obj) - recalibrate_camera(picamera_obj) - microscope.save_settings() - else: - raise RuntimeError( - "Recalibrate can only be used with a Raspberry Pi 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", + ) + + def recalibrate(self, microscope: Microscope): + """Reset the camera's settings. + + This generates new gains, exposure time, and lens shading + table such that the background is as uniform as possible + with a gray level of 230. It takes a little while to run. + """ + with pause_stream(microscope.camera) as scamera: + if hasattr(scamera, "picamera"): + picamera_obj: picamerax.PiCamera = getattr(scamera, "picamera") + auto_expose_and_freeze_settings(picamera_obj) + recalibrate_camera(picamera_obj) + microscope.save_settings() + else: + raise RuntimeError( + "Recalibrate can only be used with a Raspberry Pi camera" + ) class RecalibrateView(ActionView): @@ -70,7 +90,7 @@ class RecalibrateView(ActionView): logging.info("Starting microscope recalibration...") - return recalibrate(microscope) + return self.extension.recalibrate(microscope) class FlattenLSTView(ActionView): @@ -102,22 +122,3 @@ class DeleteLSTView(ActionView): with pause_stream(microscope.camera) as scamera: scamera.camera.lens_shading_table = None microscope.save_settings() - - -lst_extension_v2 = BaseExtension( - "org.openflexure.calibration.picamera", - version="2.0.0-beta.1", - description="Routines to perform flat-field correction on the camera.", -) - -lst_extension_v2.add_method( - recalibrate, "org.openflexure.calibration.picamera.recalibrate" -) - -lst_extension_v2.add_view(RecalibrateView, "/recalibrate", endpoint="recalibrate") -lst_extension_v2.add_view( - FlattenLSTView, "/flatten_lens_shading_table", endpoint="flatten_lens_shading_table" -) -lst_extension_v2.add_view( - DeleteLSTView, "/delete_lens_shading_table", endpoint="delete_lens_shading_table" -) diff --git a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py index 94d22a90..0392720e 100644 --- a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py +++ b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py @@ -8,14 +8,16 @@ from picamerax import PiCamera from picamerax.array import PiBayerArray, PiRGBArray -def rgb_image(camera: PiCamera, resize: Optional[Tuple[int, int]] = None, **kwargs): +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): +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 @@ -78,10 +80,12 @@ def auto_expose_and_freeze_settings(camera: PiCamera): 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: np.ndarray = np.zeros( - (4, bayer_array.shape[0] // 2, bayer_array.shape[1] // 2), - dtype=bayer_array.dtype, + 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( @@ -105,9 +109,13 @@ def lst_from_channels(channels: np.ndarray) -> np.ndarray: [channels.shape[0]] + lst_resolution, dtype=np.float ) for i in range(lens_shading.shape[0]): - image_channel = channels[i, :, :] + image_channel: np.ndarray = channels[i, :, :] + iw: int + ih: int iw, ih = image_channel.shape - ls_channel = lens_shading[i, :, :] + 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 @@ -116,7 +124,7 @@ def lst_from_channels(channels: np.ndarray) -> np.ndarray: # half the size of the full image - remember the Bayer pattern... This # should give results very close to 6by9's solution, albeit considerably # less computationally efficient! - padded_image_channel = np.pad( + 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( @@ -129,7 +137,7 @@ def lst_from_channels(channels: np.ndarray) -> np.ndarray: ) # Next, fill the shading table (except edge pixels). Please excuse the # for loop - I know it's not fast but this code needn't be! - box = 3 # We average together a square of this side length for each pixel. + 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: @@ -150,10 +158,10 @@ def lst_from_channels(channels: np.ndarray) -> np.ndarray: # What we actually want to calculate is the gains needed to compensate for the # lens shading - that's 1/lens_shading_table_float as we currently have it. - gains = 32.0 / lens_shading # 32 is unity gain + gains: 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 = gains.astype(np.uint8) + lens_shading_table: np.ndarray = gains.astype(np.uint8) return lens_shading_table[::-1, :, :].copy() diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index 6cf5abd8..5a2365c4 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -99,9 +99,12 @@ def construct_grid( 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 - BaseExtension.__init__(self, "org.openflexure.scan", version="2.0.0") + + self.add_view(TileScanAPI, "/tile", endpoint="tile") def capture( self, @@ -356,9 +359,6 @@ class ScanExtension(BaseExtension): microscope.stage.move_abs(initial_position) -scan_extension_v2 = ScanExtension() - - class TileScanArgs(FullCaptureArgs): namemode = fields.String(missing="coordinates", example="coordinates") grid = fields.List(fields.Integer, missing=[3, 3, 3], example=[3, 3, 3]) @@ -398,7 +398,7 @@ class TileScanAPI(ActionView): # Acquire microscope lock with 1s timeout with microscope.lock(timeout=1): # Run scan_extension_v2 - return scan_extension_v2.tile( + return self.extension.tile( microscope, basename=args.get("filename"), namemode=args.get("namemode"), @@ -414,6 +414,3 @@ class TileScanAPI(ActionView): annotations=args.get("annotations"), tags=args.get("tags"), ) - - -scan_extension_v2.add_view(TileScanAPI, "/tile", endpoint="tile") diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index 93da215d..74444a07 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -131,8 +131,18 @@ class ZipManager: zd.close() -# Create a global ZIP manager -default_zip_manager = ZipManager() +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): @@ -142,7 +152,7 @@ class ZipBuilderAPIView(ActionView): microscope = find_component("org.openflexure.microscope") # Return a handle on the autofocus task - return default_zip_manager.marshaled_build_zip_from_capture_ids( + return self.extension.manager.marshaled_build_zip_from_capture_ids( microscope, args ) @@ -151,7 +161,7 @@ class ZipListAPIView(PropertyView): schema = ZipObjectSchema(many=True) def get(self): - return default_zip_manager.session_zips.values() + return self.extension.manager.session_zips.values() class ZipGetterAPIView(View): @@ -163,13 +173,13 @@ class ZipGetterAPIView(View): """ Download a particular capture collection ZIP file """ - if not session_id in default_zip_manager.session_zips: + if not session_id in self.extension.manager.session_zips: return abort(404) # 404 Not Found logging.info("Session ID: %s", session_id) return send_file( - default_zip_manager.zip_fp_from_id(session_id).name, + self.extension.manager.zip_fp_from_id(session_id).name, mimetype="application/zip", as_attachment=True, attachment_filename=f"{session_id}.zip", @@ -179,26 +189,12 @@ class ZipGetterAPIView(View): """ Close and delete a particular capture collection ZIP file """ - if not session_id in default_zip_manager.session_zips: + if not session_id in self.extension.manager.session_zips: return abort(404) # 404 Not Found # Close the file - default_zip_manager.session_zips[session_id].close() + self.extension.manager.session_zips[session_id].close() # Delete the file reference - del default_zip_manager.session_zips[session_id] + del self.extension.manager.session_zips[session_id] return {"return": session_id} - - -zip_extension_v2 = BaseExtension( - "org.openflexure.zipbuilder", - version="2.0.0", - description="Build and download capture collections as ZIP files", -) - -zip_extension_v2.add_view( - ZipGetterAPIView, "/get/", endpoint="get_id" -) -zip_extension_v2.add_view(ZipListAPIView, "/get", endpoint="get") - -zip_extension_v2.add_view(ZipBuilderAPIView, "/build", endpoint="build") diff --git a/openflexure_microscope/api/dev_extensions/__init__.py b/openflexure_microscope/api/dev_extensions/__init__.py index 01e565b4..0f098ecf 100644 --- a/openflexure_microscope/api/dev_extensions/__init__.py +++ b/openflexure_microscope/api/dev_extensions/__init__.py @@ -1 +1,3 @@ -from .tools import devtools_extension_v2 +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 index 81231bef..f8da0a03 100644 --- a/openflexure_microscope/api/dev_extensions/tools.py +++ b/openflexure_microscope/api/dev_extensions/tools.py @@ -6,6 +6,17 @@ 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") @@ -23,13 +34,3 @@ class SleepFor(ActionView): end = time.time() logging.info("Waking up!") return {"TimeAsleep": (end - start)} - - -devtools_extension_v2 = BaseExtension( - "org.openflexure.dev.tools", - version="0.1.0", - description="Actions to cause various traumatic events in the microscope, used for testing.", -) - -devtools_extension_v2.add_view(RaiseException, "/raise") -devtools_extension_v2.add_view(SleepFor, "/sleep") diff --git a/openflexure_microscope/api/static/package-lock.json b/openflexure_microscope/api/static/package-lock.json index 47f04d31..f8a024be 100644 --- a/openflexure_microscope/api/static/package-lock.json +++ b/openflexure_microscope/api/static/package-lock.json @@ -2308,9 +2308,9 @@ } }, "@types/express-serve-static-core": { - "version": "4.17.13", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.13.tgz", - "integrity": "sha512-RgDi5a4nuzam073lRGKTUIaL3eF2+H7LJvJ8eUnCI0wA6SNjXc44DCmWNiTLs/AZ7QlsFWZiw/gTG3nSQGL0fA==", + "version": "4.17.14", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.14.tgz", + "integrity": "sha512-uFTLwu94TfUFMToXNgRZikwPuZdOtDgs3syBtAIr/OXorL1kJqUJT9qCLnRZ5KBOWfZQikQ2xKgR2tnDj1OgDA==", "dev": true, "requires": { "@types/node": "*", @@ -2403,9 +2403,9 @@ "dev": true }, "@types/serve-static": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.7.tgz", - "integrity": "sha512-3diZWucbR+xTmbDlU+FRRxBf+31OhFew7cJXML/zh9NmvSPTNoFecAwHB66BUqFgENJtqMiyl7JAwUE/siqdLw==", + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.8.tgz", + "integrity": "sha512-MoJhSQreaVoL+/hurAZzIm8wafFR6ajiTM1m4A0kv6AGeVBl4r4pOV8bGFrjjq1sGxDTnCoF8i22o0/aE5XCyA==", "dev": true, "requires": { "@types/mime": "*", @@ -2442,9 +2442,9 @@ } }, "@types/webpack": { - "version": "4.41.24", - "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.24.tgz", - "integrity": "sha512-1A0MXPwZiMOD3DPMuOKUKcpkdPo8Lq33UGggZ7xio6wJ/jV1dAu5cXDrOfGDnldUroPIRLsr/DT43/GqOA4RFQ==", + "version": "4.41.25", + "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.25.tgz", + "integrity": "sha512-cr6kZ+4m9lp86ytQc1jPOJXgINQyz3kLLunZ57jznW+WIAL0JqZbGubQk4GlD42MuQL5JGOABrxdpqqWeovlVQ==", "dev": true, "requires": { "@types/anymatch": "*", @@ -2477,9 +2477,9 @@ } }, "@types/webpack-sources": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-2.0.0.tgz", - "integrity": "sha512-a5kPx98CNFRKQ+wqawroFunvFqv7GHm/3KOI52NY9xWADgc8smu4R6prt4EU/M4QfVjvgBkMqU4fBhw3QfMVkg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-2.1.0.tgz", + "integrity": "sha512-LXn/oYIpBeucgP1EIJbKQ2/4ZmpvRl+dlrFdX7+94SKRUV3Evy3FsfMZY318vGhkWUS5MPhtOM3w1/hCOAOXcg==", "dev": true, "requires": { "@types/node": "*", @@ -2594,9 +2594,9 @@ } }, "@vue/cli-overlay": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/@vue/cli-overlay/-/cli-overlay-4.5.8.tgz", - "integrity": "sha512-M6q4QPKQfErHk54ycxBASgBadgdsK45f6v2NWUTAiFXdTtqv1Z7gR8JAbQhiXbni/m9571bLJnZFv5c8uhAHnw==", + "version": "4.5.9", + "resolved": "https://registry.npmjs.org/@vue/cli-overlay/-/cli-overlay-4.5.9.tgz", + "integrity": "sha512-E2PWv6tCdUz+eEDj2Th2oxiKmzMe02qi0PcxiNaO7oaqggmEOrp1rLgop7DWpiLDBiqUZk2x0vjK/q2Tz8z/eg==", "dev": true }, "@vue/cli-plugin-babel": { @@ -2708,18 +2708,18 @@ } }, "@vue/cli-plugin-router": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/@vue/cli-plugin-router/-/cli-plugin-router-4.5.8.tgz", - "integrity": "sha512-tJwVLdX4jj0Ia/1hsBejRbo4gs0hb2z1z5ert+4Ve5RNdpOKUG69OaPQgXPqvuYSQh9MW7bqG0iJmYtVD+KBNw==", + "version": "4.5.9", + "resolved": "https://registry.npmjs.org/@vue/cli-plugin-router/-/cli-plugin-router-4.5.9.tgz", + "integrity": "sha512-eBBfbZpQ1sJrdlx8i7iReFxSnuzwmrv+s2OCT3kjBd6uWRqGnD4VihpS4srC7vZLzDQrDplumSn0a93L9Qf3wQ==", "dev": true, "requires": { - "@vue/cli-shared-utils": "^4.5.8" + "@vue/cli-shared-utils": "^4.5.9" }, "dependencies": { "@vue/cli-shared-utils": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/@vue/cli-shared-utils/-/cli-shared-utils-4.5.8.tgz", - "integrity": "sha512-pa6oenhBO/5HeDLRSokiwVN01gROACEDy3ESXWuPmragOREGNmmFKtkPHlqeYavGEX6LFp7f0VK3uMX6UYS5mQ==", + "version": "4.5.9", + "resolved": "https://registry.npmjs.org/@vue/cli-shared-utils/-/cli-shared-utils-4.5.9.tgz", + "integrity": "sha512-anvsrv+rkQC+hgxaT2nQQxnSWSsIzyysZ36LO7qPjXvDRBvcvKLAAviFlUkYbZ+ntbV8puzJ3zw+gUhQw4SEVA==", "dev": true, "requires": { "@hapi/joi": "^15.0.1", @@ -2798,15 +2798,15 @@ } }, "@vue/cli-plugin-vuex": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/@vue/cli-plugin-vuex/-/cli-plugin-vuex-4.5.8.tgz", - "integrity": "sha512-wU9WPhay1zBxtdd/HLRYhFRLBbG9lw3YTBJep9sTnYwUeXiEgum4O88Q2j6cwBBPPHMgeMrKMPHS85Jf4hMc0g==", + "version": "4.5.9", + "resolved": "https://registry.npmjs.org/@vue/cli-plugin-vuex/-/cli-plugin-vuex-4.5.9.tgz", + "integrity": "sha512-mFNIJhYiJjzCgytkDHX00ROy5Yzl7prkZpUbeDE0biwcLteMf2s3qZVbESOQl6GcviqcfEt2f3tHQQtLNa+OLg==", "dev": true }, "@vue/cli-service": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/@vue/cli-service/-/cli-service-4.5.8.tgz", - "integrity": "sha512-YcVEJvA5vQOcfRzhCQDfHxRW9WHvKqlQeiuSvNddfV2uQqKD3ifhsNYiZZuFvbev9qgRUmtmxSafpwYzj/LLBw==", + "version": "4.5.9", + "resolved": "https://registry.npmjs.org/@vue/cli-service/-/cli-service-4.5.9.tgz", + "integrity": "sha512-E3XlfM0q+UnnjbC9rwLIWNo2umZCRwnlMJY0KOhY1hFvqisGIYzFmQQ4o01KGyTx2BZNMuQg7Kw+BZ5gyM1Wig==", "dev": true, "requires": { "@intervolga/optimize-cssnano-plugin": "^1.0.5", @@ -2815,10 +2815,10 @@ "@types/minimist": "^1.2.0", "@types/webpack": "^4.0.0", "@types/webpack-dev-server": "^3.11.0", - "@vue/cli-overlay": "^4.5.8", - "@vue/cli-plugin-router": "^4.5.8", - "@vue/cli-plugin-vuex": "^4.5.8", - "@vue/cli-shared-utils": "^4.5.8", + "@vue/cli-overlay": "^4.5.9", + "@vue/cli-plugin-router": "^4.5.9", + "@vue/cli-plugin-vuex": "^4.5.9", + "@vue/cli-shared-utils": "^4.5.9", "@vue/component-compiler-utils": "^3.1.2", "@vue/preload-webpack-plugin": "^1.1.0", "@vue/web-component-wrapper": "^1.2.0", @@ -2868,9 +2868,9 @@ }, "dependencies": { "@vue/cli-shared-utils": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/@vue/cli-shared-utils/-/cli-shared-utils-4.5.8.tgz", - "integrity": "sha512-pa6oenhBO/5HeDLRSokiwVN01gROACEDy3ESXWuPmragOREGNmmFKtkPHlqeYavGEX6LFp7f0VK3uMX6UYS5mQ==", + "version": "4.5.9", + "resolved": "https://registry.npmjs.org/@vue/cli-shared-utils/-/cli-shared-utils-4.5.9.tgz", + "integrity": "sha512-anvsrv+rkQC+hgxaT2nQQxnSWSsIzyysZ36LO7qPjXvDRBvcvKLAAviFlUkYbZ+ntbV8puzJ3zw+gUhQw4SEVA==", "dev": true, "requires": { "@hapi/joi": "^15.0.1", @@ -2928,16 +2928,16 @@ } }, "browserslist": { - "version": "4.14.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.14.7.tgz", - "integrity": "sha512-BSVRLCeG3Xt/j/1cCGj1019Wbty0H+Yvu2AOuZSuoaUWn3RatbL33Cxk+Q4jRMRAbOm0p7SLravLjpnT6s0vzQ==", + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.15.0.tgz", + "integrity": "sha512-IJ1iysdMkGmjjYeRlDU8PQejVwxvVO5QOfXH7ylW31GO6LwNRSmm/SgRXtNsEXqMLl2e+2H5eEJ7sfynF8TCaQ==", "dev": true, "requires": { - "caniuse-lite": "^1.0.30001157", + "caniuse-lite": "^1.0.30001164", "colorette": "^1.2.1", - "electron-to-chromium": "^1.3.591", + "electron-to-chromium": "^1.3.612", "escalade": "^3.1.1", - "node-releases": "^1.1.66" + "node-releases": "^1.1.67" } }, "cacache": { @@ -2967,9 +2967,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001157", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001157.tgz", - "integrity": "sha512-gOerH9Wz2IRZ2ZPdMfBvyOi3cjaz4O4dgNwPGzx8EhqAs4+2IL/O+fJsbt+znSigujoZG8bVcIAUM/I/E5K3MA==", + "version": "1.0.30001165", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001165.tgz", + "integrity": "sha512-8cEsSMwXfx7lWSUMA2s08z9dIgsnR5NAqjXP23stdsU3AUWkCr/rr4s4OFtHXn5XXr6+7kam3QFVoYyXNPdJPA==", "dev": true }, "color-convert": { @@ -2990,9 +2990,9 @@ "optional": true }, "electron-to-chromium": { - "version": "1.3.592", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.592.tgz", - "integrity": "sha512-kGNowksvqQiPb1pUSQKpd8JFoGPLxYOwduNRCqCxGh/2Q1qE2JdmwouCW41lUzDxOb/2RIV4lR0tVIfboWlO9A==", + "version": "1.3.615", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.615.tgz", + "integrity": "sha512-fNYTQXoUhNc6RmHDlGN4dgcLURSBIqQCN7ls6MuQ741+NJyLNRz8DxAC+pZpOKfRs6cfY0lv2kWdy8Oxf9j4+A==", "dev": true }, "emojis-list": { @@ -3083,9 +3083,9 @@ } }, "node-releases": { - "version": "1.1.66", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.66.tgz", - "integrity": "sha512-JHEQ1iWPGK+38VLB2H9ef2otU4l8s3yAMt9Xf934r6+ojCYDMHPMqvCc9TnzfeFSP1QEOeU6YZEd3+De0LTCgg==", + "version": "1.1.67", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.67.tgz", + "integrity": "sha512-V5QF9noGFl3EymEwUYzO+3NTDpGfQB4ve6Qfnzf3UNydMhjQRVPR1DZTuvWiLzaFJYw2fmDwAfnRNEVb64hSIg==", "dev": true }, "p-limit": { @@ -3235,9 +3235,9 @@ } }, "vue-loader-v16": { - "version": "npm:vue-loader@16.1.0", - "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.1.0.tgz", - "integrity": "sha512-fTtCdI7VeyNK0HP4q4y9Z9ts8TUeaF+2/FjKx8CJ/7/Oem1rCX7zIJe+d+jLrVnVNQjENd3gqmANraLcdRWwnQ==", + "version": "npm:vue-loader@16.1.1", + "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.1.1.tgz", + "integrity": "sha512-wz/+HFg/3SBayHWAlZXARcnDTl3VOChrfW9YnxvAweiuyKX/7IGx1ad/4yJHmwhgWlOVYMAbTiI7GV8G33PfGQ==", "dev": true, "optional": true, "requires": { @@ -3872,34 +3872,34 @@ }, "dependencies": { "browserslist": { - "version": "4.14.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.14.7.tgz", - "integrity": "sha512-BSVRLCeG3Xt/j/1cCGj1019Wbty0H+Yvu2AOuZSuoaUWn3RatbL33Cxk+Q4jRMRAbOm0p7SLravLjpnT6s0vzQ==", + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.15.0.tgz", + "integrity": "sha512-IJ1iysdMkGmjjYeRlDU8PQejVwxvVO5QOfXH7ylW31GO6LwNRSmm/SgRXtNsEXqMLl2e+2H5eEJ7sfynF8TCaQ==", "dev": true, "requires": { - "caniuse-lite": "^1.0.30001157", + "caniuse-lite": "^1.0.30001164", "colorette": "^1.2.1", - "electron-to-chromium": "^1.3.591", + "electron-to-chromium": "^1.3.612", "escalade": "^3.1.1", - "node-releases": "^1.1.66" + "node-releases": "^1.1.67" } }, "caniuse-lite": { - "version": "1.0.30001157", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001157.tgz", - "integrity": "sha512-gOerH9Wz2IRZ2ZPdMfBvyOi3cjaz4O4dgNwPGzx8EhqAs4+2IL/O+fJsbt+znSigujoZG8bVcIAUM/I/E5K3MA==", + "version": "1.0.30001165", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001165.tgz", + "integrity": "sha512-8cEsSMwXfx7lWSUMA2s08z9dIgsnR5NAqjXP23stdsU3AUWkCr/rr4s4OFtHXn5XXr6+7kam3QFVoYyXNPdJPA==", "dev": true }, "electron-to-chromium": { - "version": "1.3.592", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.592.tgz", - "integrity": "sha512-kGNowksvqQiPb1pUSQKpd8JFoGPLxYOwduNRCqCxGh/2Q1qE2JdmwouCW41lUzDxOb/2RIV4lR0tVIfboWlO9A==", + "version": "1.3.615", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.615.tgz", + "integrity": "sha512-fNYTQXoUhNc6RmHDlGN4dgcLURSBIqQCN7ls6MuQ741+NJyLNRz8DxAC+pZpOKfRs6cfY0lv2kWdy8Oxf9j4+A==", "dev": true }, "node-releases": { - "version": "1.1.66", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.66.tgz", - "integrity": "sha512-JHEQ1iWPGK+38VLB2H9ef2otU4l8s3yAMt9Xf934r6+ojCYDMHPMqvCc9TnzfeFSP1QEOeU6YZEd3+De0LTCgg==", + "version": "1.1.67", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.67.tgz", + "integrity": "sha512-V5QF9noGFl3EymEwUYzO+3NTDpGfQB4ve6Qfnzf3UNydMhjQRVPR1DZTuvWiLzaFJYw2fmDwAfnRNEVb64hSIg==", "dev": true } } @@ -4864,16 +4864,16 @@ } }, "cli-highlight": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.4.tgz", - "integrity": "sha512-s7Zofobm20qriqDoU9sXptQx0t2R9PEgac92mENNm7xaEe1hn71IIMsXMK+6encA6WRCWWxIGQbipr3q998tlQ==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.9.tgz", + "integrity": "sha512-t8RNIZgiI24i/mslZ8XT8o660RUj5ZbUJpEZrZa/BNekTzdC2LfMRAnt0Y7sgzNM4FGW5tmWg/YnbTH8o1eIOQ==", "dev": true, "requires": { - "chalk": "^3.0.0", - "highlight.js": "^9.6.0", + "chalk": "^4.0.0", + "highlight.js": "^10.0.0", "mz": "^2.4.0", "parse5": "^5.1.1", - "parse5-htmlparser2-tree-adapter": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^15.0.0" }, "dependencies": { @@ -4887,9 +4887,9 @@ } }, "chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", "dev": true, "requires": { "ansi-styles": "^4.1.0", @@ -5628,28 +5628,28 @@ "dev": true }, "csso": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/csso/-/csso-4.1.0.tgz", - "integrity": "sha512-h+6w/W1WqXaJA4tb1dk7r5tVbOm97MsKxzwnvOR04UQ6GILroryjMWu3pmCCtL2mLaEStQ0fZgeGiy99mo7iyg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", "dev": true, "requires": { - "css-tree": "^1.0.0" + "css-tree": "^1.1.2" }, "dependencies": { "css-tree": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0.tgz", - "integrity": "sha512-CdVYz/Yuqw0VdKhXPBIgi8DO3NicJVYZNWeX9XcIuSp9ZoFT5IcleVRW07O5rMjdcx1mb+MEJPknTTEW7DdsYw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.2.tgz", + "integrity": "sha512-wCoWush5Aeo48GLhfHPbmvZs59Z+M7k5+B1xDnXbdWNcEF423DoFdqSWE0PM5aNk5nI5cp1q7ms36zGApY/sKQ==", "dev": true, "requires": { - "mdn-data": "2.0.12", + "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "mdn-data": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.12.tgz", - "integrity": "sha512-ULbAlgzVb8IqZ0Hsxm6hHSlQl3Jckst2YEQS7fODu9ilNWy2LvcoSY7TRFIktABP2mdppBioc66va90T+NUs8Q==", + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", "dev": true }, "source-map": { @@ -6089,9 +6089,9 @@ }, "dependencies": { "domelementtype": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.2.tgz", - "integrity": "sha512-wFwTwCVebUrMgGeAwRL/NhZtHAUyT9n9yg4IMDwf10+6iCMxSkVq9MGCVEH+QZWo1nNidy8kNvwmv4zWHDTqvA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.1.0.tgz", + "integrity": "sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w==", "dev": true } } @@ -6620,9 +6620,9 @@ } }, "eslint-plugin-prettier": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.4.tgz", - "integrity": "sha512-jZDa8z76klRqo+TdGDTFJSavwbnWK2ZpqGKNZ+VvweMW516pDUMmQ2koXvxEE4JhzNvTv+radye/bWGBmA6jmg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.2.0.tgz", + "integrity": "sha512-kOUSJnFjAUFKwVxuzy6sA5yyMx6+o9ino4gCdShzBNx4eyFRudWRYKCFolKjoM40PEiuU6Cn7wBLfq3WsGg7qg==", "dev": true, "requires": { "prettier-linter-helpers": "^1.0.0" @@ -8216,9 +8216,9 @@ "dev": true }, "highlight.js": { - "version": "9.18.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.18.3.tgz", - "integrity": "sha512-zBZAmhSupHIl5sITeMqIJnYCDfAEc3Gdkqj65wC1lpI468MMQeeQkhcIAvk+RylAkxrCcI9xy9piHiXeQ1BdzQ==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.4.1.tgz", + "integrity": "sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg==", "dev": true }, "hmac-drbg": { @@ -9521,9 +9521,9 @@ } }, "loglevel": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.7.0.tgz", - "integrity": "sha512-i2sY04nal5jDcagM3FMfG++T69GEEM8CYuOfeOIvmXzOIcwE9a/CJPR0MFM97pYMj/u10lzz7/zd7+qwhrBTqQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.7.1.tgz", + "integrity": "sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw==", "dev": true }, "loose-envify": { @@ -10206,11 +10206,49 @@ "dev": true }, "object-is": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.3.tgz", - "integrity": "sha512-teyqLvFWzLkq5B9ki8FVWA902UER2qkxmdA4nLf+wjOLAWgxzCWZNCxpDq9MvE8MmhWNr+I8w3BN49Vx36Y6Xg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.4.tgz", + "integrity": "sha512-1ZvAZ4wlF7IyPVOcE1Omikt7UpaFlOQq0HlSti+ZvDH3UiD2brwGMwDbyV43jao2bKJ+4+WdPJHSd7kgzKYVqg==", "dev": true, "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + } + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "dev": true, + "requires": { + "isobject": "^3.0.0" + } + }, + "object.assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", + "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.0", + "object-keys": "^1.0.11" + } + }, + "object.getownpropertydescriptors": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.1.tgz", + "integrity": "sha512-6DtXgZ/lIZ9hqx4GtZETobXLR/ZLaa0aqV0kzbn80Rf8Z2e/XFnhA0I7p07N2wH8bBBltr2xQPi6sbKWAY2Eng==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", "define-properties": "^1.1.3", "es-abstract": "^1.18.0-next.1" }, @@ -10251,9 +10289,9 @@ } }, "object-inspect": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz", - "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz", + "integrity": "sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==", "dev": true }, "object.assign": { @@ -10270,43 +10308,6 @@ } } }, - "object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true - }, - "object-visit": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", - "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", - "dev": true, - "requires": { - "isobject": "^3.0.0" - } - }, - "object.assign": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", - "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", - "dev": true, - "requires": { - "define-properties": "^1.1.2", - "function-bind": "^1.1.1", - "has-symbols": "^1.0.0", - "object-keys": "^1.0.11" - } - }, - "object.getownpropertydescriptors": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz", - "integrity": "sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg==", - "dev": true, - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.0-next.1" - } - }, "object.pick": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", @@ -10317,15 +10318,70 @@ } }, "object.values": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.1.tgz", - "integrity": "sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.2.tgz", + "integrity": "sha512-MYC0jvJopr8EK6dPBiO8Nb9mvjdypOachO5REGk6MXzujbBrAisKo3HmdEI6kZDL6fC31Mwee/5YbtMebixeag==", "dev": true, "requires": { + "call-bind": "^1.0.0", "define-properties": "^1.1.3", - "es-abstract": "^1.17.0-next.1", - "function-bind": "^1.1.1", + "es-abstract": "^1.18.0-next.1", "has": "^1.0.3" + }, + "dependencies": { + "es-abstract": { + "version": "1.18.0-next.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", + "integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.2", + "is-negative-zero": "^2.0.0", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.1", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + }, + "is-callable": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz", + "integrity": "sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==", + "dev": true + }, + "is-regex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz", + "integrity": "sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, + "object-inspect": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz", + "integrity": "sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==", + "dev": true + }, + "object.assign": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", + "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + } + } } }, "obuf": { @@ -10561,12 +10617,20 @@ "dev": true }, "parse5-htmlparser2-tree-adapter": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-5.1.1.tgz", - "integrity": "sha512-CF+TKjXqoqyDwHqBhFQ+3l5t83xYi6fVT1tQNg+Ye0JRLnTxWvIroCjEp1A0k4lneHNBGnICUf0cfYVYGEazqw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", "dev": true, "requires": { - "parse5": "^5.1.1" + "parse5": "^6.0.1" + }, + "dependencies": { + "parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + } } }, "parseurl": { @@ -10783,9 +10847,9 @@ }, "dependencies": { "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "requires": { "ms": "^2.1.1" @@ -12452,9 +12516,9 @@ }, "dependencies": { "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "requires": { "ms": "^2.1.1" @@ -12770,68 +12834,13 @@ } }, "string.prototype.trimend": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.2.tgz", - "integrity": "sha512-8oAG/hi14Z4nOVP0z6mdiVZ/wqjDtWSLygMigTzAb+7aPEDTleeFf+WrF+alzecxIRkckkJVn+dTlwzJXORATw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.3.tgz", + "integrity": "sha512-ayH0pB+uf0U28CtjlLvL7NaohvR1amUvVZk+y3DYb0Ey2PUV5zPkkKy9+U1ndVEIXO8hNg18eIv9Jntbii+dKw==", "dev": true, "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.18.0-next.1" - }, - "dependencies": { - "es-abstract": { - "version": "1.18.0-next.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", - "integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==", - "dev": true, - "requires": { - "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1", - "is-callable": "^1.2.2", - "is-negative-zero": "^2.0.0", - "is-regex": "^1.1.1", - "object-inspect": "^1.8.0", - "object-keys": "^1.1.1", - "object.assign": "^4.1.1", - "string.prototype.trimend": "^1.0.1", - "string.prototype.trimstart": "^1.0.1" - } - }, - "is-callable": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz", - "integrity": "sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==", - "dev": true - }, - "is-regex": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz", - "integrity": "sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==", - "dev": true, - "requires": { - "has-symbols": "^1.0.1" - } - }, - "object-inspect": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz", - "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==", - "dev": true - }, - "object.assign": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", - "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3", - "has-symbols": "^1.0.1", - "object-keys": "^1.1.1" - } - } + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" } }, "string.prototype.trimleft": { @@ -12855,68 +12864,13 @@ } }, "string.prototype.trimstart": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.2.tgz", - "integrity": "sha512-7F6CdBTl5zyu30BJFdzSTlSlLPwODC23Od+iLoVH8X6+3fvDPPuBVVj9iaB1GOsSTSIgVfsfm27R2FGrAPznWg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.3.tgz", + "integrity": "sha512-oBIBUy5lea5tt0ovtOFiEQaBkoBBkyJhZXzJYrSmDo5IUUqbOPvVezuRs/agBIdZ2p2Eo1FD6bD9USyBLfl3xg==", "dev": true, "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.18.0-next.1" - }, - "dependencies": { - "es-abstract": { - "version": "1.18.0-next.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", - "integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==", - "dev": true, - "requires": { - "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1", - "is-callable": "^1.2.2", - "is-negative-zero": "^2.0.0", - "is-regex": "^1.1.1", - "object-inspect": "^1.8.0", - "object-keys": "^1.1.1", - "object.assign": "^4.1.1", - "string.prototype.trimend": "^1.0.1", - "string.prototype.trimstart": "^1.0.1" - } - }, - "is-callable": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz", - "integrity": "sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==", - "dev": true - }, - "is-regex": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz", - "integrity": "sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==", - "dev": true, - "requires": { - "has-symbols": "^1.0.1" - } - }, - "object-inspect": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz", - "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==", - "dev": true - }, - "object.assign": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", - "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3", - "has-symbols": "^1.0.1", - "object-keys": "^1.1.1" - } - } + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" } }, "string_decoder": { @@ -13387,9 +13341,9 @@ } }, "uikit": { - "version": "3.5.9", - "resolved": "https://registry.npmjs.org/uikit/-/uikit-3.5.9.tgz", - "integrity": "sha512-0197WceeBMlCLscsmds6zqL8K3TOtdUCvoHPJEuYqYY2gKa7JttlA6MiyzU0yBPzDlcawXF2TdOmQ/DWy6ZDEw==", + "version": "3.5.10", + "resolved": "https://registry.npmjs.org/uikit/-/uikit-3.5.10.tgz", + "integrity": "sha512-X+OUD02atmks1yYG9C3FSo1EcQ0xfV0+NCHspTShd2uMKYcC5LiMq6o/MyxBxx2bQSeKXh6Kh8TqQ/v4LWI4Tg==", "dev": true }, "unicode-canonical-property-names-ecmascript": { @@ -13699,9 +13653,9 @@ } }, "object-inspect": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz", - "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz", + "integrity": "sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==", "dev": true }, "object.assign": { @@ -13915,9 +13869,9 @@ "dev": true }, "vuex": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.5.1.tgz", - "integrity": "sha512-w7oJzmHQs0FM9LXodfskhw9wgKBiaB+totOdb8sNzbTB2KDCEEwEs29NzBZFh/lmEK1t5tDmM1vtsO7ubG1DFw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.6.0.tgz", + "integrity": "sha512-W74OO2vCJPs9/YjNjW8lLbj+jzT24waTo2KShI8jLvJW8OaIkgb3wuAMA7D+ZiUxDOx3ubwSZTaJBip9G8a3aQ==", "dev": true }, "watchpack": { diff --git a/openflexure_microscope/api/static/package.json b/openflexure_microscope/api/static/package.json index 8e2a5f33..a0d8b116 100644 --- a/openflexure_microscope/api/static/package.json +++ b/openflexure_microscope/api/static/package.json @@ -18,25 +18,25 @@ "devDependencies": { "@vue/cli-plugin-babel": "^3.12.1", "@vue/cli-plugin-eslint": "^4.5.9", - "@vue/cli-service": "^4.5.8", + "@vue/cli-service": "^4.5.9", "@vue/eslint-config-prettier": "^6.0.0", "axios": "^0.19.2", "babel-eslint": "^10.1.0", "css-loader": "^3.6.0", "eslint": "^6.8.0", - "eslint-plugin-prettier": "^3.1.4", + "eslint-plugin-prettier": "^3.2.0", "eslint-plugin-vue": "^6.2.2", "less": "^3.12.2", "less-loader": "^5.0.0", "mdns-js": "^1.0.3", "prettier": "^1.19.1", - "uikit": "^3.5.9", + "uikit": "^3.5.10", "vue": "^2.6.12", "vue-friendly-iframe": "^0.17.0", "vue-template-compiler": "^2.6.12", "vue-tour": "^1.5.0", "vuejs-paginate": "^2.1.0", - "vuex": "^3.5.1" + "vuex": "^3.6.0" }, "postcss": { "plugins": { diff --git a/openflexure_microscope/api/static/src/components/fieldComponents/keyvalList.vue b/openflexure_microscope/api/static/src/components/fieldComponents/keyvalList.vue index 4d7743a4..32a82f64 100644 --- a/openflexure_microscope/api/static/src/components/fieldComponents/keyvalList.vue +++ b/openflexure_microscope/api/static/src/components/fieldComponents/keyvalList.vue @@ -98,7 +98,9 @@ export default { this.$emit("input", newSelected); // Move focus back to key textbox - this.$refs.textboxKey.focus(); + if (this.$refs.textboxKey) { + this.$refs.textboxKey.focus(); + } }, delMetadataKey: function(key) { diff --git a/openflexure_microscope/api/static/src/components/genericComponents/taskSubmitter.vue b/openflexure_microscope/api/static/src/components/genericComponents/taskSubmitter.vue index cb11e22a..3ae912d4 100644 --- a/openflexure_microscope/api/static/src/components/genericComponents/taskSubmitter.vue +++ b/openflexure_microscope/api/static/src/components/genericComponents/taskSubmitter.vue @@ -198,7 +198,6 @@ export default { if (!error) { error = Error("Unknown error"); } - this.$emit("error", error); this.$emit("finished"); }) diff --git a/openflexure_microscope/api/static/src/components/tabContentComponents/captureComponents/paneCapture.vue b/openflexure_microscope/api/static/src/components/tabContentComponents/captureComponents/paneCapture.vue index a3827938..696ffadf 100644 --- a/openflexure_microscope/api/static/src/components/tabContentComponents/captureComponents/paneCapture.vue +++ b/openflexure_microscope/api/static/src/components/tabContentComponents/captureComponents/paneCapture.vue @@ -248,9 +248,8 @@ :submit-data="scanPayload" :submit-label="'Start Scan'" :button-primary="true" - @submit="onScanSubmit" @response="onScanResponse" - @error="onScanError" + @error="modalError" > @@ -433,14 +432,10 @@ export default { }); }, - onScanSubmit: function() {}, - onScanResponse: function() { this.modalNotify("Finished scan."); - }, - - onScanError: function(error) { - this.modalError(error); + // Emit signal to update capture list + this.$root.$emit("globalUpdateCaptures"); } } }; diff --git a/openflexure_microscope/api/static/src/components/tabContentComponents/galleryComponents/scanCard.vue b/openflexure_microscope/api/static/src/components/tabContentComponents/galleryComponents/scanCard.vue index 1c8d0f49..1af496e7 100644 --- a/openflexure_microscope/api/static/src/components/tabContentComponents/galleryComponents/scanCard.vue +++ b/openflexure_microscope/api/static/src/components/tabContentComponents/galleryComponents/scanCard.vue @@ -119,7 +119,6 @@ export default { deleteAll: function() { axios.all(this.allURLs.map(l => axios.delete(l))).then(() => { - console.log("Delete finished") // Emit signal to update capture list this.$root.$emit("globalUpdateCaptures"); }); diff --git a/openflexure_microscope/api/static/src/components/tabContentComponents/galleryComponents/zipDownloader.vue b/openflexure_microscope/api/static/src/components/tabContentComponents/galleryComponents/zipDownloader.vue index a0074706..12e01041 100644 --- a/openflexure_microscope/api/static/src/components/tabContentComponents/galleryComponents/zipDownloader.vue +++ b/openflexure_microscope/api/static/src/components/tabContentComponents/galleryComponents/zipDownloader.vue @@ -18,7 +18,7 @@ :submit-label="'Create ZIP'" :submit-data="captureIds" @response="onResponse" - @error="onError" + @error="modalError" > @@ -163,10 +163,6 @@ export default { this.lastSessionId = response.output.id; this.downloadUrl = `${this.zipGetterUri}/${this.lastSessionId}`; this.downloadReady = true; - }, - - onError: function(error) { - this.modalError(error); // Let mixin handle error } } }; diff --git a/openflexure_microscope/api/static/src/components/tabContentComponents/galleryContent.vue b/openflexure_microscope/api/static/src/components/tabContentComponents/galleryContent.vue index 3ae91b76..79a984fe 100644 --- a/openflexure_microscope/api/static/src/components/tabContentComponents/galleryContent.vue +++ b/openflexure_microscope/api/static/src/components/tabContentComponents/galleryContent.vue @@ -197,7 +197,7 @@ export default { var captures = []; for (var capture of this.captures) { // Add to capture list if matched - if (!capture.dataset) { + if (!this.isDatasetPopulated(capture.dataset)) { captures.push(capture); } } @@ -212,7 +212,7 @@ export default { for (var capture of this.captures) { var dataset = capture.dataset; - if (dataset) { + if (this.isDatasetPopulated(dataset)) { var id = dataset["id"]; // If this scan ID hasn't been seen before @@ -361,6 +361,18 @@ export default { } }, + isDatasetPopulated: function(dataset) { + if ( + !dataset || // If no dataset key + (dataset.constructor === Object && // Or dataset is an object... + Object.keys(dataset).length === 0) // ...but it's empty + ) { + return false; + } else { + return true; + } + }, + filterCaptures: function(list, filterTags) { // Filter a list of captures by an array of tags var result = []; diff --git a/openflexure_microscope/api/static/src/components/tabContentComponents/loggingContent.vue b/openflexure_microscope/api/static/src/components/tabContentComponents/loggingContent.vue index 93b9447f..8bbf45c6 100644 --- a/openflexure_microscope/api/static/src/components/tabContentComponents/loggingContent.vue +++ b/openflexure_microscope/api/static/src/components/tabContentComponents/loggingContent.vue @@ -11,7 +11,11 @@
+ > + +
@@ -40,15 +44,14 @@ v-for="item in pagedItems" :key="item.timestamp" uk-alert + class="logging-entry" :class="{ 'uk-alert-warning uk-alert': item.data.levelname == 'WARNING', 'uk-alert-danger uk-alert': item.data.levelname == 'ERROR' }" > -

- {{ formatDateTime(item.data.created) }} -

- {{ item.data.levelname }}: {{ item.data.message }} + {{ formatDateTime(item.data.created) }} +
{{ formatMessage(item) }}
diff --git a/openflexure_microscope/api/static/src/components/tabContentComponents/navigateComponents/paneNavigate.vue b/openflexure_microscope/api/static/src/components/tabContentComponents/navigateComponents/paneNavigate.vue index 9d291f16..aff73c4b 100644 --- a/openflexure_microscope/api/static/src/components/tabContentComponents/navigateComponents/paneNavigate.vue +++ b/openflexure_microscope/api/static/src/components/tabContentComponents/navigateComponents/paneNavigate.vue @@ -148,6 +148,7 @@ :submit-on-event="'globalFastAutofocusEvent'" @taskStarted="isAutofocusing = 1" @finished="isAutofocusing = 0" + @error="modalError" > @@ -160,6 +161,7 @@ :button-primary="false" @taskStarted="isAutofocusing = 2" @finished="isAutofocusing = 0" + @error="modalError" > @@ -172,6 +174,7 @@ :button-primary="false" @taskStarted="isAutofocusing = 3" @finished="isAutofocusing = 0" + @error="modalError" > diff --git a/openflexure_microscope/api/static/src/components/tabContentComponents/settingsComponents/CSMSettingsComponents/CSMCalibrationSettings.vue b/openflexure_microscope/api/static/src/components/tabContentComponents/settingsComponents/CSMSettingsComponents/CSMCalibrationSettings.vue index 40ed3216..a7280c1f 100644 --- a/openflexure_microscope/api/static/src/components/tabContentComponents/settingsComponents/CSMSettingsComponents/CSMCalibrationSettings.vue +++ b/openflexure_microscope/api/static/src/components/tabContentComponents/settingsComponents/CSMSettingsComponents/CSMCalibrationSettings.vue @@ -12,7 +12,7 @@ :submit-url="recalibrationLinks.calibrate_xy.href" :submit-label="'Auto-Calibrate using camera'" @response="onRecalibrateResponse" - @error="onRecalibrateError" + @error="modalError" > @@ -149,10 +149,6 @@ export default { this.modalNotify("Finished stage-to-camera calibration."); // Update local settings this.updateSettings(); - }, - - onRecalibrateError: function(error) { - this.modalError(error); // Let mixin handle error } } }; diff --git a/openflexure_microscope/api/static/src/components/tabContentComponents/settingsComponents/cameraSettingsComponents/cameraCalibrationSettings.vue b/openflexure_microscope/api/static/src/components/tabContentComponents/settingsComponents/cameraSettingsComponents/cameraCalibrationSettings.vue index 5b995952..e6f3c8d8 100644 --- a/openflexure_microscope/api/static/src/components/tabContentComponents/settingsComponents/cameraSettingsComponents/cameraCalibrationSettings.vue +++ b/openflexure_microscope/api/static/src/components/tabContentComponents/settingsComponents/cameraSettingsComponents/cameraCalibrationSettings.vue @@ -11,7 +11,7 @@ :submit-url="recalibrationLinks.recalibrate.href" :submit-label="'Auto-Calibrate'" @response="onRecalibrateResponse" - @error="onRecalibrateError" + @error="modalError" > @@ -128,10 +128,6 @@ export default { this.modalNotify("Finished recalibration."); }, - onRecalibrateError: function(error) { - this.modalError(error); // Let mixin handle error - }, - flattenLensShadingTableRequest: function() { axios.post(this.recalibrationLinks.flatten_lens_shading_table.href); }, diff --git a/openflexure_microscope/api/static/src/components/tabContentComponents/slideScanComponents/paneSlideScan.vue b/openflexure_microscope/api/static/src/components/tabContentComponents/slideScanComponents/paneSlideScan.vue index 0d3096bf..616b047c 100644 --- a/openflexure_microscope/api/static/src/components/tabContentComponents/slideScanComponents/paneSlideScan.vue +++ b/openflexure_microscope/api/static/src/components/tabContentComponents/slideScanComponents/paneSlideScan.vue @@ -95,7 +95,7 @@ submit-label="Start scan" @submit="scanRunning = true" @response="scanRunning = false" - @error="scanRunning = false" + @error="onScanError" >
@@ -233,6 +233,11 @@ export default { }); }, + onScanError: function(error) { + this.scanRunning = false; + this.modalError(error); + }, + decrement: function() { if (this.stepValue > 0) { this.stepValue = this.stepValue - 1; diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index 75b8cc51..e3c1fce4 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -79,19 +79,25 @@ class CaptureSchema(ImageSchema): "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, @@ -101,7 +107,9 @@ class CaptureSchema(ImageSchema): ), "mimetype": "image/jpeg", **description_from_view(CaptureDownload), - }, + } + if CaptureDownload.endpoint + else {}, } if isinstance(data, dict): diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py index 0251b5c2..f67e3e8f 100644 --- a/openflexure_microscope/camera/base.py +++ b/openflexure_microscope/camera/base.py @@ -3,14 +3,16 @@ import io import logging import time from abc import ABCMeta, abstractmethod -from collections import namedtuple from types import TracebackType -from typing import BinaryIO, List, Optional, Tuple, Type, Union +from typing import BinaryIO, List, NamedTuple, Optional, Tuple, Type, Union from labthings import ClientEvent, StrictLock + # Class to store a frames metadata -TrackerFrame = namedtuple("TrackerFrame", ["size", "time"]) +class TrackerFrame(NamedTuple): + size: int + time: float class FrameStream(io.BytesIO): @@ -170,6 +172,21 @@ class BaseCamera(metaclass=ABCMeta): 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 diff --git a/openflexure_microscope/camera/mock.py b/openflexure_microscope/camera/mock.py index dd0eb850..6b9b67ea 100644 --- a/openflexure_microscope/camera/mock.py +++ b/openflexure_microscope/camera/mock.py @@ -178,7 +178,7 @@ class MissingCamera(BaseCamera): """ Change the camera zoom, handling re-centering and scaling. """ - logging.warning("Zoom not implemented in mock camera") + logging.info("Zoom not implemented in mock camera") def start_stream(self): pass @@ -188,11 +188,11 @@ class MissingCamera(BaseCamera): def start_preview(self, *_, **__): """Start the on board GPU camera preview.""" - logging.warning("GPU preview not implemented in mock camera") + logging.info("GPU preview not implemented in mock camera") def stop_preview(self): """Stop the on board GPU camera preview.""" - logging.warning("GPU preview not implemented in mock camera") + logging.info("GPU preview not implemented in mock camera") def start_recording(self, *_, **__): """Start recording. diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index e24a60be..f4404af9 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -22,7 +22,7 @@ try: except Exception as e: # pylint: disable=W0703 logging.error(e) logging.warning("Unable to import PiCameraStreamer") -from labthings import CompositeLock, StrictLock +from labthings import CompositeLock from openflexure_microscope.config import user_configuration, user_settings @@ -48,7 +48,7 @@ class Microscope: # Initialise with an empty composite lock #: :py:class:`labthings.CompositeLock`: Composite lock for locking both camera and stage - self.lock: Union[CompositeLock, StrictLock] = CompositeLock([]) + self.lock: CompositeLock = CompositeLock([]) self.camera: BaseCamera = None #: Currently connected camera object self.stage: BaseStage = None #: Currently connected stage object diff --git a/openflexure_microscope/stage/base.py b/openflexure_microscope/stage/base.py index b38d2ee6..026ac642 100644 --- a/openflexure_microscope/stage/base.py +++ b/openflexure_microscope/stage/base.py @@ -5,6 +5,8 @@ import numpy as np from labthings import StrictLock from typing_extensions import Literal +CoordinateType = Tuple[int, int, int] + class BaseStage(metaclass=ABCMeta): """ @@ -41,7 +43,7 @@ class BaseStage(metaclass=ABCMeta): @property @abstractmethod - def position(self) -> Tuple[int, int, int]: + def position(self) -> CoordinateType: """The current position, as a list""" @property @@ -64,7 +66,7 @@ class BaseStage(metaclass=ABCMeta): @abstractmethod def move_rel( self, - displacement: Union[int, Tuple[int, int, int]], + displacement: Union[int, CoordinateType], axis: Optional[Literal["x", "y", "z"]] = None, backlash: bool = True, ): @@ -74,7 +76,7 @@ class BaseStage(metaclass=ABCMeta): """ @abstractmethod - def move_abs(self, final: Tuple[int, int, int], **kwargs): + def move_abs(self, final: CoordinateType, **kwargs): """Make an absolute move to a position""" @abstractmethod @@ -87,7 +89,7 @@ class BaseStage(metaclass=ABCMeta): def scan_linear( self, - rel_positions: List[Tuple[int, int, int]], + rel_positions: List[CoordinateType], backlash: bool = True, return_to_start: bool = True, ): diff --git a/openflexure_microscope/stage/sanga.py b/openflexure_microscope/stage/sanga.py index 2b8e4c92..ed9f3c54 100644 --- a/openflexure_microscope/stage/sanga.py +++ b/openflexure_microscope/stage/sanga.py @@ -1,6 +1,7 @@ import logging import time from collections.abc import Iterable +from types import GeneratorType from typing import Optional, Tuple, Union import numpy as np @@ -11,6 +12,19 @@ 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 @@ -30,11 +44,9 @@ class SangaStage(BaseStage): self.port = port self.board = Sangaboard(port, **kwargs) - self._backlash = ( - None # Initialise backlash storage, used by property setter/getter - ) + # 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 @@ -56,11 +68,11 @@ class SangaStage(BaseStage): return 3 @property - def position(self): + def position(self) -> Tuple[int, int, int]: return self.board.position @property - def backlash(self): + 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, @@ -75,8 +87,12 @@ class SangaStage(BaseStage): 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 self._backlash is not None: + 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) @@ -98,13 +114,16 @@ class SangaStage(BaseStage): if "backlash" in config: # Construct backlash array backlash = axes_to_array(config["backlash"], ["x", "y", "z"], [0, 0, 0]) - self.backlash = backlash + 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""" - blsh = self.backlash.tolist() + 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, @@ -114,7 +133,7 @@ class SangaStage(BaseStage): def move_rel( self, - displacement: Union[int, Tuple[int, int, int]], + displacement: Union[int, Tuple[int, int, int], np.ndarray], axis: Optional[Literal["x", "y", "z"]] = None, backlash: bool = True, ): @@ -122,11 +141,19 @@ class SangaStage(BaseStage): 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 not backlash or self.backlash is None: - return self.board.move_rel(displacement, axis=axis) # 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 @@ -137,37 +164,42 @@ class SangaStage(BaseStage): # 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, + # Calculate displacement array + displacement_array: np.ndarray = _displacement_to_array( + displacement, axis ) - displacement = move + 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)}") - initial_move = np.array(displacement, dtype=np.int) - # 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: + # 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 = displacement_array initial_move -= np.where( - self.backlash * displacement < 0, + 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) - if np.any(displacement - initial_move != 0): + # 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 - initial_move) + 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: Tuple[int, int, int], **kwargs): + def move_abs(self, final: Union[Tuple[int, int, int], np.ndarray], **kwargs): """Make an absolute move to a position """ with self.lock: @@ -220,15 +252,21 @@ class SangaStage(BaseStage): class SangaDeltaStage(SangaStage): def __init__( - self, port=None, flex_h=80, flex_a=50, flex_b=50, camera_angle=0, **kwargs + 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 = flex_h - self.flex_a = flex_a - self.flex_b = flex_b + 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 = (camera_angle / 180) * np.pi - self.R_camera = np.array( + 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], @@ -239,13 +277,15 @@ class SangaDeltaStage(SangaStage): logging.debug(self.R_camera) # Transformation matrix converting delta into cartesian - x_fac = -1 * np.multiply( + x_fac: np.float = -1 * np.multiply( np.divide(2, np.sqrt(3)), np.divide(self.flex_b, self.flex_h) ) - y_fac = -1 * np.divide(self.flex_b, self.flex_h) - z_fac = np.multiply(np.divide(1, 3), np.divide(self.flex_b, self.flex_a)) + y_fac: np.float = -1 * np.divide(self.flex_b, self.flex_h) + z_fac: np.float = np.multiply( + np.divide(1, 3), np.divide(self.flex_b, self.flex_a) + ) - self.Tvd = np.array( + self.Tvd: np.ndarray = np.array( [ [-x_fac, x_fac, 0], [0.5 * y_fac, 0.5 * y_fac, -y_fac], @@ -254,40 +294,76 @@ class SangaDeltaStage(SangaStage): ) logging.debug(self.Tvd) - self.Tdv = np.linalg.inv(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.dot(self.Tvd, self.board.position) + position: np.ndarray = np.dot(self.Tvd, self.raw_position) - position = np.dot(np.linalg.inv(self.R_camera), 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, axis=None, backlash=True): + 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 - displacement = np.dot(self.R_camera, displacement) + camera_displacement_array: np.ndarray = np.dot( + self.R_camera, cartesian_displacement_array + ) # Transform into delta coordinates - displacement = np.dot(self.Tdv, displacement) + delta_displacement_array: np.ndarray = np.dot( + self.Tdv, camera_displacement_array + ) - logging.debug("Delta displacement: %s", (displacement)) + logging.debug("Delta displacement: %s", (delta_displacement_array)) # Do the move - SangaStage.move_rel(self, displacement, axis=None, backlash=backlash) + SangaStage.move_rel( + self, delta_displacement_array, axis=None, backlash=backlash + ) - def move_abs(self, final, **kwargs): + def move_abs(self, final: Union[Tuple[int, int, int], np.ndarray], **kwargs): # Transform into camera coordinates - final = np.dot(self.R_camera, final) + camera_final_array: np.ndarray = np.dot(self.R_camera, final) # Transform into delta coordinates - final = np.dot(self.Tdv, final) + 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, final, **kwargs) + SangaStage.move_abs(self, delta_final_array, **kwargs) diff --git a/openflexure_microscope/utilities.py b/openflexure_microscope/utilities.py index 58d816ca..b0b22e28 100644 --- a/openflexure_microscope/utilities.py +++ b/openflexure_microscope/utilities.py @@ -1,18 +1,25 @@ import base64 import copy import logging +import sys import time from contextlib import contextmanager -from typing import Dict, List, Optional +from typing import Dict, List, Optional, 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): - self.name = name - self.start = None - self.end = None + 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() @@ -22,19 +29,27 @@ class Timer(object): logging.debug("%s time: %s", self.name, self.end - self.start) -def deserialise_array_b64(b64_string: str, dtype: str, shape: List[int]): - flat_arr = np.frombuffer(base64.b64decode(b64_string), dtype) +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): - b64_string = base64.b64encode(npy_arr).decode("ascii") - dtype = str(npy_arr.dtype) - shape = npy_arr.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): +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. @@ -43,7 +58,7 @@ def ndarray_to_json(arr: np.ndarray): return {"@type": "ndarray", "dtype": dtype, "shape": shape, "base64": b64_string} -def json_to_ndarray(json_dict: dict): +def json_to_ndarray(json_dict: JSONArrayType): if not json_dict.get("@type") != "ndarray": logging.warning("No valid @type attribute found. Conversion may fail.") for required_param in ("dtype", "shape", "base64"): @@ -52,7 +67,7 @@ def json_to_ndarray(json_dict: dict): b64_string: Optional[str] = json_dict.get("base64") dtype: Optional[str] = json_dict.get("dtype") - shape: Optional[List[int]] = json_dict.get("shape") + shape: Optional[Tuple[int, ...]] = json_dict.get("shape") if b64_string and dtype and shape: return deserialise_array_b64(b64_string, dtype, shape) diff --git a/poetry.lock b/poetry.lock index d7a4f526..ff67e3a7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -8,20 +8,20 @@ python-versions = "*" [[package]] name = "apispec" -version = "3.3.2" +version = "4.0.0" description = "A pluggable API specification generator. Currently supports the OpenAPI Specification (f.k.a. the Swagger specification)." category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.dependencies] PyYAML = {version = ">=3.10", optional = true, markers = "extra == \"yaml\""} [package.extras] -dev = ["PyYAML (>=3.10)", "prance[osv] (>=0.11)", "marshmallow (>=2.19.2)", "pytest", "mock", "flake8 (==3.8.3)", "flake8-bugbear (==20.1.4)", "pre-commit (>=2.4,<3.0)", "tox"] -docs = ["marshmallow (>=2.19.2)", "pyyaml (==5.3.1)", "sphinx (==3.2.1)", "sphinx-issues (==1.2.0)", "sphinx-rtd-theme (==0.5.0)"] +dev = ["PyYAML (>=3.10)", "prance[osv] (>=0.11)", "marshmallow (>=3.0.0)", "pytest", "mock", "flake8 (==3.8.3)", "flake8-bugbear (==20.1.4)", "pre-commit (>=2.4,<3.0)", "tox"] +docs = ["marshmallow (>=3.0.0)", "pyyaml (==5.3.1)", "sphinx (==3.2.1)", "sphinx-issues (==1.2.0)", "sphinx-rtd-theme (==0.5.0)"] lint = ["flake8 (==3.8.3)", "flake8-bugbear (==20.1.4)", "pre-commit (>=2.4,<3.0)"] -tests = ["PyYAML (>=3.10)", "prance[osv] (>=0.11)", "marshmallow (>=2.19.2)", "pytest", "mock"] +tests = ["PyYAML (>=3.10)", "prance[osv] (>=0.11)", "marshmallow (>=3.0.0)", "pytest", "mock"] validation = ["prance[osv] (>=0.11)"] yaml = ["PyYAML (>=3.10)"] @@ -278,7 +278,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "3.1.0" +version = "3.1.1" description = "Read metadata from Python packages" category = "main" optional = false @@ -288,8 +288,8 @@ python-versions = ">=3.6" zipp = ">=0.5" [package.extras] -docs = ["sphinx", "rst.linker"] -testing = ["packaging", "pep517", "unittest2", "importlib-resources (>=1.3)"] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] [[package]] name = "iniconfig" @@ -336,28 +336,21 @@ i18n = ["Babel (>=0.8)"] [[package]] name = "labthings" -version = "1.1.5" +version = "1.2.2" description = "Python implementation of LabThings, based on the Flask microframework" category = "main" optional = false -python-versions = "^3.6" -develop = true +python-versions = ">=3.6,<4.0" [package.dependencies] -apispec = "^3.2.0" -apispec_webframeworks = "^0.5.2" -Flask = "^1.1.1" -flask-cors = "^3.0.8" -marshmallow = "^3.4.0" -webargs = "^6.0.0" +apispec = ">=3.2,<5.0" +apispec_webframeworks = ">=0.5.2,<0.6.0" +Flask = ">=1.1.1,<2.0.0" +flask-cors = ">=3.0.8,<4.0.0" +marshmallow = ">=3.4.0,<4.0.0" +webargs = ">=6.0.0,<7.0.0" zeroconf = ">=0.24.5,<0.29.0" -[package.source] -type = "git" -url = "https://github.com/labthings/python-labthings" -reference = "master" -resolved_reference = "e9ed2444c08e3854ebefdadaa753700401392c97" - [[package]] name = "lazy-object-proxy" version = "1.4.3" @@ -428,6 +421,25 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "numpy-stubs" +version = "0.0.1" +description = "" +category = "main" +optional = false +python-versions = "*" +develop = false + +[package.dependencies] +numpy = ">=1.16.0" +typing_extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} + +[package.source] +type = "git" +url = "https://github.com/numpy/numpy-stubs.git" +reference = "master" +resolved_reference = "c49d2d6875971a669a166ea93ef998911af283a1" + [[package]] name = "opencv-python-headless" version = "4.4.0.44" @@ -441,7 +453,7 @@ numpy = ">=1.17.3" [[package]] name = "packaging" -version = "20.4" +version = "20.7" description = "Core utilities for Python packages" category = "main" optional = false @@ -449,7 +461,6 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] pyparsing = ">=2.0.2" -six = "*" [[package]] name = "pastel" @@ -956,7 +967,7 @@ rpi = ["RPi.GPIO"] [metadata] lock-version = "1.1" python-versions = "^3.7.3" -content-hash = "e2f1011751b49fc5837f475f5304822216724a89f877ee5daf9c281cd8830673" +content-hash = "2c0faa244099ec3433edfc2cb9321f5de8bd5c0c32e135cae23f91e717cb07d7" [metadata.files] alabaster = [ @@ -964,8 +975,8 @@ alabaster = [ {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, ] apispec = [ - {file = "apispec-3.3.2-py2.py3-none-any.whl", hash = "sha256:a1df9ec6b2cd0edf45039ef025abd7f0660808fa2edf737d3ba1cf5ef1a4625b"}, - {file = "apispec-3.3.2.tar.gz", hash = "sha256:d23ebd5b71e541e031b02a19db10b5e6d5ef8452c552833e3e1afc836b40b1ad"}, + {file = "apispec-4.0.0-py2.py3-none-any.whl", hash = "sha256:20d271f7c8d130719be223fdb122af391ff8d59fb24958c793f632305b87f8ed"}, + {file = "apispec-4.0.0.tar.gz", hash = "sha256:360e28e5e84a4d7023b16de2b897327fe3da63ddc8e01f9165b9113b7fe1c48a"}, ] apispec-webframeworks = [ {file = "apispec-webframeworks-0.5.2.tar.gz", hash = "sha256:0db35b267914b3f8c562aca0261957dbcb4176f255eacc22520277010818dcf3"}, @@ -1090,8 +1101,8 @@ imagesize = [ {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, ] importlib-metadata = [ - {file = "importlib_metadata-3.1.0-py2.py3-none-any.whl", hash = "sha256:590690d61efdd716ff82c39ca9a9d4209252adfe288a4b5721181050acbd4175"}, - {file = "importlib_metadata-3.1.0.tar.gz", hash = "sha256:d9b8a46a0885337627a6430db287176970fff18ad421becec1d64cfc763c2099"}, + {file = "importlib_metadata-3.1.1-py3-none-any.whl", hash = "sha256:6112e21359ef8f344e7178aa5b72dc6e62b38b0d008e6d3cb212c5b84df72013"}, + {file = "importlib_metadata-3.1.1.tar.gz", hash = "sha256:b0c2d3b226157ae4517d9625decf63591461c66b3a808c2666d538946519d170"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -1109,7 +1120,10 @@ jinja2 = [ {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, ] -labthings = [] +labthings = [ + {file = "labthings-1.2.2-py3-none-any.whl", hash = "sha256:0f4fb76feed5ce9213d471f97700c4b52d9563f940954e1efb644889d3381d2d"}, + {file = "labthings-1.2.2.tar.gz", hash = "sha256:62a8c5bceea25883a7f329a8e19bd95d211d4fe8ac7eb39ee2a2bd51befa4bd5"}, +] lazy-object-proxy = [ {file = "lazy-object-proxy-1.4.3.tar.gz", hash = "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"}, {file = "lazy_object_proxy-1.4.3-cp27-cp27m-macosx_10_13_x86_64.whl", hash = "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442"}, @@ -1224,6 +1238,7 @@ numpy = [ {file = "numpy-1.19.2-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:0bfd85053d1e9f60234f28f63d4a5147ada7f432943c113a11afcf3e65d9d4c8"}, {file = "numpy-1.19.2.zip", hash = "sha256:0d310730e1e793527065ad7dde736197b705d0e4c9999775f212b03c44a8484c"}, ] +numpy-stubs = [] opencv-python-headless = [ {file = "opencv-python-headless-4.4.0.44.tar.gz", hash = "sha256:6eefcacfb9b2da305277e1a93c7bf074dcd10b7aa154a0c963ded08fc0ffc02e"}, {file = "opencv_python_headless-4.4.0.44-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:0896413b35b4b64acae42b84f740a458e0520d51f806946adf185e710a2ab300"}, @@ -1243,8 +1258,8 @@ opencv-python-headless = [ {file = "opencv_python_headless-4.4.0.44-cp38-cp38-win_amd64.whl", hash = "sha256:93f251c28739d8e8ade8898ddcbd75dcec60f779d9534644c568e7e65e2b76de"}, ] packaging = [ - {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, - {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, + {file = "packaging-20.7-py2.py3-none-any.whl", hash = "sha256:eb41423378682dadb7166144a4926e443093863024de508ca5c9737d6bc08376"}, + {file = "packaging-20.7.tar.gz", hash = "sha256:05af3bb85d320377db281cf254ab050e1a7ebcbf5410685a9a407e18a1f81236"}, ] pastel = [ {file = "pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364"}, diff --git a/pyproject.toml b/pyproject.toml index 68b9d60d..1f65701a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.masonry.api" [tool.poetry] name = "openflexure-microscope-server" -version = "2.8.0" +version = "2.9.0" description = "Python module, and Flask-based web API, to run the OpenFlexure Microscope." authors = [ @@ -43,10 +43,11 @@ expiringdict = "^1.2.1" camera-stage-mapping = "0.1.4" picamerax = ">=20.9.1" pyyaml = "^5.3.1" -labthings = {git = "https://github.com/labthings/python-labthings", develop = true} -typing-extensions = "^3.7.4" # Needed for some type-hints in Python < 3.8 (e.g. Literal) pytest-cov = "^2.10.1" piexif = "^1.1.3" +labthings = "1.2.2" +typing-extensions = "^3.7.4" # Needed for some type-hints in Python < 3.8 (e.g. Literal) +numpy-stubs = {git = "https://github.com/numpy/numpy-stubs.git"} # Needed for Numpy < 1.20 [tool.poetry.extras] rpi = ["RPi.GPIO"] @@ -66,7 +67,7 @@ poethepoet = "^0.9.0" freezegun = "^1.0.0" [tool.black] -exclude = '(\.eggs|\.git|\.venv|node_modules/)' +exclude = '(\.eggs|\.git|\.venv|\venv|node_modules/)' [tool.isort] multi_line_output = 3