Migrated scan plugin

This commit is contained in:
Joel Collins 2019-12-18 11:06:02 +00:00
parent 480848b009
commit dee77c295a
13 changed files with 442 additions and 290 deletions

View file

@ -1,8 +1,7 @@
__all__ = ["Microscope", "config", "task", "utilities"]
__all__ = ["Microscope", "config", "utilities"]
__version__ = "0.1.0"
from .microscope import Microscope
from . import config
from . import utilities
from . import task
from . import common

View file

@ -317,9 +317,6 @@ class PiCameraStreamer(BaseCamera):
"""
Change the camera zoom, handling re-centering and scaling.
"""
logging.warning(
"set_zoom is deprecated. Please use the 'zoom' property in picamera_settings."
)
with self.lock:
self.status["zoom_value"] = float(zoom_value)
if self.status["zoom_value"] < 1:

View file

@ -1,12 +1,17 @@
import logging
from flask import current_app
from . import EXTENSION_NAME
def _current_labthing():
app = current_app
app = current_app._get_current_object()
if not app:
return None
logging.debug("Active app extensions:")
logging.debug(app.extensions)
logging.debug("Active labthing:")
logging.debug(app.extensions[EXTENSION_NAME])
return app.extensions[EXTENSION_NAME]
@ -30,3 +35,15 @@ def find_device(device_name, labthing_instance=None):
return labthing_instance.devices[device_name]
else:
return None
def find_plugin(plugin_name, labthing_instance=None):
if not labthing_instance:
labthing_instance = _current_labthing()
logging.debug("Current labthing:")
logging.debug(_current_labthing())
if plugin_name in labthing_instance.plugins:
return labthing_instance.plugins[plugin_name]
else:
return None

View file

@ -53,6 +53,14 @@ class LabThing(object):
# TODO: Add plugin routes
for plugin_view_id, plugin_view in plugin_object.views.items():
# Add route to the plugins blueprint
self.add_resource(
plugin_view["view"],
"/plugins" + plugin_view["rule"],
**plugin_view["kwargs"],
)
### Resource stuff
def _complete_url(self, url_part, registration_prefix):

View file

@ -29,6 +29,8 @@ class BasePlugin:
self.name = name
self.methods = {}
@property
def views(self):
return self._views
@ -102,6 +104,13 @@ class BasePlugin:
def _name_uri_safe(self):
return snake_to_spine(self._name_python_safe)
def add_method(self, method_name, method):
self.methods[method_name] = method
if not hasattr(self, method_name):
setattr(self, method_name, method)
else:
logging.warning("Unable to bind method to plugin. Method name already exists.")
def find_plugins(plugin_path, module_name="plugins"):
print(f"Loading plugins from {plugin_path}")

View file

@ -4,6 +4,7 @@ from functools import wraps
from .thread import TaskThread
from flask import copy_current_request_context
class TaskMaster:
def __init__(self, *args, **kwargs):
@ -26,7 +27,8 @@ class TaskMaster:
return {t.id: t.state for t in self._tasks}
def new(self, f, *args, **kwargs):
task = TaskThread(target=f, args=args, kwargs=kwargs)
# copy_current_request_context allows threads to access flask current_app
task = TaskThread(target=copy_current_request_context(f), args=args, kwargs=kwargs)
self._tasks.append(task)
return task

View file

@ -13,7 +13,6 @@ from openflexure_microscope.camera.mock import MockStreamer
from openflexure_microscope.utilities import serialise_array_b64
from openflexure_microscope.plugins import PluginLoader
from openflexure_microscope.task import TaskOrchestrator
from openflexure_microscope.common.lock import CompositeLock
from openflexure_microscope.config import user_settings
@ -46,9 +45,6 @@ class Microscope:
# Initialise with an empty composite lock
self.lock = CompositeLock([])
# Create a task orchestrator
self.task = TaskOrchestrator()
# Apply settings loaded from file
self.apply_settings(user_settings.load())
@ -151,26 +147,6 @@ class Microscope:
else:
return False
# Create unified state
@property
def state(self):
"""Dictionary of the basic microscope state.
Return:
dict: Dictionary containing position data,
and :py:attr:`openflexure_microscope.camera.base.BaseCamera.status`
"""
# DEPRECATED
logging.warning(
"Microscope.state is deprecated. Use Microscope.status instead. State will be removed in a future version."
)
state = {
"camera": self.camera.status,
"stage": self.stage.status,
"plugin": self.plugins.state,
"version": pkg_resources.get_distribution("openflexure_microscope").version,
}
return state
# Create unified status
@property
@ -256,22 +232,6 @@ class Microscope:
self.stage.save_settings()
user_settings.save(current_config, backup=True)
@property
def config(self) -> dict:
logging.warning(
"Reading microscope through config property is deprecated.\
Please use read_settings method instead."
)
return self.read_settings()
@config.setter
def config(self, config: dict) -> None:
logging.warning(
"Setting microscope through config property is deprecated.\
Please use apply_settings method instead."
)
self.apply_settings(config)
@property
def metadata(self):
"""

View file

@ -1,4 +0,0 @@
__all__ = ["Plugin", "HelloWorldAPI", "IdentifyAPI"]
from .plugin import Plugin
from .api import HelloWorldAPI, IdentifyAPI

View file

@ -1,100 +0,0 @@
from openflexure_microscope.api.utilities import JsonResponse
from openflexure_microscope.api.views import MicroscopeViewPlugin
from openflexure_microscope.exceptions import TaskDeniedException
from flask import request, Response, escape, jsonify, abort
class IdentifyAPI(MicroscopeViewPlugin):
"""
A simple example API plugin, attached through the main microscope plugin.
"""
def get(self):
"""
Method to call when an HTTP GET request is made.
"""
data = (
self.plugin.identify()
) # Call a method from our plugin, using the MicroscopeViewPlugin.plugin shortcut
return Response(escape(data))
class HelloWorldAPI(MicroscopeViewPlugin):
"""
A method to create, set, and return a new microscope parameter.
"""
def get(self):
"""
Method to call when an HTTP GET request is made.
"""
# If the microscope does not already contain our plugin_string attribute
if not hasattr(self.microscope, "plugin_string"):
# Make a string, using the MicroscopeViewPlugin.plugin shortcut
self.microscope.plugin_string = self.plugin.hello_world()
return Response(self.microscope.plugin_string)
def post(self):
"""
Method to call when an HTTP POST request is made.
Assumes request will include a JSON payload.
"""
# Get payload JSON
payload = JsonResponse(request)
# Extract a value from the JSON key 'plugin_string', and convert to a string. If no value is given, default to empty.
new_plugin_string = payload.param("plugin_string", default="", convert=str)
if new_plugin_string: # If not None or empty
# Set microscope attribute to the specified string
self.microscope.plugin_string = new_plugin_string
return Response(self.microscope.plugin_string)
class LongRunningAPI(MicroscopeViewPlugin):
"""
An example API plugin that uses a long-running plugin method.
"""
def post(self):
"""
Method to call when an HTTP POST request is made.
"""
# Get payload JSON
payload = JsonResponse(request)
# Extract a values from the JSON payload.
time_to_run = payload.param("time", default=10, convert=int)
# Attach the long-running method as a microscope task
try:
task = self.microscope.task.start(self.plugin.long_running, time_to_run)
return jsonify(task.state), 201
except TaskDeniedException:
return abort(409)
class SomeExceptionAPI(MicroscopeViewPlugin):
"""
An example API plugin that uses a long-running but broken plugin method.
"""
def post(self):
"""
Method to call when an HTTP POST request is made.
"""
# Get payload JSON
payload = JsonResponse(request)
# Attach the long-running method as a microscope task
try:
task = self.microscope.task.start(self.plugin.some_exception)
return jsonify(task.state), 201
except TaskDeniedException:
return abort(409)

View file

@ -1,63 +0,0 @@
import random
import time
from openflexure_microscope.plugins import MicroscopePlugin
from .api import IdentifyAPI, HelloWorldAPI, LongRunningAPI, SomeExceptionAPI
class Plugin(MicroscopePlugin):
"""
A set of default plugins
"""
api_views = {
"/identify": IdentifyAPI,
"/hello": HelloWorldAPI,
"/long_running": LongRunningAPI,
"/some_exception": SomeExceptionAPI,
}
def identify(self):
"""
Demonstrate access to Microscope.camera, and Microscope.stage
"""
response = "My parent camera is {}, and my parent stage is {}.".format(
self.microscope.camera, self.microscope.stage
)
print(response)
return response
def hello_world(self):
"""
Demonstrate passive method
"""
return "Hello world!"
def some_exception(self):
"""
Demonstrate some broken plugin method that would be long running
"""
with self.microscope.lock:
time.sleep(3)
result = 15.0 / 0
return result
def long_running(self, t_run):
"""
Demonstrate a long-running method that requires microscope hardware
"""
print("Starting a long-running task...")
n_array = []
print("Acquiring composite microscope lock...")
with self.microscope.camera.lock, self.microscope.stage.lock:
for _ in range(t_run):
n_array.append(random.random())
time.sleep(1)
print("Long-running task finished! Releasing locks.")
return n_array

View file

@ -355,6 +355,10 @@ class FastAutofocusAPI(MethodView):
autofocus_plugin_v2 = BasePlugin("autofocus")
autofocus_plugin_v2.add_method("fast_autofocus", fast_autofocus)
autofocus_plugin_v2.add_method("autofocus", autofocus)
autofocus_plugin_v2.add_view("/measure_sharpness", MeasureSharpnessAPI)
autofocus_plugin_v2.add_view("/autofocus", AutofocusAPI)
autofocus_plugin_v2.add_view("/fast_autofocus", FastAutofocusAPI)

View file

@ -0,0 +1,399 @@
import numpy as np
import itertools
import logging
import uuid
from typing import Tuple
from functools import reduce
from openflexure_microscope.camera.base import generate_basename
from openflexure_microscope.common.labthings.find import find_device, find_plugin
from openflexure_microscope.common.labthings.plugins import BasePlugin
from openflexure_microscope.devel import (
JsonResponse,
request,
jsonify,
taskify,
abort,
update_task_progress,
update_task_data,
)
from flask.views import MethodView
import time
### Grid construction
def construct_grid(initial, step_sizes, n_steps, style="raster"):
"""
Given an initial position, step sizes, and number of steps,
construct a 2-dimensional list of scan x-y positions.
"""
arr = []
for i in range(n_steps[0]): # x axis
arr.append([])
for j in range(n_steps[1]): # y axis
# Create a coordinate array
coord = [initial[ax] + [i, j][ax] * step_sizes[ax] for ax in range(2)]
# Append coordinate array to position grid
arr[i].append(tuple(coord))
# Style modifiers
if style == "snake":
for i, line in enumerate(arr):
if i % 2 != 0:
line.reverse()
return arr
def flatten_grid(grid):
"""
Convert a 3D list of scan positions into a flat list
of sequential positions.
"""
grid = list(itertools.chain(*grid))
return grid
### Progress
_images_to_be_captured: int = 1
_images_captured_so_far: int = 0
def progress():
progress = (_images_captured_so_far / _images_to_be_captured) * 100
logging.info(progress)
return progress
### Capturing
def capture(
microscope,
basename,
scan_id,
temporary: bool = False,
use_video_port: bool = False,
resize: Tuple[int, int] = None,
bayer: bool = False,
metadata: dict = {},
tags: list = [],
):
# Construct a tile filename
filename = "{}_{}_{}_{}".format(basename, *microscope.stage.position)
folder = "SCAN_{}".format(basename)
# Create output object
output = microscope.camera.new_image(
temporary=temporary, filename=filename, folder=folder
)
# Capture
microscope.camera.capture(
output.file, use_video_port=use_video_port, resize=resize, bayer=bayer
)
# Affix metadata
if "scan" not in tags:
tags.append("scan")
# Inject system metadata
output.put_metadata(microscope.metadata, system=True)
# Insert custom metadata
output.put_metadata(metadata)
# Insert custom tags
output.put_tags(tags)
### Scanning
def tile(
microscope,
basename: str = None,
temporary: bool = False,
step_size: int = [2000, 1500, 100],
grid: list = [3, 3, 5],
style="raster",
autofocus_dz: int = 50,
use_video_port: bool = False,
resize: Tuple[int, int] = None,
bayer: bool = False,
fast_autofocus=False,
metadata: dict = {},
tags: list = [],
):
global _images_to_be_captured
global _images_captured_so_far
# Keep task progress
# TODO: Make this line not nasty
_images_to_be_captured = reduce((lambda x, y: x * y), grid)
_images_captured_so_far = 0
# Generate a basename if none given
if not basename:
basename = generate_basename()
# Generate a stack ID
scan_id = uuid.uuid4().hex
# Store initial position
initial_position = microscope.stage.position
# Add scan metadata
if "time" not in metadata:
metadata["time"] = generate_basename()
metadata.update(
{
"scan_id": scan_id,
"basename": basename,
"scan_parameters": {
"step_size": step_size,
"grid": grid,
"style": style,
"autofocus_dz": autofocus_dz,
},
}
)
# Check if autofocus is enabled
autofocus_plugin = find_plugin("autofocus")
if (
autofocus_dz
and autofocus_plugin
and microscope.has_real_stage()
and microscope.has_real_camera()
):
autofocus_enabled = True
else:
autofocus_enabled = False
z_stack_dz = (
grid[2] * step_size[2] if grid[2] > 1 else 0
) # shorthand for Z stack range
# Construct an x-y grid (worry about z later)
x_y_grid = construct_grid(
initial_position, step_size[:2], grid[:2], style=style
)
# Keep the initial Z position the same as our current position
next_z = initial_position[2]
if fast_autofocus: # If fast autofocus is enabled, make
next_z += autofocus_dz / 2 # sure we start from the top of the range
initial_z = next_z # Save this value for use in raster scans
# Now step through each point in the x-y coordinate array
for line in x_y_grid:
# If rastering, rather than snake (or eventually spiral)
# Return focus to initial position
if style == "raster":
next_z = initial_z
logging.debug("Returning to initial z position")
microscope.stage.move_abs(
[line[0][0], line[0][1], next_z]
) # RWB: I think this line is redundant
for x_y in line:
# Move to new grid position without changing z
logging.debug("Moving to step {}".format([x_y[0], x_y[1], next_z]))
microscope.stage.move_abs([x_y[0], x_y[1], next_z])
# Refocus
if autofocus_enabled:
if fast_autofocus:
autofocus_plugin.fast_autofocus(
dz=autofocus_dz,
target_z=-z_stack_dz / 2.0, # Finish below the focus
initial_move_up=False, # We're already at the top of the scan
)
# TODO: save the focus data for future reference? Use it for diagnostics?
else:
logging.debug("Running autofocus")
autofocus_plugin.autofocus(
range(-3 * autofocus_dz, 4 * autofocus_dz, autofocus_dz)
)
logging.debug("Finished autofocus")
time.sleep(1) # TODO: Remove
# If we're not doing a z-stack, just capture
if grid[2] <= 1:
capture(
microscope,
basename,
scan_id,
temporary=temporary,
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
metadata=metadata,
tags=tags,
)
# Update task progress
_images_captured_so_far += 1
update_task_progress(progress())
else:
logging.debug("Entering z-stack")
stack(
microscope=microscope,
basename=basename,
temporary=temporary,
scan_id=scan_id,
step_size=step_size[2],
steps=grid[2],
center=not fast_autofocus, # fast_autofocus does this for us!
return_to_start=not fast_autofocus,
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
metadata=metadata,
tags=tags,
)
# Make sure we use our current best estimate of focus (i.e. the current position) next point
next_z = microscope.stage.position[2]
if fast_autofocus:
next_z += (
autofocus_dz / 2
) # Fast autofocus requires us to start at the top of the range
if grid[2] > 1:
next_z -= int(
grid[2] / 2.0 * step_size[2]
) # Z stacking means we're higher up to start with
logging.debug("Returning to {}".format(initial_position))
microscope.stage.move_abs(initial_position)
def stack(
microscope,
basename: str = None,
temporary: bool = False,
scan_id: str = None,
step_size: int = 100,
steps: int = 5,
center: bool = True,
return_to_start: bool = True,
use_video_port: bool = False,
resize: Tuple[int, int] = None,
bayer: bool = False,
metadata: dict = {},
tags: list = [],
):
global _images_captured_so_far
# Generate a basename if none given
if not basename:
basename = generate_basename()
# Generate a stack ID
if not scan_id:
scan_id = uuid.uuid4().hex
# Add scan metadata
if not "time" in metadata:
metadata["time"] = generate_basename()
# Store initial position
initial_position = microscope.stage.position
with microscope.lock:
# Move to center scan
if center:
logging.debug("Moving to starting position")
microscope.stage.move_rel([0, 0, int((-step_size * steps) / 2)])
for i in range(steps):
time.sleep(0.1)
logging.debug("Capturing...")
capture(
microscope,
basename,
scan_id,
temporary=temporary,
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
metadata=metadata,
tags=tags,
)
# Update task progress
_images_captured_so_far += 1
update_task_progress(progress())
if i != steps - 1:
logging.debug("Moving z by {}".format(step_size))
microscope.stage.move_rel([0, 0, step_size])
if return_to_start:
logging.debug("Returning to {}".format(initial_position))
microscope.stage.move_abs(initial_position)
### Web views
class TileScanAPI(MethodView):
def post(self):
payload = JsonResponse(request)
microscope = find_device("openflexure_microscope")
if not microscope:
abort(503, "No microscope connected. Unable to autofocus.")
# Get params
filename = payload.param("filename")
temporary = payload.param("temporary", default=False, convert=bool)
step_size = payload.param("step_size", default=[2000, 1500, 100], convert=list)
step_size = [int(i) for i in step_size]
grid = payload.param("grid", default=[3, 3, 5], convert=list)
grid = [int(i) for i in grid]
style = payload.param("style", default="raster", convert=str)
autofocus_dz = payload.param("autofocus_dz", default=50, convert=int)
fast_autofocus = payload.param("fast_autofocus", default=False, convert=bool)
use_video_port = payload.param("use_video_port", default=True, convert=bool)
resize = payload.param("size", default=None)
if resize:
if ("width" in resize) and ("height" in resize):
resize = (
int(resize["width"]),
int(resize["height"]),
) # Convert dict to tuple
else:
abort(404)
bayer = payload.param("bayer", default=False, convert=bool)
metadata = payload.param("metadata", default={}, convert=dict)
tags = payload.param("tags", default=[], convert=list)
logging.info("Running tile scan...")
task = taskify(tile)(
microscope,
basename=filename,
temporary=temporary,
step_size=step_size,
grid=grid,
style=style,
autofocus_dz=autofocus_dz,
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
fast_autofocus=fast_autofocus,
metadata=metadata,
tags=tags,
)
# return a handle on the scan task
return jsonify(task.state), 201
scan_plugin_v2 = BasePlugin("scan")
scan_plugin_v2.add_view("/tile", TileScanAPI)

View file

@ -1,76 +0,0 @@
import logging
from openflexure_microscope.common.tasks import (
tasks,
states,
taskify,
cleanup_tasks,
remove_task,
)
# DEPRECATED
class TaskOrchestrator:
"""
DEPRECATED: Class responsible for spawning threaded tasks, and storing their returns.
A microscope should contain exactly one instance of `TaskOrchestrator`.
Attributes:
tasks (list): List of `Task` objects
"""
@property
def tasks(self):
logging.warning(
"TaskOrchestrator class is deprecated. Please use common.tasks.tasks().values() instead."
)
return tasks().values()
@property
def state(self):
"""
Returns a list of dictionary representations of all tasks in the session.
"""
logging.warning(
"TaskOrchestrator class is deprecated. Please use common.tasks.tasks() instead."
)
return states()
def task_from_id(self, task_id: str):
"""
Returns a particular task object with the specified `task_id`.
"""
return tasks()[task_id]
def start(self, function, *args, **kwargs):
"""
Attach and start a new long-running task, if allowed by `start_condition()`.
Returns an instance of :py:class:`openflexure_microscope.task.Task`, which can be used to check the state
or eventual return of the task.
Args:
function (function): The target function to run in the background
args, kwargs: Arguments that will be passed to `function` at runtime.
"""
logging.warning(
"TaskOrchestrator class is deprecated. Please use common.tasks.taskify() instead."
)
return taskify(function)(*args, **kwargs)
def clean(self):
"""
Remove all inactive tasks from the task array.
This will remove tasks that either finished or never started.
"""
logging.warning(
"TaskOrchestrator class is deprecated. Please use common.tasks.cleanup_tasks() instead."
)
cleanup_tasks()
def delete(self, task_id: str):
"""
Delete a given task by ID, only if task is not currently running.
"""
logging.warning(
"TaskOrchestrator class is deprecated. Please use common.tasks.remove_task() instead."
)
remove_task(task_id)