Merge remote-tracking branch 'origin/master' into deltastage

This commit is contained in:
Samuel McDermott 2020-08-27 15:46:17 +01:00
commit 5e9e8cb2a3
222 changed files with 30104 additions and 7329 deletions

View file

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

View file

@ -1,3 +0,0 @@
__all__ = ['utilities']
from . import utilities

View file

@ -1,207 +1,190 @@
#!/usr/bin/env python
import sys
import time
import atexit
import logging
import sys
import logging, logging.handlers
# Look for debug flag
if "-d" in sys.argv or "--debug" in sys.argv:
log_level = logging.DEBUG
else:
log_level = logging.INFO
# Set root logger level
root_log = logging.getLogger()
root_log.setLevel(log_level)
import os
import pkg_resources
from flask import (
Flask, jsonify, send_file)
from flask import Flask, send_file, abort
from serial import SerialException
from datetime import datetime
from flask_cors import CORS
from flask_cors import CORS, cross_origin
from openflexure_microscope.api.exceptions import JSONExceptionHandler
from openflexure_microscope.api.utilities import list_routes
from openflexure_microscope.api.utilities import list_routes, init_default_extensions
from openflexure_microscope import Microscope
from openflexure_microscope.config import JSONEncoder
from openflexure_microscope.paths import (
OPENFLEXURE_VAR_PATH,
OPENFLEXURE_EXTENSIONS_PATH,
settings_file_path,
logs_file_path,
)
from openflexure_microscope.camera.capture import build_captures_from_exif
from openflexure_microscope.config import USER_CONFIG_DIR
from openflexure_microscope.api.v1 import blueprints
from labthings import create_app
from labthings.extensions import find_extensions
# Import device modules
# NB this will eventually be handled by the RC file, so you can choose what device
# class should be attached.
try:
from openflexure_microscope.camera.pi import PiCameraStreamer
except ImportError:
logging.warning("Unable to import PiCameraStreamer")
from openflexure_microscope.camera.mock import MockStreamer
from openflexure_microscope.stage.sanga import SangaStage, SangaDeltaStage
from openflexure_microscope.stage.mock import MockStage
from openflexure_microscope.api.microscope import default_microscope as api_microscope
from openflexure_microscope.api.v2 import views
# Handle logging
is_gunicorn = "gunicorn" in os.environ.get("SERVER_SOFTWARE", "")
access_log = logging.getLogger('werkzeug')
# Block the access logs from propagating up to the root logger
access_log.propagate = False
DEFAULT_LOGFILE = os.path.join(USER_CONFIG_DIR, 'openflexure_microscope.log')
ROOT_LOGFILE = logs_file_path("openflexure_microscope.log")
ACCESS_LOGFILE = logs_file_path("openflexure_microscope.access.log")
if (__name__ == "__main__") or (not is_gunicorn):
# If imported, but not by gunicorn
print("Letting sys handle logs")
logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
else:
# Direct standard Python logging to file and console
root = logging.getLogger()
error_formatter = logging.Formatter("[%(asctime)s] [%(threadName)s] [%(levelname)s] %(message)s")
rotating_logfile = logging.handlers.RotatingFileHandler(
DEFAULT_LOGFILE,
maxBytes=1000000,
backupCount=7
)
error_handlers = [
rotating_logfile,
logging.StreamHandler()
]
for handler in error_handlers:
handler.setFormatter(error_formatter)
root.addHandler(handler)
root.setLevel(logging.getLogger("gunicorn.error").level)
# Create a dummy microscope object, with no hardware attachments
api_microscope = Microscope()
# Rebuild the capture list
# TODO: Offload to a thread?
stored_image_list = build_captures_from_exif()
# Basic log format
formatter = logging.Formatter(
"[%(asctime)s] [%(threadName)s] [%(levelname)s] %(message)s"
)
# Generate API URI based on version from filename
def uri(suffix, api_version, base=None):
if not base:
base = "/api/{}".format(api_version)
return_uri = base + suffix
logging.debug("Created app route: {}".format(return_uri))
return return_uri
# Create file handler
fh = logging.handlers.RotatingFileHandler(
ROOT_LOGFILE, maxBytes=1_000_000, backupCount=5
)
fh.setFormatter(formatter)
fh.setLevel(logging.DEBUG)
fh.propagate = False
# Create access log file handler
afh = logging.handlers.RotatingFileHandler(
ACCESS_LOGFILE, maxBytes=1_000_000, backupCount=5
)
afh.setFormatter(formatter)
afh.setLevel(logging.DEBUG)
afh.propagate = False
# Add file handler to root logger
root_log.addHandler(fh)
access_log.addHandler(afh)
# Log server paths being used
logging.info(f"Running with data path {OPENFLEXURE_VAR_PATH}")
logging.info("Creating app")
# Create flask app
app = Flask(__name__)
app.url_map.strict_slashes = False
app, labthing = create_app(
__name__,
prefix="/api/v2",
title=f"OpenFlexure Microscope {api_microscope.name}",
description="Test LabThing-based API for OpenFlexure Microscope",
types=["org.openflexure.microscope"],
version=pkg_resources.get_distribution("openflexure-microscope-server").version,
flask_kwargs={"static_url_path": "", "static_folder": "static/dist"},
)
CORS(app, resources=r'/api/*')
# Enable CORS for some routes outside of LabThings
cors = CORS(app)
# Make errors more API friendly
handler = JSONExceptionHandler(app)
# Use custom JSON encoder
labthing.json_encoder = JSONEncoder
app.json_encoder = JSONEncoder
# Attach lab devices
labthing.add_component(api_microscope, "org.openflexure.microscope")
# Attach extensions
if not os.path.isfile(OPENFLEXURE_EXTENSIONS_PATH):
init_default_extensions(OPENFLEXURE_EXTENSIONS_PATH)
for extension in find_extensions(OPENFLEXURE_EXTENSIONS_PATH):
labthing.register_extension(extension)
# Attach captures resources
labthing.add_view(views.CaptureList, f"/captures")
labthing.add_root_link(views.CaptureList, "captures")
labthing.add_view(views.CaptureView, f"/captures/<id>")
labthing.add_view(views.CaptureDownload, f"/captures/<id>/download/<filename>")
labthing.add_view(views.CaptureTags, f"/captures/<id>/tags")
labthing.add_view(views.CaptureAnnotations, f"/captures/<id>/annotations")
# Attach settings and state resources
labthing.add_view(views.SettingsProperty, f"/instrument/settings")
labthing.add_root_link(views.SettingsProperty, "instrumentSettings")
labthing.add_view(views.NestedSettingsProperty, "/instrument/settings/<path:route>")
labthing.add_view(views.StateProperty, "/instrument/state")
labthing.add_view(views.NestedStateProperty, "/instrument/state/<path:route>")
labthing.add_root_link(views.StateProperty, "instrumentState")
labthing.add_view(views.ConfigurationProperty, "/instrument/configuration")
labthing.add_view(
views.NestedConfigurationProperty, "/instrument/configuration/<path:route>"
)
labthing.add_root_link(views.ConfigurationProperty, "instrumentConfiguration")
# After app starts, but before first request, attach hardware to global microscope
@app.before_first_request
def attach_microscope():
# Create the microscope object globally (common to all spawned server threads)
global api_microscope, stored_image_list
logging.debug("First request made. Populating microscope with hardware...")
# Attach streams resources
labthing.add_view(views.MjpegStream, f"/streams/mjpeg")
labthing.add_view(views.SnapshotStream, f"/streams/snapshot")
# Initialise camera
logging.debug("Creating camera object...")
try:
api_camera = PiCameraStreamer()
except Exception as e:
logging.error(e)
logging.warning("No valid camera hardware found. Falling back to mock camera!")
api_camera = MockStreamer()
# Initialise stage
logging.debug("Creating stage object...")
try:
api_stage = SangaDeltaStage()
except Exception as e:
logging.error(e)
logging.warning("No valid stage hardware found. Falling back to mock stage!")
api_stage = MockStage()
# Attach devices to microscope
logging.debug("Attaching devices to microscope...")
api_microscope.attach(
api_camera,
api_stage
)
# Restore loaded capture array to camera object
logging.debug("Restoring captures...")
if stored_image_list:
api_microscope.camera.images = stored_image_list
logging.debug("Microscope successfully attached!")
# Attach microscope action resources
labthing.add_view(views.actions.ActionsView, "/actions")
labthing.add_root_link(views.actions.ActionsView, "actions")
for name, action in views.enabled_root_actions().items():
view_class = action["view_class"]
rule = action["rule"]
labthing.add_view(view_class, f"/actions{rule}")
# WEBAPP ROUTES
# API ROUTES
# Base routes
base_blueprint = blueprints.base.construct_blueprint(api_microscope)
app.register_blueprint(base_blueprint, url_prefix=uri('', 'v1'))
# Stage routes
stage_blueprint = blueprints.stage.construct_blueprint(api_microscope)
app.register_blueprint(stage_blueprint, url_prefix=uri('/stage', 'v1'))
# Camera routes
camera_blueprint = blueprints.camera.construct_blueprint(api_microscope)
app.register_blueprint(camera_blueprint, url_prefix=uri('/camera', 'v1'))
# Plugin routes
plugin_blueprint = blueprints.plugins.construct_blueprint(api_microscope)
app.register_blueprint(plugin_blueprint, url_prefix=uri('/plugin', 'v1'))
# Task routes
task_blueprint = blueprints.task.construct_blueprint(api_microscope)
app.register_blueprint(task_blueprint, url_prefix=uri('/task', 'v1'))
@app.route("/")
def openflexure_ev():
return app.send_static_file("index.html")
@app.route('/routes')
@app.route("/routes")
@cross_origin()
def routes():
"""
List of all connected API routes
.. :quickref: System; Routes
:>header Accept: application/json
:>header Content-Type: application/json
:status 200: stream active
"""
return jsonify(list_routes(app))
return list_routes(app)
@app.route('/log')
@app.route("/log")
def err_log():
"""
Most recent 1mb of log output
.. :quickref: System; Log
:>header Accept: application/json
:>header Content-Type: application/json
:status 200: stream active
"""
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
return send_file(
DEFAULT_LOGFILE,
as_attachment=True,
attachment_filename='openflexure_microscope_{}.log'.format(timestamp)
ROOT_LOGFILE,
as_attachment=True,
attachment_filename="openflexure_microscope_{}.log".format(timestamp),
)
@app.route("/api/v1/", defaults={"path": ""})
@app.route("/api/v1/<path:path>")
def api_v1_catch_all(path):
abort(410, "API v1 is no longer in use. Please upgrade your client.")
# Automatically clean up microscope at exit
def cleanup():
global api_microscope
logging.debug("App teardown started...")
logging.debug("Settling...")
time.sleep(0.5)
# Save config
logging.debug("Saving config for teardown...")
api_microscope.save_config(backup=True)
api_microscope.save_settings()
logging.debug("Settling...")
time.sleep(0.5)
@ -218,5 +201,9 @@ def cleanup():
atexit.register(cleanup)
# Start the app
if __name__ == "__main__":
app.run(host='0.0.0.0', port="5000", threaded=True, debug=True, use_reloader=False)
from labthings import Server
logging.info("Starting OpenFlexure Microscope Server...")
server = Server(app)
server.run(host="::", port=5000, debug=False, zeroconf=True)

View file

@ -0,0 +1,28 @@
import logging
import traceback
from contextlib import contextmanager
@contextmanager
def handle_extension_error(extension_name):
"""'gracefully' log an error if an extension fails to load."""
try:
yield
except Exception as e:
logging.error(
f"Exception loading builtin extension {extension_name}: \n{traceback.format_exc()}"
)
with handle_extension_error("autofocus"):
from .autofocus import autofocus_extension_v2
with handle_extension_error("scan"):
from .scan import scan_extension_v2
with handle_extension_error("zip builder"):
from .zip_builder import zip_extension_v2
with handle_extension_error("autostorage"):
from .autostorage import autostorage_extension_v2
with handle_extension_error("camera stage mapping"):
from camera_stage_mapping.ofm_extension import csm_extension
with handle_extension_error("lens shading calibration"):
from .picamera_autocalibrate import lst_extension_v2

View file

@ -0,0 +1,409 @@
from labthings import fields, find_component, current_action
from labthings.extensions import BaseExtension
from labthings.views import View, ActionView, PropertyView
from openflexure_microscope.devel import JsonResponse, request, abort
from openflexure_microscope.utilities import set_properties
import time
import numpy as np
import logging
from scipy import ndimage
from contextlib import contextmanager
from threading import Thread, Event
### Autofocus utilities
class JPEGSharpnessMonitor:
def __init__(self, microscope, timeout=60):
self.microscope = microscope
self.camera = microscope.camera
self.stage = microscope.stage
self.jpeg_sizes = []
self.jpeg_times = []
self.stage_positions = []
self.stage_times = []
self.stop_event = Event()
self.timeout = timeout
self.keep_alive()
self.background_thread = None
def is_alive(self):
if self.background_thread is None:
return False
else:
return self.background_thread.is_alive()
def should_stop(self):
import time
return time.time() - self.kept_alive > self.timeout
def keep_alive(self):
import time
self.kept_alive = time.time()
def start(self):
"Start monitoring sharpness by looking at JPEG size"
if not self.camera.stream_active:
logging.warn(
"Autofocus sharpness monitor was started but the camera isn't streaming. Attempting to start the stream..."
)
self.camera.start_stream_recording()
self.background_thread = Thread(target=self._measure_jpegs)
self.background_thread.start()
return self
def stop(self):
"Stop the background thread"
self.stop_event.set()
logging.info("Joining JPEG thread")
self.background_thread.join()
def _measure_jpegs(self):
"Function that runs in a background thread to record sharpness"
logging.debug("Starting sharpness measurement in background thread")
self.keep_alive()
logging.debug(f"_measure_jpegs stop_event: {self.stop_event.is_set()}")
while not self.stop_event.is_set() and not self.should_stop():
size_now = self.jpeg_size()
time_now = time.time()
self.jpeg_sizes.append(size_now)
self.jpeg_times.append(time_now)
logging.info("Exited JPEG measure loop")
if self.stop_event.is_set():
logging.debug("Cleanly stopped sharpness measurement in background thread")
if self.should_stop():
logging.debug("Sharpness measurement timed out and has stopped")
def jpeg_size(self):
"""Return the size of a frame from the MJPEG stream"""
return len(self.camera.get_frame())
def focus_rel(self, dz, backlash=False, **kwargs):
self.keep_alive()
self.stage_times.append(time.time())
self.stage_positions.append(self.stage.position)
self.stage.move_rel([0, 0, dz], backlash=backlash, **kwargs)
self.stage_times.append(time.time())
self.stage_positions.append(self.stage.position)
i = len(self.stage_positions) - 2
return i, self.stage_positions[-1][2]
def move_data(self, istart, istop=None):
"Extract sharpness as a function of (interpolated) z"
if istop is None:
istop = istart + 2
jpeg_times = np.array(self.jpeg_times)
jpeg_sizes = np.array(self.jpeg_sizes)
stage_times = np.array(self.stage_times)[istart:istop]
stage_zs = np.array(self.stage_positions)[istart:istop, 2]
try:
start = np.argmax(jpeg_times > stage_times[0])
stop = np.argmax(jpeg_times > stage_times[1])
except ValueError as e:
if np.sum(jpeg_times > stage_times[0]) == 0:
raise ValueError(
"No images were captured during the move of the stage. Perhaps the camera is not streaming images?"
)
else:
raise e
if stop < 1:
stop = len(jpeg_times)
logging.debug("changing stop to {}".format(stop))
jpeg_times = jpeg_times[start:stop]
jpeg_zs = np.interp(jpeg_times, stage_times, stage_zs)
return jpeg_times, jpeg_zs, jpeg_sizes[start:stop]
def sharpest_z_on_move(self, index):
"""Return the z position of the sharpest image on a given move"""
jt, jz, js = self.move_data(index)
if len(js) == 0:
raise ValueError(
"No images were captured during the move of the stage. Perhaps the camera is not streaming images?"
)
return jz[np.argmax(js)]
def data_dict(self):
"""Return the gathered data as a single convenient dictionary"""
data = {}
for k in ["jpeg_times", "jpeg_sizes", "stage_times", "stage_positions"]:
data[k] = getattr(self, k)
return data
def decimate_to(shape, image):
"""Decimate an image to reduce its size if it's too big."""
decimation = np.max(
np.ceil(np.array(image.shape, dtype=np.float)[: len(shape)] / np.array(shape))
)
return image[:: int(decimation), :: int(decimation), ...]
def sharpness_sum_lap2(rgb_image):
"""Return an image sharpness metric: sum(laplacian(image)**")"""
# image_bw=np.mean(decimate_to((1000,1000), rgb_image),2)
image_bw = np.mean(rgb_image, 2)
image_lap = ndimage.filters.laplace(image_bw)
return np.mean(image_lap.astype(np.float) ** 4)
def sharpness_edge(image):
"""Return a sharpness metric optimised for vertical lines"""
gray = np.mean(image.astype(float), 2)
n = 20
edge = np.array([[-1] * n + [1] * n])
return np.sum(
[np.sum(ndimage.filters.convolve(gray, W) ** 2) for W in [edge, edge.T]]
)
### Autofocus extension
def measure_sharpness(microscope, metric_fn=sharpness_sum_lap2):
"""Measure the sharpness of the camera's current view."""
if hasattr(microscope.camera, "array"):
return metric_fn(microscope.camera.array(use_video_port=True))
def autofocus(microscope, dz, settle=0.5, metric_fn=sharpness_sum_lap2):
"""Perform a simple autofocus routine.
The stage is moved to z positions (relative to current position) in dz,
and at each position an image is captured and the sharpness function
evaulated. We then move back to the position where the sharpness was
highest. No interpolation is performed.
dz is assumed to be in ascending order (starting at -ve values)
"""
camera = microscope.camera
stage = microscope.stage
with set_properties(stage, backlash=256), stage.lock, camera.lock:
sharpnesses = []
positions = []
camera.annotate_text = ""
for _ in stage.scan_z(dz, return_to_start=False):
if current_action() and current_action().stopped:
return
positions.append(stage.position[2])
time.sleep(settle)
sharpnesses.append(measure_sharpness(microscope, metric_fn))
newposition = positions[np.argmax(sharpnesses)]
stage.move_rel([0, 0, newposition - stage.position[2]])
return positions, sharpnesses
@contextmanager
def monitor_sharpness(microscope):
m = JPEGSharpnessMonitor(microscope)
m.start()
try:
yield m
finally:
m.stop()
def move_and_find_focus(microscope, dz):
"""Make a relative Z move and return the peak sharpness position"""
with monitor_sharpness(microscope) as m:
m.focus_rel(dz)
return m.sharpest_z_on_move(0)
def fast_autofocus(microscope, dz=2000, backlash=None):
"""Perform a down-up-down-up autofocus"""
with monitor_sharpness(
microscope
) as m, microscope.camera.lock, microscope.stage.lock:
i, z = m.focus_rel(-dz / 2)
i, z = m.focus_rel(dz)
fz = m.sharpest_z_on_move(i)
if backlash is None:
i, z = m.focus_rel(-dz) # move all the way to the start so it's consistent
else:
i, z = m.focus_rel(fz - z - backlash)
m.focus_rel(fz - z)
return m.data_dict()
def fast_up_down_up_autofocus(
microscope, dz=2000, target_z=0, initial_move_up=True, mini_backlash=25
):
"""Autofocus by measuring on the way down, and moving back up with feedback.
This autofocus method is very efficient, as it only passes the peak once.
The sequence of moves it performs is:
1. Move to the top of the range `dz/2` (can be disabled)
2. Move down by `dz` while monitoring JPEG size to find the focus.
3. Move back up to the `target_z` position, relative to the sharpest image.
4. Measure the sharpness, and compare against the curve recorded in (2) to \\
estimate how much further we need to go. Make this move, to reach our \\
target position.
Moving back to the target position in two steps allows us to correct for
backlash, by using the sharpness-vs-z curve as a rough encoder for Z.
Parameters:
dz: number of steps over which to scan (optional, default 2000)
target_z: we aim to finish at this position, relative to focus. This may
be useful if, for example, you want to acquire a stack of images in Z.
It is optional, and the default value of 0 will finish at the focus.
initial_move_up: (optional, default True) set this to `False` to move down
from the starting position. Mostly useful if you're able to combine
the initial move with something else, e.g. moving to the next scan point.
mini_backlash: (optional, default 25) is a small extra move made in step
3 to help counteract backlash. It should be small enough that you
would always expect there to be greater backlash than this. Too small
might slightly hurt accuracy, but is unlikely to be a big issue. Too big
may cause you to overshoot, which is a problem.
"""
with monitor_sharpness(
microscope
) as m, microscope.camera.lock, microscope.stage.lock:
# Ensure the MJPEG stream has started
microscope.camera.start_stream_recording()
df = dz # TODO: refactor so I actually use dz in the code below!
logging.debug("Initial move")
if initial_move_up:
m.focus_rel(df / 2)
# move down
logging.debug("Move down")
i, z = m.focus_rel(-df)
# now inspect where the sharpest point is, and estimate the sharpness
# (JPEG size) that we should find at the start of the Z stack
logging.debug("Get target_s")
jt, jz, js = m.move_data(i)
best_z = jz[np.argmax(js)]
target_s = np.interp(
[best_z + target_z], jz[::-1], js[::-1]
) # NB jz is decreasing
# now move to the start of the z stack
logging.debug("Move to the start of the z stack")
i, z = m.focus_rel(
best_z + target_z - z + mini_backlash
) # takes us to the start of the stack
# We've deliberately undershot - figure out how much further we should move based on the curve
logging.debug("Calculate remining movement")
current_js = m.jpeg_size()
imax = np.argmax(js) # we want to crop out just the bit below the peak
js = js[imax:] # NB z is in DECREASING order
jz = jz[imax:]
inow = np.argmax(
js < current_js
) # use the curve we recorded to estimate our position
# TODO: fancy interpolation stuff
# So, the Z position corresponding to our current sharpness value is zs[inow]
# That means we should move forwards, by best_z - zs[inow]
logging.debug("Correction move")
correction_move = best_z + target_z - jz[inow]
logging.debug(
"Fast autofocus scan: correcting backlash by moving {} steps".format(
correction_move
)
)
m.focus_rel(correction_move)
return m.data_dict()
class MeasureSharpnessAPI(View):
def post(self):
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(503, "No microscope connected. Unable to measure sharpness.")
return {"sharpness": measure_sharpness(microscope)}
class AutofocusAPI(ActionView):
"""
Run a standard autofocus
"""
args = {"dz": fields.List(fields.Int())}
def post(self, args):
payload = JsonResponse(request)
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(503, "No microscope connected. Unable to autofocus.")
# Figure out the range of z values to use
dz = np.array(args.get("dz", np.linspace(-300, 300, 7)))
if microscope.has_real_stage():
logging.debug("Running autofocus...")
# return a handle on the autofocus task
return autofocus(microscope, dz)
else:
abort(503, "No stage connected. Unable to autofocus.")
class FastAutofocusAPI(ActionView):
"""
Run a fast autofocus
"""
args = {
"dz": fields.Int(missing=2000),
"backlash": fields.Int(missing=25, minimum=0)
}
def post(self, args):
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(503, "No microscope connected. Unable to autofocus.")
# Figure out the parameters to use
dz = args.get("dz")
backlash = args.get("backlash")
if microscope.has_real_stage():
logging.debug("Running autofocus...")
# Acquire microscope lock with 1s timeout
with microscope.lock(timeout=1):
# Run fast_up_down_up_autofocus
return fast_up_down_up_autofocus(
microscope, dz=dz, mini_backlash=backlash
)
else:
abort(503, "No stage connected. Unable to autofocus.")
autofocus_extension_v2 = BaseExtension(
"org.openflexure.autofocus",
version="2.0.0",
description="Actions to move the microscope in Z and pick the point with the sharpest image.",
)
autofocus_extension_v2.add_method(fast_autofocus, "fast_autofocus")
autofocus_extension_v2.add_method(
fast_up_down_up_autofocus, "fast_up_down_up_autofocus"
)
autofocus_extension_v2.add_method(autofocus, "autofocus")
autofocus_extension_v2.add_view(MeasureSharpnessAPI, "/measure_sharpness", endpoint="measure_sharpness")
autofocus_extension_v2.add_view(AutofocusAPI, "/autofocus", endpoint="autofocus")
autofocus_extension_v2.add_view(FastAutofocusAPI, "/fast_autofocus", endpoint="fast_autofocus")

View file

@ -0,0 +1,272 @@
from labthings.extensions import BaseExtension
from labthings.views import View, PropertyView
from labthings import fields, find_component
from openflexure_microscope.paths import settings_file_path, check_rw
from openflexure_microscope.config import OpenflexureSettingsFile
from openflexure_microscope.captures.capture_manager import BASE_CAPTURE_PATH
from openflexure_microscope.captures.capture import build_captures_from_exif
from openflexure_microscope.api.utilities.gui import build_gui
from flask import abort
import logging
import os
import psutil
from sys import platform
AS_SETTINGS_PATH = settings_file_path("autostorage_settings.json")
def get_partitions():
return [disk.mountpoint for disk in psutil.disk_partitions() if "rw" in disk.opts]
def get_permissive_partitions():
return [partition for partition in get_partitions() if check_rw(partition)]
def get_permissive_locations():
return [
(partition, os.path.join(partition, "openflexure", "data", "micrographs"))
for partition in get_permissive_partitions()
]
def get_current_location(capture_manager):
return capture_manager.paths.get("default")
def set_current_location(capture_manager, location: str):
if not os.path.isdir(location):
os.makedirs(location)
logging.debug("Updating location...")
capture_manager.paths.update({"default": location})
logging.debug("Rebuilding captures...")
capture_manager.rebuild_captures()
logging.debug("Capture location changed successfully.")
def get_default_location():
return BASE_CAPTURE_PATH
def get_all_locations():
locations = {}
# If default is not already listed (e.g. if it's currently set)
if get_default_location() not in locations.values():
locations["Default"] = get_default_location()
for ppartition, plocation in get_permissive_locations():
pdrive = os.path.splitdrive(plocation)[0]
if not (
pdrive # If path actually has a drive (basically just Windows?)
and any( # And shares a common drive with an existing location
[
pdrive == os.path.splitdrive(location)[0]
for location in locations.values()
]
)
):
locations[ppartition] = plocation
# Strip out Nones
return {k: v for k, v in locations.items() if v}
class CaptureStorageLocation:
def __init__(self, mountpoint):
pass
class AutostorageExtension(BaseExtension):
def __init__(self):
BaseExtension.__init__(
self,
"org.openflexure.autostorage",
version="2.0.0",
description="Handle switching capture storage devices",
)
# We'll store a reference to a CaptureManager object, who's capture paths will be modified
self.capture_manager = None
self.initial_location = get_default_location()
# Register the on_microscope function to run when the microscope is attached
self.on_component("org.openflexure.microscope", self.on_microscope)
def on_microscope(self, microscope_obj):
"""Function to automatically call when the parent LabThing has a microscope attached."""
logging.debug(f"Autostorage extension found microscope {microscope_obj}")
if hasattr(microscope_obj, "captures"):
logging.debug(
f"Autostorage extension bound to CaptureManager {self.capture_manager}"
)
# Store a reference to the CaptureManager
self.capture_manager = microscope_obj.captures
# Store the initial storage location
self.initial_location = get_current_location(self.capture_manager)
# If preferred path does not exist, or cannot be written to
self.check_location(self.initial_location)
logging.debug(self.get_locations())
def check_location(self, location=None):
if not location:
location = get_current_location(self.capture_manager)
# If preferred path does not exist, or cannot be written to
if not (os.path.isdir(location) and check_rw(location)):
logging.error(
f"Preferred capture path {location} is missing or cannot be written to. Restoring defaults."
)
# Reset the storage location to default
set_current_location(self.capture_manager, get_default_location())
def get_locations(self):
if self.capture_manager:
locations = get_all_locations()
current_location = get_current_location(self.capture_manager)
if current_location not in locations.values():
locations.update({"Custom": current_location})
# Add location from the CaptureManager settings file
return locations
else:
return {}
def get_preferred_key(self):
current = get_current_location(self.capture_manager)
locations = self.get_locations()
matches = [k for k, v in locations.items() if v == current]
if len(matches) > 1:
logging.warning(
"Multiple path matches found. Weird, but carrying on using zeroth."
)
return matches[0]
def set_preferred_key(self, new_path_key: str):
if not new_path_key in self.get_locations().keys():
raise KeyError(f"No location named {new_path_key}")
location = self.get_locations().get(new_path_key)
set_current_location(self.capture_manager, location)
def key_to_title(self, path_key: str):
if not path_key in self.get_locations().keys():
raise KeyError(f"No location named {path_key}")
return f"{path_key} ({self.get_locations().get(path_key)})"
def title_to_key(self, path_title: str):
matches = []
for loc_key in self.get_locations().keys():
if path_title.startswith(loc_key):
matches.append(loc_key)
if len(matches) > 1:
logging.warning(
"Multiple path matches found. Weird, but carrying on using zeroth."
)
return matches[0]
def get_titles(self):
return [self.key_to_title(key) for key in self.get_locations().keys()]
def get_preferred_title(self):
return self.key_to_title(self.get_preferred_key())
autostorage_extension_v2 = AutostorageExtension()
class GetLocationsView(PropertyView):
def get(self):
global autostorage_extension_v2
autostorage_extension_v2.check_location()
return autostorage_extension_v2.get_locations()
class PreferredLocationView(PropertyView):
schema = fields.String(required=True, example="Default")
def get(self):
global autostorage_extension_v2
autostorage_extension_v2.check_location()
return autostorage_extension_v2.get_preferred_key()
def post(self, new_path_key):
global autostorage_extension_v2
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(503, "No microscope connected. Unable to autofocus.")
autostorage_extension_v2.check_location()
autostorage_extension_v2.set_preferred_key(new_path_key)
microscope.save_settings()
class PreferredLocationGUIView(View):
args = {"new_path_title": fields.String(required=True)}
def post(self, args):
global autostorage_extension_v2
new_path_title = args.get("new_path_title")
logging.debug(new_path_title)
new_path_key = autostorage_extension_v2.title_to_key(new_path_title)
logging.debug(f"{new_path_key}")
autostorage_extension_v2.check_location()
autostorage_extension_v2.set_preferred_key(new_path_key)
return new_path_title
def dynamic_form():
global autostorage_extension_v2
autostorage_extension_v2.check_location()
return {
"icon": "sd_storage",
"title": "Storage",
"viewPanel": "gallery",
"forms": [
{
"name": "Autostorage",
"isCollapsible": False,
"isTask": False,
"route": "/location-from-title",
"emitOnResponse": "globalUpdateCaptures",
"submitLabel": "Set path",
"schema": [
{
"fieldType": "selectList",
"name": "new_path_title",
"label": "Capture storage path",
"options": autostorage_extension_v2.get_titles(),
"value": autostorage_extension_v2.get_preferred_title(),
}
],
}
],
}
autostorage_extension_v2.add_view(GetLocationsView, "/list-locations")
autostorage_extension_v2.add_view(PreferredLocationView, "/location")
autostorage_extension_v2.add_view(PreferredLocationGUIView, "/location-from-title")
autostorage_extension_v2.add_meta(
"gui", build_gui(dynamic_form, autostorage_extension_v2)
)

View file

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

View file

@ -0,0 +1,129 @@
from labthings import find_component
from labthings.views import View, ActionView
from labthings.extensions import BaseExtension
from flask import abort
from contextlib import contextmanager
import logging
# Type hinting
from typing import Tuple
from .recalibrate_utils import (
recalibrate_camera,
auto_expose_and_freeze_settings,
flat_lens_shading_table,
)
@contextmanager
def pause_stream(scamera, resolution: Tuple[int, int] = None):
"""This context manager locks a streaming camera, and pauses the stream.
The stream is re-enabled, with the original resolution, once the with
block has finished.
"""
with scamera.lock:
assert (
not scamera.record_active
), "We can't pause the camera's video stream while a recording is in progress."
streaming = scamera.stream_active
old_resolution = scamera.camera.resolution
if streaming:
logging.info("Stopping stream in pause_stream context manager")
scamera.stop_stream_recording(resolution=resolution)
try:
yield scamera
finally:
scamera.camera.resolution = old_resolution
if streaming:
logging.info("Restarting stream in pause_stream context manager")
scamera.start_stream_recording()
def recalibrate(microscope):
"""Reset the camera's settings.
This generates new gains, exposure time, and lens shading
table such that the background is as uniform as possible
with a gray level of 230. It takes a little while to run.
"""
with pause_stream(microscope.camera) as scamera:
auto_expose_and_freeze_settings(
scamera.camera
) # scamera.camera is the PiCamera object
recalibrate_camera(scamera.camera)
microscope.save_settings()
class RecalibrateView(ActionView):
def post(self):
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(503, "No microscope connected. Unable to recalibrate.")
logging.info("Starting microscope recalibration...")
return recalibrate(microscope)
class FlattenLSTView(ActionView):
def post(self):
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(
503,
"No microscope connected. Unable to flatten the lens shading table.",
)
try:
with pause_stream(microscope.camera) as scamera:
flat_lst = flat_lens_shading_table(scamera.camera)
scamera.camera.lens_shading_table = flat_lst
microscope.save_settings()
except:
logging.exception("Error flattening the lens shading table.")
abort(
503,
"Couldn't flatten the lens shading table - do you have the forked PiCamera library installed?",
)
class DeleteLSTView(ActionView):
def post(self):
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(
503,
"No microscope connected. Unable to flatten the lens shading table.",
)
try:
with pause_stream(microscope.camera) as scamera:
scamera.camera.lens_shading_table = None
microscope.save_settings()
except:
logging.exception("Error deleting the lens shading table.")
abort(
503,
"Couldn't flatten the lens shading table - do you have the forked PiCamera library installed?",
)
lst_extension_v2 = BaseExtension(
"org.openflexure.calibration.picamera",
version="2.0.0-beta.1",
description="Routines to perform flat-field correction on the camera.",
)
lst_extension_v2.add_method(
recalibrate, "org.openflexure.calibration.picamera.recalibrate"
)
lst_extension_v2.add_view(RecalibrateView, "/recalibrate", endpoint="recalibrate")
lst_extension_v2.add_view(FlattenLSTView, "/flatten_lens_shading_table", endpoint="flatten_lens_shading_table")
lst_extension_v2.add_view(DeleteLSTView, "/delete_lens_shading_table", endpoint="delete_lens_shading_table")

View file

@ -1,13 +1,15 @@
import numpy as np
import time
import logging
from picamera import PiCamera
from picamera.array import PiRGBArray, PiBayerArray
def rgb_image(camera, resize=None, **kwargs):
"""Capture an image and return an RGB numpy array"""
with PiRGBArray(camera, size=resize) as output:
camera.capture(output, format='rgb', resize=resize, **kwargs)
camera.capture(output, format="rgb", resize=resize, **kwargs)
return output.array
@ -19,7 +21,9 @@ def flat_lens_shading_table(camera):
library (with lens shading table support) it will raise an error.
"""
if not hasattr(PiCamera, "lens_shading_table"):
raise ImportError("This program requires the forked picamera library with lens shading support")
raise ImportError(
"This program requires the forked picamera library with lens shading support"
)
return np.zeros(camera._lens_shading_table_shape(), dtype=np.uint8) + 32
@ -28,42 +32,55 @@ def adjust_exposure_to_setpoint(camera, setpoint):
print("Adjusting shutter speed to hit setpoint {}".format(setpoint), end="")
for i in range(3):
print(".", end="")
camera.shutter_speed = int(camera.shutter_speed * setpoint / np.max(rgb_image(camera)))
camera.shutter_speed = int(
camera.shutter_speed * setpoint / np.max(rgb_image(camera))
)
time.sleep(1)
print("done")
def auto_expose_and_freeze_settings(camera):
"""Freeze the settings after auto-exposing to white illumination"""
print("Allowing the camera to auto-expose")
logging.info("Allowing the camera to auto-expose")
camera.awb_mode = "auto"
camera.exposure_mode = "auto"
camera.iso = 0 # This is important, if it's on a fixed ISO, gain might not set properly.
camera.iso = (
0
) # This is important, if it's on a fixed ISO, gain might not set properly.
for i in range(6):
print(".", end="")
time.sleep(0.5)
print("done")
logging.info("done")
print("Freezing the camera settings...")
logging.info("Freezing the camera settings...")
camera.shutter_speed = camera.exposure_speed
print("Shutter speed = {}".format(camera.shutter_speed))
logging.info("Shutter speed = {}".format(camera.shutter_speed))
camera.exposure_mode = "off"
print("Auto exposure disabled")
logging.info("Auto exposure disabled")
g = camera.awb_gains
camera.awb_mode = "off"
camera.awb_gains = g
print("Auto white balance disabled, gains are {}".format(g))
print("Analogue gain: {}, Digital gain: {}".format(camera.analog_gain, camera.digital_gain))
logging.info("Auto white balance disabled, gains are {}".format(g))
logging.info(
"Analogue gain: {}, Digital gain: {}".format(
camera.analog_gain, camera.digital_gain
)
)
adjust_exposure_to_setpoint(camera, 215)
def channels_from_bayer_array(bayer_array):
"""Given the 'array' from a PiBayerArray, return the 4 channels."""
bayer_pattern = [(i//2, i % 2) for i in range(4)]
channels = np.zeros((4, bayer_array.shape[0]//2, bayer_array.shape[1]//2), dtype=bayer_array.dtype)
bayer_pattern = [(i // 2, i % 2) for i in range(4)]
channels = np.zeros(
(4, bayer_array.shape[0] // 2, bayer_array.shape[1] // 2),
dtype=bayer_array.dtype,
)
for i, offset in enumerate(bayer_pattern):
# We simplify life by dealing with only one channel at a time.
channels[i, :, :] = np.sum(bayer_array[offset[0]::2, offset[1]::2, :], axis=2)
channels[i, :, :] = np.sum(
bayer_array[offset[0] :: 2, offset[1] :: 2, :], axis=2
)
return channels
@ -74,7 +91,7 @@ def lst_from_channels(channels):
# lst_resolution = list(np.ceil(full_resolution / 64.0).astype(int))
lst_resolution = [(r // 64) + 1 for r in full_resolution]
# NB the size of the LST is 1/64th of the image, but rounded UP.
print("Generating a lens shading table at {}x{}".format(*lst_resolution))
logging.info("Generating a lens shading table at {}x{}".format(*lst_resolution))
lens_shading = np.zeros([channels.shape[0]] + lst_resolution, dtype=np.float)
for i in range(lens_shading.shape[0]):
image_channel = channels[i, :, :]
@ -86,25 +103,27 @@ def lst_from_channels(channels):
# pad the image by copying edge pixels, so that it is exactly 32 times the
# size of the lens shading table (NB 32 not 64 because each channel is only
# half the size of the full image - remember the Bayer pattern... This
# should give results very close to 6by9's solution, albeit considerably
# should give results very close to 6by9's solution, albeit considerably
# less computationally efficient!
padded_image_channel = np.pad(image_channel,
[(0, lw*32 - iw), (0, lh*32 - ih)],
mode="edge") # Pad image to the right and bottom
print("Channel shape: {}x{}, shading table shape: {}x{}, after padding {}".format(iw,
ih,
lw*32,
lh*32,
padded_image_channel.shape))
padded_image_channel = np.pad(
image_channel, [(0, lw * 32 - iw), (0, lh * 32 - ih)], mode="edge"
) # Pad image to the right and bottom
logging.info(
"Channel shape: {}x{}, shading table shape: {}x{}, after padding {}".format(
iw, ih, lw * 32, lh * 32, padded_image_channel.shape
)
)
# Next, fill the shading table (except edge pixels). Please excuse the
# for loop - I know it's not fast but this code needn't be!
box = 3 # We average together a square of this side length for each pixel.
# NB this isn't quite what 6by9's program does - it averages 3 pixels
# horizontally, but not vertically.
for dx in np.arange(box) - box//2:
for dy in np.arange(box) - box//2:
ls_channel[:, :] += padded_image_channel[16+dx::32, 16+dy::32] - 64
ls_channel /= box**2
for dx in np.arange(box) - box // 2:
for dy in np.arange(box) - box // 2:
ls_channel[:, :] += (
padded_image_channel[16 + dx :: 32, 16 + dy :: 32] - 64
)
ls_channel /= box ** 2
# The original C code written by 6by9 normalises to the central 64 pixels in each channel.
# ls_channel /= np.mean(image_channel[iw//2-4:iw//2+4, ih//2-4:ih//2+4])
# I have had better results just normalising to the maximum:
@ -114,10 +133,10 @@ def lst_from_channels(channels):
# For most sensible lenses I'd expect that 1.0 is the maximum value.
# NB ls_channel should be a "view" of the whole lens shading array, so we don't
# need to update the big array here.
# What we actually want to calculate is the gains needed to compensate for the
# What we actually want to calculate is the gains needed to compensate for the
# lens shading - that's 1/lens_shading_table_float as we currently have it.
gains = 32.0/lens_shading # 32 is unity gain
gains = 32.0 / lens_shading # 32 is unity gain
gains[gains > 255] = 255 # clip at 255, maximum gain is 255/32
gains[gains < 32] = 32 # clip at 32, minimum gain is 1 (is this necessary?)
lens_shading_table = gains.astype(np.uint8)
@ -145,7 +164,7 @@ def recalibrate_camera(camera):
# raw_image is a 3D array, with full resolution and 3 colour channels. No
# de-mosaicing has been done, so 2/3 of the values are zero (3/4 for R and B
# channels, 1/2 for green because there's twice as many green pixels).
channels = channels_from_bayer_array(raw_image)
channels = channels_from_bayer_array(raw_image)
lens_shading_table = lst_from_channels(channels)
camera.lens_shading_table = lens_shading_table
@ -154,8 +173,10 @@ def recalibrate_camera(camera):
# Fix the AWB gains so the image is neutral
channel_means = np.mean(np.mean(rgb_image(camera), axis=0, dtype=np.float), axis=0)
old_gains = camera.awb_gains
camera.awb_gains = (channel_means[1]/channel_means[0] * old_gains[0],
channel_means[1]/channel_means[2]*old_gains[1])
camera.awb_gains = (
channel_means[1] / channel_means[0] * old_gains[0],
channel_means[1] / channel_means[2] * old_gains[1],
)
time.sleep(1)
# Ensure the background is bright but not saturated
adjust_exposure_to_setpoint(camera, 230)
@ -165,7 +186,7 @@ if __name__ == "__main__":
with PiCamera() as camera:
camera.start_preview()
time.sleep(3)
print("Recalibrating...")
logging.info("Recalibrating...")
recalibrate_camera(camera)
print("Done.")
logging.info("Done.")
time.sleep(2)

View file

@ -0,0 +1,354 @@
import itertools
import logging
import uuid
import datetime
from typing import Tuple
from functools import reduce
from openflexure_microscope.captures.capture_manager import generate_basename
from labthings import fields, find_component, find_extension, current_action, update_action_progress
from labthings.views import View, ActionView
from labthings.extensions import BaseExtension
from openflexure_microscope.devel import abort
import time
### Grid construction
def construct_grid(initial, step_sizes, n_steps, style="raster"):
"""
Given an initial position, step sizes, and number of steps,
construct a 2-dimensional list of scan x-y positions.
"""
arr = []
for i in range(n_steps[0]): # x axis
arr.append([])
for j in range(n_steps[1]): # y axis
# Create a coordinate array
coord = [initial[ax] + [i, j][ax] * step_sizes[ax] for ax in range(2)]
# Append coordinate array to position grid
arr[i].append(tuple(coord))
# Style modifiers
if style == "snake":
for i, line in enumerate(arr):
if i % 2 != 0:
line.reverse()
return arr
def flatten_grid(grid):
"""
Convert a 3D list of scan positions into a flat list
of sequential positions.
"""
grid = list(itertools.chain(*grid))
return grid
### Capturing
def capture(
microscope,
basename,
temporary: bool = False,
use_video_port: bool = False,
resize: Tuple[int, int] = None,
bayer: bool = False,
metadata: dict = {},
annotations: dict = {},
tags: list = [],
):
# Construct a tile filename
filename = "{}_{}_{}_{}".format(basename, *microscope.stage.position)
folder = "SCAN_{}".format(basename)
# Do capture
return microscope.capture(
filename=filename,
folder=folder,
temporary=temporary,
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
annotations=annotations,
tags=tags,
metadata=metadata,
cache_key=folder
)
class ScanExtension(BaseExtension):
def __init__(self):
self._images_to_be_captured: int = 1
self._images_captured_so_far: int = 0
BaseExtension.__init__(self, "org.openflexure.scan", version="2.0.0")
def progress(self):
progress = (self._images_captured_so_far / self._images_to_be_captured) * 100
logging.info(progress)
return progress
### Scanning
def tile(
self,
microscope,
basename: str = None,
temporary: bool = False,
stride_size: int = [2000, 1500, 100],
grid: list = [3, 3, 5],
style="raster",
autofocus_dz: int = 50,
use_video_port: bool = False,
resize: Tuple[int, int] = None,
bayer: bool = False,
fast_autofocus=False,
metadata: dict = {},
annotations: dict = {},
tags: list = [],
):
start = time.time()
# Keep task progress
self._images_to_be_captured = reduce((lambda x, y: x * y), grid)
self._images_captured_so_far = 0
# Generate a basename if none given
if not basename:
basename = generate_basename()
# Store initial position
initial_position = microscope.stage.position
# Add dataset metadata
dataset_d = {
"dataset": {
"id": uuid.uuid4(),
"type": "xyzScan",
"name": basename,
"acquisitionDate": datetime.datetime.now().isoformat(),
"strideSize": stride_size,
"grid": grid,
"style": style,
"autofocusDz": autofocus_dz,
}
}
# Check if autofocus is enabled
autofocus_extension = find_extension("org.openflexure.autofocus")
if (
autofocus_dz
and autofocus_extension
and microscope.has_real_stage()
and microscope.has_real_camera()
):
autofocus_enabled = True
else:
autofocus_enabled = False
# Construct an x-y grid (worry about z later)
x_y_grid = construct_grid(initial_position, stride_size[:2], grid[:2], style=style)
# Keep the initial Z position the same as our current position
initial_z = initial_position[2]
next_z = initial_z # Save this value for use in raster scans
# Now step through each point in the x-y coordinate array
for line in x_y_grid:
# If rastering, rather than snake (or eventually spiral)
# Return focus to initial position
if style == "raster":
next_z = initial_z # Reset z position at start of each new row
logging.debug("Returning to initial z position")
microscope.stage.move_abs(
[line[0][0], line[0][1], next_z]
) # RWB: I think this line is redundant
for x_y in line:
# Move to new grid position without changing z
logging.debug("Moving to step {}".format([x_y[0], x_y[1], next_z]))
microscope.stage.move_abs([x_y[0], x_y[1], next_z])
# Refocus
if autofocus_enabled:
if fast_autofocus:
# Run fast autofocus. Client should provide dz ~ 2000
autofocus_extension.fast_up_down_up_autofocus(
microscope, dz=autofocus_dz
)
else:
# Run slow autofocus. Client should provide dz ~ 50
autofocus_extension.autofocus(
microscope,
range(-3 * autofocus_dz, 4 * autofocus_dz, autofocus_dz),
)
logging.debug("Finished autofocus")
time.sleep(1)
# If we're not doing a z-stack, just capture
if grid[2] <= 1:
capture(
microscope,
basename,
temporary=temporary,
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
metadata=dataset_d,
annotations=annotations,
tags=tags,
)
# Update task progress
self._images_captured_so_far += 1
update_action_progress(self.progress())
else:
logging.debug("Entering z-stack")
self.stack(
microscope=microscope,
basename=basename,
temporary=temporary,
step_size=stride_size[2],
steps=grid[2],
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
metadata=dataset_d,
annotations=annotations,
tags=tags,
)
if current_action() and current_action().stopped:
return
# Make sure we use our current best estimate of focus (i.e. the current position) next point
next_z = microscope.stage.position[2]
logging.debug("Returning to {}".format(initial_position))
microscope.stage.move_abs(initial_position)
end = time.time()
logging.info(f"Scan took {end - start} seconds")
def stack(
self,
microscope,
basename: str = None,
temporary: bool = False,
step_size: int = 100,
steps: int = 5,
return_to_start: bool = True,
use_video_port: bool = False,
resize: Tuple[int, int] = None,
bayer: bool = False,
metadata: dict = {},
annotations: dict = {},
tags: list = [],
):
# Store initial position
initial_position = microscope.stage.position
logging.debug(f"Starting z-stack from position {microscope.stage.position}")
with microscope.lock:
# Move to center scan
logging.debug("Moving to z-stack starting position")
microscope.stage.move_rel([0, 0, int((-step_size * steps) / 2)])
logging.debug(f"Starting scan from position {microscope.stage.position}")
for i in range(steps):
time.sleep(0.1)
logging.debug(f"Capturing from position {microscope.stage.position}")
capture(
microscope,
basename,
temporary=temporary,
use_video_port=use_video_port,
resize=resize,
bayer=bayer,
metadata=metadata,
annotations=annotations,
tags=tags,
)
# Update task progress
self._images_captured_so_far += 1
update_action_progress(self.progress())
if current_action() and current_action().stopped:
return
if i != steps - 1:
logging.debug("Moving z by {}".format(step_size))
microscope.stage.move_rel([0, 0, step_size])
if return_to_start:
logging.debug("Returning to {}".format(initial_position))
microscope.stage.move_abs(initial_position)
scan_extension_v2 = ScanExtension()
class TileScanAPI(ActionView):
args = {
"filename": fields.String(missing=None, example=None),
"temporary": fields.Boolean(missing=False),
"stride_size": fields.List(
fields.Integer, missing=[2000, 1500, 100], example=[2000, 1500, 100]
),
"grid": fields.List(fields.Integer, missing=[3, 3, 3], example=[3, 3, 3]),
"style": fields.String(missing="raster"),
"autofocus_dz": fields.Integer(missing=50),
"fast_autofocus": fields.Boolean(missing=False),
"use_video_port": fields.Boolean(missing=False),
"bayer": fields.Boolean(missing=False),
"annotations": fields.Dict(missing={}, example={"Foo": "Bar"}),
"tags": fields.List(fields.String, missing=[]),
"resize": fields.Dict(missing=None), # TODO: Validate keys
}
# Allow 10 seconds to stop upon DELETE request
# Gives fast-autofocus time to finish if it's running
default_stop_timeout = 10
def post(self, args):
microscope = find_component("org.openflexure.microscope")
if not microscope:
abort(503, "No microscope connected. Unable to autofocus.")
resize = args.get("resize", None)
if resize:
if ("width" in resize) and ("height" in resize):
resize = (
int(resize["width"]),
int(resize["height"]),
) # Convert dict to tuple
else:
abort(404)
logging.info("Running tile scan...")
# Acquire microscope lock with 1s timeout
with microscope.lock(timeout=1):
# Run scan_extension_v2
return scan_extension_v2.tile(
microscope,
basename=args.get("filename"),
temporary=args.get("temporary"),
stride_size=args.get("stride_size"),
grid=args.get("grid"),
style=args.get("style"),
autofocus_dz=args.get("autofocus_dz"),
use_video_port=args.get("use_video_port"),
resize=resize,
bayer=args.get("bayer"),
fast_autofocus=args.get("fast_autofocus"),
annotations=args.get("annotations"),
tags=args.get("tags"),
)
scan_extension_v2.add_view(TileScanAPI, "/tile", endpoint="tile")

View file

@ -0,0 +1,196 @@
from openflexure_microscope.devel import (
JsonResponse,
request,
)
from flask import send_file, abort, url_for
import uuid
import os
import zipfile
import tempfile
import logging
from labthings import fields, find_component, update_action_progress
from labthings.views import View, ActionView, PropertyView
from labthings.schema import Schema, pre_dump
from labthings.extensions import BaseExtension
from labthings.utilities import description_from_view
class ZipObjectSchema(Schema):
id = fields.String()
data_size = fields.Number()
zip_size = fields.Number()
links = fields.Dict()
@pre_dump
def generate_links(self, data, **kwargs):
data.links = {
"download": {
"href": url_for(
ZipGetterAPIView.endpoint, session_id=data.id, _external=True
),
**description_from_view(ZipGetterAPIView),
}
}
return data
class ZipObjectDescription:
def __init__(self, id, file_pointer, data_size=None):
self.id = id
self.fp = file_pointer
self.data_size = data_size
self.zip_size = os.path.getsize(self.fp.name) * 1e-6
def close(self):
logging.debug(self.fp.name)
self.fp.close()
os.unlink(self.fp.name)
assert not os.path.exists(self.fp.name)
def __del__(self):
self.close()
class ZipManager:
"""
ZIP-builder manager
"""
def __init__(self):
super().__init__()
self.session_zips = {}
def build_zip_from_capture_ids(self, microscope, capture_id_list):
logging.debug(capture_id_list)
# Get array of captures from IDs
capture_list = [
microscope.captures.images.get(capture_id) for capture_id in capture_id_list
]
# Remove Nones from list (missing/invalid captures)
capture_list = [capture for capture in capture_list if capture]
# Get size (in bytes) of each capture
capture_sizes = [
os.path.getsize(capture_obj.file) for capture_obj in capture_list
]
# Calculate size of input data in megabytes
data_size_megabytes = sum(capture_sizes) * 1e-6
# If more than 1GB
if data_size_megabytes > 1000:
# Throw exception
raise Exception(
"Zip data cannot exceed 1GB. Please transfer data manually."
)
# Number of files to add (used for task progress)
n_files = len(capture_id_list)
# Create temporary file
fp = tempfile.NamedTemporaryFile(delete=False)
# Open temp file as a ZIP file
with zipfile.ZipFile(fp, "w") as zipObj:
for index, capture_obj in enumerate(capture_list):
# Add to ZIP file if it exists
file_path = capture_obj.file
rel_path = os.path.relpath(
file_path, microscope.captures.paths["default"]
)
zipObj.write(file_path, arcname=rel_path)
# Update task progress
update_action_progress(int((index / n_files) * 100))
session_id = str(uuid.uuid4())
session_description = ZipObjectDescription(
session_id, fp, data_size=data_size_megabytes
)
self.session_zips[session_id] = session_description
return self.session_zips[session_id]
def marshaled_build_zip_from_capture_ids(self, *args, **kwargs):
return ZipObjectSchema().dump(self.build_zip_from_capture_ids(*args, **kwargs))
def zip_fp_from_id(self, session_id):
return self.session_zips[session_id].fp
def __del__(self):
for zd in self.session_zips.values():
zd.close()
# Create a global ZIP manager
default_zip_manager = ZipManager()
class ZipBuilderAPIView(ActionView):
def post(self):
ids = list(JsonResponse(request).json)
microscope = find_component("org.openflexure.microscope")
# Return a handle on the autofocus task
return default_zip_manager.marshaled_build_zip_from_capture_ids(
microscope, ids
)
class ZipListAPIView(PropertyView):
schema = ZipObjectSchema(many=True)
def get(self):
return default_zip_manager.session_zips.values()
class ZipGetterAPIView(View):
"""
Download or delete a particular capture collection ZIP file
"""
def get(self, session_id):
"""
Download a particular capture collection ZIP file
"""
if not session_id in default_zip_manager.session_zips:
return abort(404) # 404 Not Found
logging.info(f"Session ID: {session_id}")
return send_file(
default_zip_manager.zip_fp_from_id(session_id).name,
mimetype="application/zip",
as_attachment=True,
attachment_filename=f"{session_id}.zip",
)
def delete(self, session_id):
"""
Close and delete a particular capture collection ZIP file
"""
if not session_id in default_zip_manager.session_zips:
return abort(404) # 404 Not Found
# Close the file
default_zip_manager.session_zips[session_id].close()
# Delete the file reference
del default_zip_manager.session_zips[session_id]
return {"return": session_id}
zip_extension_v2 = BaseExtension(
"org.openflexure.zipbuilder",
version="2.0.0",
description="Build and download capture collections as ZIP files",
)
zip_extension_v2.add_view(ZipGetterAPIView, "/get/<string:session_id>", endpoint="get_id")
zip_extension_v2.add_view(ZipListAPIView, "/get", endpoint="get")
zip_extension_v2.add_view(ZipBuilderAPIView, "/build", endpoint="build")

View file

@ -0,0 +1 @@
from .tools import devtools_extension_v2

View file

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

View file

@ -1,35 +0,0 @@
from flask import jsonify, escape
from werkzeug.exceptions import default_exceptions
from werkzeug.exceptions import HTTPException
class JSONExceptionHandler(object):
def __init__(self, app=None):
if app:
self.init_app(app)
def std_handler(self, error):
if isinstance(error, HTTPException):
message = error.description
elif hasattr(error, 'message'):
message = error.message
else:
message = str(error)
status_code = error.code if isinstance(error, HTTPException) else 500
response = {
'status_code': status_code,
'message': escape(message)
}
return jsonify(response), status_code
def init_app(self, app):
self.app = app
self.register(HTTPException)
for code, v in default_exceptions.items():
self.register(code)
def register(self, exception_or_code, handler=None):
self.app.errorhandler(exception_or_code)(handler or self.std_handler)

View file

@ -0,0 +1,12 @@
from openflexure_microscope.microscope import Microscope
import logging
default_microscope = Microscope()
# Restore loaded capture array to camera object
logging.debug("Restoring captures...")
default_microscope.captures.rebuild_captures()
logging.debug("Microscope successfully attached!")

View file

@ -0,0 +1,3 @@
NODE_ENV=development
VUE_APP_PLATFORM=web
VUE_APP_TARGET=web

View file

@ -0,0 +1,3 @@
NODE_ENV=production
VUE_APP_PLATFORM=web
VUE_APP_TARGET=web

View file

@ -0,0 +1,24 @@
module.exports = {
root: true,
env: {
node: true
},
extends: [
"plugin:vue/recommended",
"eslint:recommended",
"prettier/vue",
"plugin:prettier/recommended"
],
rules: {
"vue/component-name-in-template-casing": ["error", "PascalCase"],
"no-console": process.env.NODE_ENV === "production" ? "error" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "off"
},
globals: {
$nuxt: true
},
parserOptions: {
parser: "babel-eslint"
}
};

View file

@ -0,0 +1,30 @@
.DS_Store
node_modules
/dist
/dist-lite
/app/dist
/release-builds
/installers/release-builds
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
# NativeScript application
hooks
platforms
./webpack.config.js

View file

@ -0,0 +1,93 @@
# Contributing
When contributing to this repository, please first discuss the change you wish to make via issue,
email, or any other method with the owners of this repository before making a change.
Please note we have a working flow, and a code of conduct. Please follow it in all your interactions with the project.
## Flow
<img src="https://gitlab.com/openflexure/openflexure-identity/raw/master/flow.png" height="200">
### Development
* Master is the latest working version.
* New features and bug fixes should be developed on branches (or forks), and merged into master.
### Releases
* Once we've merged a few things in and want to make a release, we tag it on master.
* Not every commit on master will get a tag, we'll accumulate a few changes before making a release, unless there's a reason to release straight away.
* Once we're confident that the tagged release is building and stable, we merge it into the stable branch.
## Code of Conduct
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
### Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
### Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
### Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
### Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at [INSERT EMAIL ADDRESS]. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
### Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

View file

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
openflexure-microscope-jsclient
Copyright (C) 2019 OpenFlexure.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
openflexure-microscope-jsclient Copyright (C) 2019 OpenFlexure.org
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.

View file

@ -0,0 +1,20 @@
# OpenFlexure Microscope JS Client
## Developer notes
### VS Code and ESLint
To prevent the editor from interfering with ESLint, add to your project `settings.json`:
```json
{
"editor.tabSize": 2,
"cSpell.enabled": false,
"eslint.validate": ["vue","javascript", "javascriptreact"],
"editor.formatOnSave": false,
"vetur.validation.template": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}
```

View file

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/app'
]
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
{
"name": "openflexure-microscope-jsclient",
"version": "2.4.0",
"private": true,
"description": "User client for the OpenFlexure Microscope Server",
"author": "OpenFlexure <contact@openflexure.org> (https://www.openflexure.org)",
"homepage": "https://gitlab.com/openflexure/openflexure-microscope-jsclient",
"publisher": "Bath Open Instrumentation Group",
"displayName": "OpenFlexure Microscope",
"license": "GNU General Public License v3.0 ",
"scripts": {
"build": "vue-cli-service build --mode production",
"serve": "vue-cli-service serve --mode development"
},
"dependencies": {
"material-design-icons": "^3.0.1",
"mousetrap": "^1.6.5"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^3.12.1",
"@vue/cli-service": "^4.4.1",
"axios": "^0.19.2",
"babel-eslint": "^10.1.0",
"css-loader": "^3.5.3",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-vue": "^6.2.2",
"less": "^3.11.2",
"less-loader": "^5.0.0",
"mdns-js": "^1.0.3",
"uikit": "^3.5.3",
"vue": "^2.6.11",
"vue-friendly-iframe": "^0.17.0",
"vue-plugin-load-script": "^1.2.0",
"vue-template-compiler": "^2.6.11",
"vue-tour": "^1.4.0",
"vuex": "^3.4.0"
},
"postcss": {
"plugins": {
"autoprefixer": {}
}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not ie <= 8"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>OpenFlexure Microscope</title>
</head>
<body>
<noscript>
<strong>We're sorry but vue3 doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 56" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;">
<g transform="matrix(1,0,0,1,-30.245,-47.0254)">
<g id="path3773" transform="matrix(0.12596,0,0,0.12596,28.1368,19.1335)">
<path d="M212.85,344.652C234.052,276.913 297.347,227.702 372.047,227.702C446.747,227.702 510.042,276.913 531.245,344.652L531.245,344.652L531.246,344.656C531.789,346.391 532.304,348.138 532.791,349.896C556.452,421.618 655.593,393.868 655.593,393.868L721.09,563.056L472.356,659.347L430.306,550.728L430.129,550.794L406.853,490.142C408.621,489.464 410.361,488.736 412.072,487.962C419.736,484.232 426.404,480.109 432.198,475.703C447.995,463.209 459.586,446.041 465.257,426.69C473.38,395.32 463.384,366.691 463.101,365.891C449.309,328.959 413.54,302.366 372.047,302.366C330.554,302.366 294.785,328.959 280.994,365.891C280.711,366.689 270.714,395.319 278.838,426.69C284.509,446.041 296.099,463.209 311.897,475.703C317.69,480.109 324.359,484.232 332.023,487.962C333.734,488.736 335.474,489.464 337.242,490.142L337.166,490.34C337.166,490.34 313.965,550.794 313.965,550.794L313.788,550.728L271.739,659.347L23.004,563.056L88.501,393.868C88.501,393.868 187.642,421.618 211.303,349.896C211.79,348.138 212.306,346.391 212.849,344.656L212.85,344.652L212.85,344.652Z" style="fill:white;fill-rule:nonzero;stroke:white;stroke-width:12.54px;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,470 @@
<template>
<div
id="app"
class="uk-height-1-1 uk-margin-remove uk-padding-remove"
:class="handleTheme"
>
<loadingContent v-if="!$store.getters.ready" />
<div v-if="$store.getters.ready" id="tour-header"></div>
<appContent v-if="$store.getters.ready" />
<!-- Runtime modals -->
<div
id="modal-center"
ref="keyboardManualModal"
class="uk-flex-top"
uk-modal
>
<div class="uk-modal-dialog uk-modal-body uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div
v-for="shortcut in keyboardManual"
:key="shortcut.shortcut"
class="uk-margin-small"
uk-grid
>
<div class="uk-width-small">{{ shortcut.shortcut }}</div>
<div class="uk-width-expand">{{ shortcut.description }}</div>
</div>
</div>
</div>
<v-tour
v-show="$store.getters.ready"
name="guidedTour"
:steps="tourSteps"
:callbacks="tourCallbacks"
:options="{ highlight: true }"
></v-tour>
</div>
</template>
<script>
// Import components
import appContent from "./components/appContent.vue";
import loadingContent from "./components/loadingContent.vue";
import axios from "axios";
var Mousetrap = require("mousetrap");
Mousetrap.prototype.stopCallback = function(e, element, combo) {
// if the element has the class "mousetrap" then no need to stop
if ((" " + element.className + " ").indexOf(" mousetrap ") > -1) {
return false;
}
// if we're in a lightbox, stop mousetrap
if (element.classList.contains("lightbox-link")) {
return true;
}
// stop for input, select, and textarea
return (
element.tagName == "INPUT" ||
element.tagName == "SELECT" ||
element.tagName == "TEXTAREA" ||
(element.contentEditable && element.contentEditable == "true")
);
};
// Export main app
export default {
name: "App",
components: {
appContent,
loadingContent
},
data: function() {
return {
appAvailable: false,
arrowKeysDown: {},
keyboardManual: [],
systemDark: undefined,
themeObserver: undefined,
tourCallbacks: {
onStop: () => {
this.setLocalStorageObj("completedTour", true);
}
}
};
},
computed: {
isSystemDark: function() {
if (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
) {
return true;
} else {
return false;
}
},
handleTheme: function() {
var isDark = false;
if (this.$store.state.globalSettings.appTheme == "dark") {
isDark = true;
} else if (this.$store.state.globalSettings.appTheme == "system") {
if (this.systemDark) {
isDark = true;
}
}
return {
"uk-light": isDark,
"uk-background-secondary": isDark
};
},
tourSteps: function() {
return [
{
target: "#tour-header", // We're using document.querySelector() under the hood
header: {
title: "Welcome to the OpenFlexure Microscope"
},
content: `Click Next to learn how to use your microscope`,
params: {
placement: "bottom"
}
},
{
target: "#gallery-tab-icon",
header: {
title: "Capture gallery"
},
content: `View and download your microscope images from the gallery tab`
},
{
target: "#navigate-tab-icon",
header: {
title: "Navigate around your sample"
},
content: `Move your microscope stage and perform autofocus from the navigate tab`
},
{
target: "#capture-tab-icon",
header: {
title: "Capture microscope images"
},
content: `Take images and simple tile scans from the capture tab`
},
{
target: "#settings-tab-icon",
header: {
title: "Change settings"
},
content: `Change app and microscope settings, including microscope calibration, from the settings tab`
},
{
target: "#extension-tab-divider",
header: {
title: "Microscope extensions"
},
content: `Extensions installed on your microscope will appear below this line`
}
];
}
},
mounted() {
// Query CSS dark theme preference
var mql = window.matchMedia("(prefers-color-scheme: dark)");
// Check for system dark theme when mounted
if (mql.matches) {
this.systemDark = true;
}
// Create a theme observer to watch for changes
this.themeObserver = mql.addListener(e => {
if (e.matches) {
this.systemDark = true;
} else {
this.systemDark = false;
}
});
// Check connection to API
this.checkConnection();
// Handle guided tour
// If the user has already completed or skipped the guided tour
// TODO: Only run this if connected to the API
var completedTour = this.getLocalStorageObj("completedTour") || false;
if (!completedTour) {
this.$tours["guidedTour"].start();
}
},
created: function() {
window.addEventListener("beforeunload", this.handleExit);
// Scrollwheel listener
window.addEventListener("wheel", this.wheelMonitor);
// Watch for origin changes
this.unwatchOriginFunction = this.$store.watch(
(state, getters) => {
return getters.uriV2;
},
uriV2 => {
this.checkConnection();
console.log(uriV2);
}
);
// Keyboard shortcuts
// TODO: Shortcut guide
Mousetrap.bind("?", () => {
console.log(this.keyboardManual);
this.toggleModalElement(this.$refs["keyboardManualModal"]); // Calls the mixin
});
// Arrow keys
Mousetrap.bind(
["up", "down", "left", "right"],
event => {
this.arrowKeysDown[event.keyCode] = true; //Add key to array
this.navigateKeyHandler();
},
"keydown"
);
Mousetrap.bind(
["up", "down", "left", "right"],
event => {
delete this.arrowKeysDown[event.keyCode]; //Remove key from array
},
"keyup"
);
this.keyboardManual.push({
shortcut: "←↑→↓",
description: "Move the microscope stage"
});
// Focus keys
Mousetrap.bind("pageup", () => {
this.$root.$emit("globalMoveStepEvent", 0, 0, 1);
});
Mousetrap.bind("pagedown", () => {
this.$root.$emit("globalMoveStepEvent", 0, 0, -1);
});
this.keyboardManual.push({
shortcut: "pgup / pgdn",
description: "Move the microscope focus"
});
// Capture
Mousetrap.bind("c", () => {
this.$root.$emit("globalCaptureEvent");
});
this.keyboardManual.push({
shortcut: "c",
description: "Take a capture"
});
// Autofocus
Mousetrap.bind("a", () => {
this.$root.$emit("globalFastAutofocusEvent");
});
this.keyboardManual.push({
shortcut: "a",
description: "Fast autofocus"
});
// Increment/decrement tab
Mousetrap.bind("shift+down", () => {
this.$root.$emit("globalIncrementTab");
});
Mousetrap.bind("shift+up", () => {
this.$root.$emit("globalDecrementTab");
});
this.keyboardManual.push({
shortcut: "shift+↑ / shift+↓",
description: "Switch tab"
});
// Re-run tour
Mousetrap.bind("alt+t", () => {
this.$tours["guidedTour"].start();
});
},
beforeDestroy: function() {
// Disconnect the theme observer
if (this.themeObserver) {
this.themeObserver.disconnect();
}
// Remove scrollwheel listener
window.removeEventListener("wheel", this.wheelMonitor);
// Remove origin watcher
this.unwatchOriginFunction();
// Remove key listeners
console.log("Resetting Mousetrap");
Mousetrap.reset();
},
methods: {
checkConnection: function() {
var uriV2 = this.$store.getters.uriV2;
this.$store.commit("changeWaiting", true);
axios
.get(uriV2)
.then(() => {
this.$store.commit("setConnected");
this.$store.commit("setErrorMessage", null);
})
.catch(error => {
this.$store.commit("setErrorMessage", error);
})
.finally(() => {
this.$store.commit("changeWaiting", false);
});
},
handleExit: function() {
console.log("Triggered beforeunload");
this.$root.$emit("globalTogglePreview", false);
},
// Handle global mouse wheel events to be associated with navigation
wheelMonitor: function(event) {
// Only capture scroll if the event target's parent contains the "scrollTarget" class
if (
event.target.parentNode.classList.contains("scrollTarget") ||
event.target.classList.contains("scrollTarget")
) {
var z_rel = event.deltaY / 100;
// Emit a signal to move, acted on by panelNavigate.vue
this.$root.$emit("globalMoveStepEvent", 0, 0, z_rel, false);
}
},
navigateKeyHandler: function() {
// Calculate movement array
var x_rel = 0;
var y_rel = 0;
var z_rel = 0;
if (37 in this.arrowKeysDown) {
x_rel = x_rel + 1;
}
if (39 in this.arrowKeysDown) {
x_rel = x_rel - 1;
}
if (38 in this.arrowKeysDown) {
y_rel = y_rel + 1;
}
if (40 in this.arrowKeysDown) {
y_rel = y_rel - 1;
}
// Make a position request
// Emit a signal to move, acted on by panelNavigate.vue
this.$root.$emit("globalMoveStepEvent", x_rel, y_rel, z_rel);
}
}
};
</script>
<style lang="less">
// Basic UIkit CSS
@import "../node_modules/uikit/src/less/uikit.less";
// Custom UIkit CSS modifications
@import "./assets/less/theme.less";
// We override the custom-electron-titlebar z-index
// UIKit lightbox must be able to draw over the titlebar
// as it currently always spawns at the root of the DOM
.titlebar,
.titlebar > * {
z-index: 1000 !important;
}
#app {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: left;
height: 100%;
}
body,
html {
height: 100%;
overflow: hidden;
}
.uk-disabled {
pointer-events: none;
opacity: 0.4;
}
.control-component {
overflow-y: auto;
overflow-x: hidden;
width: 300px;
height: 100%;
padding: 0;
background-color: rgba(180, 180, 180, 0.03);
border-width: 0 1px 0 0;
border-style: solid;
border-color: rgba(180, 180, 180, 0.25);
}
.view-component {
overflow-y: auto;
overflow-x: hidden;
height: 100%;
padding: 0;
}
.section-content {
padding: 0;
height: 100%;
}
// Style tour
.v-tour__target--highlighted {
box-shadow: 0px 40px 200px 30px rgba(0, 0, 0, 0.5),
0px 0px 0px 4px rgba(128, 128, 128, 0.5) !important;
border-radius: 5px;
opacity: 100% !important;
pointer-events: none !important;
}
.v-step {
background: @global-primary-background !important;
}
.v-step__header {
background-color: darken(@global-primary-background, 7%) !important;
}
.v-step__button {
font-size: 0.9rem !important;
}
// Change step arrow colour
// This is awful and hacky and makes me sad, but needs must
.v-step .v-step__arrow {
border-color: darken(@global-primary-background, 7%) !important;
&--dark {
border-color: darken(@global-primary-background, 7%) !important;
}
}
.v-step[x-placement^="top"] .v-step__arrow {
border-left-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
}
.v-step[x-placement^="bottom"] .v-step__arrow {
border-left-color: transparent !important;
border-right-color: transparent !important;
border-top-color: transparent !important;
}
.v-step[x-placement^="right"] .v-step__arrow {
border-left-color: transparent !important;
border-top-color: transparent !important;
border-bottom-color: transparent !important;
}
.v-step[x-placement^="left"] .v-step__arrow {
border-top-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
}
</style>

View file

@ -0,0 +1,18 @@
// Theming specific to the electron app
.menubar-menu-container {
box-shadow: 0.5px 1px 4px 0px rgba(0, 0, 0, 0.3) !important;
}
.menubar-menu-container>* {
box-shadow: none !important;
}
.menubar-menu-container .action-menu-item,
.menubar-menu-container .action-label {
cursor: default
}
.menubar-menu-container a:hover {
text-decoration: none;
}

View file

@ -0,0 +1,67 @@
/*
* UIkit Theme for Highlight.js
*/
.hljs {
display: block;
color: #333;
}
.hljs-comment,
.hljs-meta {
color: #969896;
}
.hljs-string,
.hljs-variable,
.hljs-template-variable,
.hljs-strong,
.hljs-emphasis,
.hljs-quote {
color: @global-danger-background;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-type {
color: #1f34aa;
}
.hljs-literal,
.hljs-symbol,
.hljs-bullet,
.hljs-attribute {
color: #0086b3;
}
.hljs-section,
.hljs-name {
color: #63a35c;
}
.hljs-tag {
color: #333;
}
.hljs-title,
.hljs-attr,
.hljs-selector-id,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #795da3;
}
.hljs-addition {
color: #55a532;
background-color: #eaffea;
}
.hljs-deletion {
color: #bd2c00;
background-color: #ffecec;
}
.hljs-link {
text-decoration: underline;
}

View file

@ -0,0 +1,374 @@
// UIkit Core
@import "../../../node_modules/uikit/src/less/uikit.theme.less";
// Highlight.js
@import "./highlight.less";
// Electron app theming
@import "./app.less";
// Custom OpenFlexure theming
//
// Colors
//
@global-color: #666;
@global-emphasis-color: #333;
@global-muted-color: #666;
@global-primary-background: #C32280;
@inverse-primary-muted-color: lighten(@global-primary-background, 15%);
@inverse-global-color: fade(@global-inverse-color, 80%);
@global-border: #d5d5d5;
// UIkit
// ========================================================================
@global-font-family: ProximaNova, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
@global-font-size: 14px;
@global-xxlarge-font-size: 38px;
@global-xlarge-font-size: 30px;
@global-large-font-size: 24px;
@global-medium-font-size: 20px;
@global-small-font-size: 14px;
@global-emphasis-color: #222;
//
// Base
//
@base-code-font-family: 'Roboto Mono', monospace;
@base-code-font-size: 12px;
@base-heading-font-weight: 300;
@base-pre-font-size: 12px;
@base-pre-padding: 25px;
@base-pre-background: @global-muted-background;
@base-pre-border-width: 0;
@base-pre-border-radius: 0;
.hook-base-body() {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
//
// Decorations
//
@small-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
@big-shadow: 0 14px 25px rgba(0, 0, 0, 0.16);
//
// Container
//
@container-max-width: 1380px;
@container-small-max-width: 650px;
//
// Navbar
//
@inverse-navbar-nav-item-color: @inverse-global-color;
@inverse-navbar-nav-item-hover-color: @inverse-global-emphasis-color;
//
// Nav
//
@nav-header-font-size: 12px;
//
// Subnav
//
@inverse-subnav-item-color: @inverse-global-color;
@inverse-subnav-item-hover-color: @inverse-global-emphasis-color;
//
// Tab
//
@tab-item-padding-horizontal: 10px;
@tab-item-padding-vertical: 9px;
@tab-item-border-width: 2px;
@tab-item-font-size: 12px;
.hook-tab-item() {
line-height: 20px;
}
//
// Table
//
@table-header-cell-font-size: 12px;
//
// Label
//
@label-font-size: 12px;
//
// Text
//
.hook-text-lead() {
font-weight: 300;
}
.hook-text-large() {
font-weight: 300;
}
//
// Utility
//
@inverse-logo-color: @inverse-global-emphasis-color;
@inverse-logo-hover-color: @inverse-global-emphasis-color;
//
// Off-canvas
//
@offcanvas-bar-background: #fff;
@offcanvas-bar-color-mode: dark;
//
// Inverse
//
@inverse-global-color: fade(@global-inverse-color, 80%);
@inverse-global-muted-color: fade(@global-inverse-color, 60%);
//
// Paper
//
@paper-border-radius: 4px;
@button-border-radius: 3px;
/* ========================================================================
Theme
========================================================================== */
@sidebar-left-width: 240px;
@sidebar-left-width-xl: 300px;
@sidebar-right-width: 200px;
@sidebar-right-left: 0px;
@sidebar-right-left-xl: 60px;
/* UIkit modifiers
========================================================================== */
/*
* Navbar
*/
// Line Mode
@navbar-nav-item-line-margin-vertical: 20px;
@navbar-nav-item-line-margin-horizontal: @navbar-nav-item-padding-horizontal;
@navbar-nav-item-line-height: 1px;
@navbar-nav-item-line-background: currentColor;
@navbar-nav-item-line-transition-duration: 0.3s;
.uk-navbar-dropdown.uk-open {
border-radius: @paper-border-radius;
}
/*
* Cards
*/
.uk-card {
border-radius: @paper-border-radius;
//border: 1px solid rgba(180, 180, 180, 0.25);
box-shadow: @small-shadow;
}
.uk-card-media-top img {
border-radius: @paper-border-radius @paper-border-radius 0 0;
}
.uk-card-default .uk-card-footer {
border-top: 1px solid rgba(180, 180, 180, 0.25);
}
.uk-card-primary .uk-card-footer {
border-top: 1px solid rgba(0, 0, 0, 0.075);
}
.uk-card-default {
color: @global-color;
}
.hook-inverse() {
// Override background colour in dark mode
.uk-card-default {
background-color: #2a2a2a !important;
color: @inverse-global-color;
}
// Lighten on hover to show depth in dark mode
.uk-card-default:hover {
transition: background-color @animation-fast-duration ease;
background-color: #333 !important;
}
}
/*
* Modal
*/
.uk-modal-dialog {
border-radius: @paper-border-radius;
box-shadow: @big-shadow;
transition: 0.3s ease !important;
}
.uk-modal-footer {
border-radius: 0 0 @paper-border-radius @paper-border-radius;
}
.uk-modal-header {
border-radius: @paper-border-radius @paper-border-radius 0 0;
}
/*
* Notifications
*/
.uk-notification-message {
border-radius: @paper-border-radius;
box-shadow: @big-shadow;
}
.uk-notification-message-danger {
border: 1px solid @notification-message-danger-color
}
.uk-notification-message-warning {
border: 1px solid @notification-message-warning-color
}
.uk-notification-message-success {
border: 1px solid @notification-message-success-color
}
/*
* Links
*/
.uk-link,
.uk-button-link {
color: @global-muted-color;
}
.uk-link:hover,
.uk-button-link:hover,
a:hover {
color: @global-primary-background;
}
.hook-inverse() {
.uk-link:hover,
.uk-button-link:hover,
a:hover {
color: @inverse-primary-muted-color;
}
}
/*
* Buttons
*/
.uk-button {
border-radius: @button-border-radius;
padding: 0 8px;
}
.uk-button-default {
background-color: rgba(180, 180, 180, 0.10);
border-color: @global-border;
}
.uk-button-primary {
background-color: rgba(180, 180, 180, 0.10);
border-color: @global-primary-background;
color: @button-text-color;
}
.uk-button-danger {
background-color: transparent;
color: #f0506e;
border: 1px solid #f0506e;
}
.hook-inverse() {
.uk-button-primary {
background-color: rgba(180, 180, 180, 0.15);
color: @inverse-primary-muted-color;
border-color: @inverse-primary-muted-color;
}
.uk-button-primary:hover {
background-color: @inverse-primary-muted-color;
color: #fff;
}
}
.uk-icon-button,
.uk-icon-button:hover {
text-decoration: none
}
/*
* Accordion
*/
.uk-accordion-title {
display: block;
font-size: @global-font-size;
line-height: 1.4;
color: #333;
font-weight: 500;
}
.uk-accordion-title::before {
content: "";
width: 1.4em;
height: 1.4em;
float: left;
margin: 0 2px 0 0;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M10 17l5-5-5-5v10z'/%3E%3Cpath fill='none' d='M0 24V0h24v24H0z'/%3E%3C/svg%3E");
mask-position: 100% 50%;
background-image: none;
background-color: #333;
}
.uk-open>.uk-accordion-title::before {
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3Cpath d='M0 0h24v24H0z' fill='none'/%3E%3C/svg%3E");
mask-position: 100% 50%;
background-image: none;
}
.uk-accordion-content {
margin-top: 10px;
}
.hook-inverse() {
.uk-accordion-title::before {
background-color: #fff;
}
}

View file

@ -0,0 +1,345 @@
<template>
<div
id="app-content"
class="uk-margin-remove uk-padding-remove uk-height-1-1"
uk-grid
>
<!-- Initialisation modals -->
<calibrationModal
ref="calibrationModal"
:available-plugins="plugins"
@onClose="enterApp()"
></calibrationModal>
<!-- Vertical tab bar -->
<div
id="switcher-left"
class="uk-flex uk-flex-column uk-padding-remove uk-width-auto uk-height-1-1 uk-text-center"
>
<tabIcon
id="view-tab-icon"
tab-i-d="view"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
<i class="material-icons">visibility</i>
</tabIcon>
<tabIcon
id="gallery-tab-icon"
tab-i-d="gallery"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
<i class="material-icons">photo_library</i>
</tabIcon>
<hr />
<tabIcon
id="navigate-tab-icon"
tab-i-d="navigate"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
<i class="material-icons">gamepad</i>
</tabIcon>
<tabIcon
id="capture-tab-icon"
tab-i-d="capture"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
<i class="material-icons">camera_alt</i>
</tabIcon>
<tabIcon
id="settings-tab-icon"
tab-i-d="settings"
:require-connection="false"
:current-tab="currentTab"
@set-tab="setTab"
>
<i class="material-icons">settings</i>
</tabIcon>
<hr id="extension-tab-divider" />
<tabIcon
v-for="plugin in pluginsGuiList"
:key="plugin.id"
:tab-i-d="plugin.id"
:title="plugin.title"
:require-connection="plugin.requiresConnection"
:current-tab="currentTab"
:click-callback="updatePlugins"
@set-tab="setTab"
>
<i class="material-icons">{{ plugin.icon || "extension" }}</i>
</tabIcon>
<tabIcon
id="about-tab-icon"
class="uk-margin-auto-top"
tab-i-d="about"
:require-connection="false"
:current-tab="currentTab"
@set-tab="setTab"
>
<i class="material-icons">info</i>
</tabIcon>
</div>
<!-- Corresponding vertical tab content -->
<div
id="container-left"
class="uk-padding-remove uk-height-1-1 uk-width-expand"
>
<tabContent
tab-i-d="view"
:require-connection="true"
:current-tab="currentTab"
>
<viewContent />
</tabContent>
<tabContent
tab-i-d="gallery"
:require-connection="false"
:current-tab="currentTab"
>
<galleryContent />
</tabContent>
<tabContent
tab-i-d="navigate"
:require-connection="true"
:current-tab="currentTab"
>
<navigateContent />
</tabContent>
<tabContent
tab-i-d="capture"
:require-connection="true"
:current-tab="currentTab"
>
<captureContent />
</tabContent>
<tabContent
tab-i-d="settings"
:require-connection="false"
:current-tab="currentTab"
>
<settingsContent class="section-content" />
</tabContent>
<tabContent
v-for="plugin in pluginsGuiList"
:key="plugin.id"
:tab-i-d="plugin.id"
:require-connection="plugin.requiresConnection"
:current-tab="currentTab"
>
<extensionContent
:forms="plugin.forms"
:frame="plugin.frame"
:web-component="plugin.wc"
:view-panel="plugin.viewPanel"
@reloadForms="updatePlugins()"
/>
</tabContent>
<tabContent
tab-i-d="about"
:require-connection="false"
:current-tab="currentTab"
>
<aboutContent class="section-content" />
</tabContent>
</div>
</div>
</template>
<script>
import axios from "axios";
// Import generic components
import tabIcon from "./genericComponents/tabIcon";
import tabContent from "./genericComponents/tabContent";
// Import new content components
import navigateContent from "./tabContentComponents/navigateContent.vue";
import captureContent from "./tabContentComponents/captureContent.vue";
import viewContent from "./tabContentComponents/viewContent.vue";
import settingsContent from "./tabContentComponents/settingsContent.vue";
import galleryContent from "./tabContentComponents/galleryContent.vue";
import extensionContent from "./tabContentComponents/extensionContent.vue";
import aboutContent from "./tabContentComponents/aboutContent.vue";
// Import modal components for device initialisation
import calibrationModal from "./modalComponents/calibrationModal.vue";
// Export main app
export default {
name: "AppContent",
components: {
tabIcon,
tabContent,
navigateContent,
captureContent,
viewContent,
settingsContent,
galleryContent,
extensionContent,
calibrationModal,
aboutContent
},
data: function() {
return {
plugins: [],
currentTab: "view",
unwatchStoreFunction: null
};
},
computed: {
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
},
pluginsGuiList: function() {
// List of plugin GUIs, obtained from this.plugins values
console.log("Recalculating plugins");
var pluginGuis = [];
for (let plugin of Object.values(this.plugins)) {
if (plugin.meta.gui) {
pluginGuis.push(plugin.meta.gui);
}
}
return pluginGuis;
},
tabOrder: function() {
// TODO: There must be a better way to do this, somehow reading the order of the HTML elements?
var ind = ["view", "gallery", "navigate", "capture", "settings"];
for (const plugin of this.pluginsGuiList) {
ind.push(plugin.id);
}
ind.push("about");
return ind;
},
currentTabIndex: function() {
return this.tabOrder.indexOf(this.currentTab);
}
},
created: function() {
if (this.$store.getters.ready) {
// Detect local connection
if (
["localhost", "0.0.0.0", "127.0.0.1", "[::1]"].includes(
window.location.hostname
)
) {
this.$store.commit("changeSetting", ["disableStream", true]);
this.$store.commit("changeSetting", ["autoGpuPreview", true]);
this.$store.commit("changeSetting", ["trackWindow", true]);
}
// Update plugins
this.updatePlugins().then(() => {
// Start initialisation modals
this.startModals();
});
}
},
mounted() {
// A global signal listener to switch tab
this.$root.$on("globalSwitchTab", tabID => {
this.currentTab = tabID;
});
// A global signal listener to increment tab
this.$root.$on("globalIncrementTab", () => {
this.incrementTabBy(1);
});
// A global signal listener to decrement tab
this.$root.$on("globalDecrementTab", () => {
this.incrementTabBy(-1);
});
},
beforeDestroy() {
// Then we call that function here to unwatch
if (this.unwatchStoreFunction) {
this.unwatchStoreFunction();
this.unwatchStoreFunction = null;
}
},
methods: {
updatePlugins: function() {
console.log("Updating plugin forms");
return axios
.get(this.pluginsUri)
.then(response => {
console.log(response);
this.plugins = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
setTab: function(event, tab) {
if (!(this.currentTab == tab)) {
this.currentTab = tab;
}
},
incrementTabBy: function(n) {
const newIndex =
(((this.currentTabIndex + n) % this.tabOrder.length) +
this.tabOrder.length) %
this.tabOrder.length;
const newId = this.tabOrder[newIndex];
this.currentTab = newId;
},
startModals: function() {
this.$refs["calibrationModal"].show();
},
enterApp: function() {
// Stuff to do once connected and all init modals are finished
console.log("Entering main application");
this.currentTab = "view";
}
}
};
</script>
<style scoped lang="less">
#component-left {
width: 100%;
height: 100%;
}
#container-left {
overflow: hidden;
background-color: rgba(180, 180, 180, 0.025);
width: 100%;
height: 100%;
}
#switcher-left {
border-width: 0 1px 0 0;
border-style: solid;
border-color: rgba(180, 180, 180, 0.25);
}
#switcher-left a {
padding: 10px 8px;
}
#switcher-left {
width: 75px;
background-color: rgba(180, 180, 180, 0.1);
padding-top: 2px !important;
}
</style>

View file

@ -0,0 +1,35 @@
<template>
<div>
<form class="uk-form-stacked" @submit.prevent="overrideAPIHost">
<label class="uk-form-label">Override API origin</label>
<input v-model="currentOrigin" class="uk-input" type="text" />
</form>
</div>
</template>
<script>
// Export main app
export default {
name: "DevTools",
components: {},
data: function() {
return {
currentOrigin: this.$store.state.origin
};
},
methods: {
overrideAPIHost: function() {
this.$store.commit("changeOrigin", this.currentOrigin);
}
}
};
</script>
<style scoped lang="less">
.error-icon {
font-size: 120px;
}
</style>

View file

@ -0,0 +1,467 @@
<template>
<div id="paneCapture" class="uk-padding-small">
<div>
<label class="uk-form-label" for="form-stacked-text">Filename</label>
<input
v-model="filename"
class="uk-input uk-width-1-1 uk-form-small"
name="inputFilename"
placeholder="Leave blank for default"
/>
</div>
<p uk-tooltip="title: Capture will be removed automatically; delay: 500">
<label
><input v-model="temporary" class="uk-checkbox" type="checkbox" />
Temporary</label
>
</p>
<hr />
<div class="uk-child-width-1-2" uk-grid>
<p>
<label
><input
v-model="fullResolution"
class="uk-checkbox"
type="checkbox"
/>
Full resolution</label
>
</p>
<p>
<label
><input v-model="storeBayer" class="uk-checkbox" type="checkbox" />
Store raw data</label
>
</p>
</div>
<hr />
<p>
<label
><input v-model="resizeCapture" class="uk-checkbox" type="checkbox" />
Resize capture</label
>
</p>
<div class="uk-child-width-1-2" uk-grid>
<div>
<input
v-model="resizeDims[0]"
:class="resizeClass"
class="uk-input uk-form-width-medium uk-form-small"
type="number"
name="inputResizeW"
/>
</div>
<div>
<input
v-model="resizeDims[1]"
:class="resizeClass"
class="uk-input uk-form-width-medium uk-form-small"
type="number"
name="inputResizeH"
/>
</div>
</div>
<ul uk-accordion="multiple: true">
<li>
<a class="uk-accordion-title" href="#">Notes</a>
<div class="uk-accordion-content">
<div class="uk-margin-small">
<textarea
v-model="captureNotes"
class="uk-textarea"
rows="5"
placeholder="Capture notes"
></textarea>
</div>
</div>
</li>
<li>
<a class="uk-accordion-title" href="#">Annotations</a>
<div class="uk-accordion-content">
<keyvalList v-model="annotations" />
</div>
</li>
<li>
<a class="uk-accordion-title" href="#">Tags</a>
<div class="uk-accordion-content">
<tagList v-model="tags" />
</div>
</li>
</ul>
<hr />
<ul uk-accordion="multiple: true">
<!--Show stack and scan if scan plugin is enabled-->
<li v-if="scanUri">
<a class="uk-accordion-title" href="#">Stack and Scan</a>
<div class="uk-accordion-content">
<div class="uk-margin">
<label
><input
v-model="scanCapture"
class="uk-checkbox"
type="checkbox"
/>
Scan capture</label
>
</div>
<div :class="{ 'uk-disabled': !scanCapture }">
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
<div>
<label class="uk-form-label" for="form-stacked-text"
>x step-size</label
>
<div class="uk-form-controls">
<input
v-model="scanStepSize.x"
class="uk-input uk-form-small"
type="number"
name="inputPositionX"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text"
>y step-size</label
>
<div class="uk-form-controls">
<input
v-model="scanStepSize.y"
class="uk-input uk-form-small"
type="number"
name="inputPositionY"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text"
>z step-size</label
>
<div class="uk-form-controls">
<input
v-model="scanStepSize.z"
class="uk-input uk-form-small"
type="number"
name="inputPositionZx"
/>
</div>
</div>
</div>
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
<div>
<label class="uk-form-label" for="form-stacked-text"
>x steps</label
>
<div class="uk-form-controls">
<input
v-model="scanSteps.x"
class="uk-input uk-form-small"
type="number"
name="inputPositionX"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text"
>y steps</label
>
<div class="uk-form-controls">
<input
v-model="scanSteps.y"
class="uk-input uk-form-small"
type="number"
name="inputPositionY"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text"
>z steps</label
>
<div class="uk-form-controls">
<input
v-model="scanSteps.z"
class="uk-input uk-form-small"
type="number"
name="inputPositionZx"
/>
</div>
</div>
</div>
<div class="uk-margin-small uk-margin-remove-bottom">
<label class="uk-form-label" for="form-stacked-text"
>Autofocus</label
>
<select v-model="scanDeltaZ" class="uk-select">
<option>Off</option>
<option>Coarse</option>
<option>Medium</option>
<option>Fine</option>
<option>Fast</option>
</select>
</div>
<div class="uk-margin-small uk-margin-remove-bottom">
<label class="uk-form-label" for="form-stacked-text"
>Scan Style</label
>
<select v-model="scanStyle" class="uk-select">
<option>Raster</option>
<option>Snake</option>
</select>
</div>
</div>
</div>
</li>
</ul>
<div v-if="scanCapture" class="uk-margin uk-margin-remove-top ">
<taskSubmitter
:submit-url="scanUri"
:submit-data="scanPayload"
:submit-label="'Start Scan'"
:button-primary="true"
@submit="onScanSubmit"
@response="onScanResponse"
@error="onScanError"
>
</taskSubmitter>
</div>
<button
v-else
class="uk-button uk-button-primary uk-margin uk-margin-remove-top uk-width-1-1"
@click="handleCapture()"
>
Capture
</button>
</div>
</template>
<script>
import axios from "axios";
import tagList from "../fieldComponents/tagList";
import keyvalList from "../fieldComponents/keyvalList";
import taskSubmitter from "../genericComponents/taskSubmitter";
// Export main app
export default {
name: "PaneCapture",
components: {
tagList,
keyvalList,
taskSubmitter
},
data: function() {
return {
filename: "",
temporary: false,
fullResolution: false,
storeBayer: false,
resizeCapture: false,
captureNotes: "",
scanCapture: false,
scanDeltaZ: "Fast",
scanStyle: "Raster",
scanStepSize: {
x: 0,
y: 0,
z: 0
},
scanSteps: {
x: 3,
y: 3,
z: 5
},
resizeDims: [640, 480],
tags: [],
annotations: {
Client: `${process.env.PACKAGE.name}.${process.env.PACKAGE.version}`
},
scanUri: null
};
},
computed: {
resizeClass: function() {
return {
"uk-disabled": !this.resizeCapture
};
},
captureActionUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/camera/capture`;
},
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
},
settingsFovUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings/fov`;
},
basePayload: function() {
var payload = {};
// Filename
if (this.filename) {
payload.filename = this.filename;
}
// Basic boolean params
payload.temporary = this.temporary;
payload.use_video_port = !this.fullResolution;
payload.bayer = this.storeBayer;
// Resizing
if (this.resizeCapture) {
payload.resize = {
width: this.resizeDims[0],
height: this.resizeDims[1]
};
}
// Additional annotations
payload.annotations = this.annotations;
payload.tags = this.tags;
// Attach notes
if (this.captureNotes) {
payload.annotations["Notes"] = this.captureNotes;
}
console.log(payload);
return payload;
},
scanPayload: function() {
var payload = this.basePayload;
// Scan params
payload.grid = [this.scanSteps.x, this.scanSteps.y, this.scanSteps.z];
payload.stride_size = [
this.scanStepSize.x,
this.scanStepSize.y,
this.scanStepSize.z
];
payload.style = this.scanStyle.toLowerCase();
// Convert AF selector to dz
var afDeltas = {
Off: 0,
Coarse: 100,
Medium: 30,
Fine: 10,
Fast: 2000
};
payload.autofocus_dz = afDeltas[this.scanDeltaZ];
payload.fast_autofocus = this.scanDeltaZ == "Fast";
return payload;
}
},
mounted() {
this.updateScanUri();
this.updateScanStepSize();
// A global signal listener to perform a capture action
this.$root.$on("globalCaptureEvent", () => {
this.handleCapture();
});
},
methods: {
handleCapture: function() {
var payload = this.basePayload;
// Do capture
axios
.post(this.captureActionUri, payload)
.then(() => {
// Flash the stream (capture animation)
this.$root.$emit("globalFlashStream");
// Update the global capture list
this.$root.$emit("globalUpdateCaptures");
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateScanUri: function() {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.scan"
);
// if ScanPlugin is enabled
if (foundExtension) {
// Get plugin action link
this.scanUri = foundExtension.links.tile.href;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateScanStepSize: function() {
axios
.get(this.settingsFovUri) // Get the microscope FOV
.then(response => {
this.scanStepSize = {
x: parseInt(0.5 * response.data[0]),
y: parseInt(0.5 * response.data[1]),
z: 50
};
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onScanSubmit: function() {},
onScanResponse: function(responseData) {
console.log("Scan finished with response data: ", responseData);
this.modalNotify("Finished scan.");
},
onScanError: function(error) {
this.modalError(error);
}
}
};
</script>
<style lang="less">
.deletable-label {
cursor: pointer;
}
.deletable-label:hover {
background-color: #f0506e;
}
</style>

View file

@ -0,0 +1,362 @@
<template>
<div id="paneNavigate" class="uk-padding-small">
<div v-if="setPosition">
<ul uk-accordion="multiple: true">
<li>
<a class="uk-accordion-title" href="#">Configure</a>
<div class="uk-accordion-content">
<div class="uk-child-width-1-2" uk-grid>
<div>
<label class="uk-form-label" for="form-stacked-text"
>x-y step size</label
>
<div class="uk-form-controls">
<input
v-model="stepXy"
class="uk-input uk-form-width-medium uk-form-small"
type="number"
name="inputStepXy"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text"
>z step size</label
>
<div class="uk-form-controls">
<input
v-model="stepZz"
class="uk-input uk-form-width-medium uk-form-small"
type="number"
name="inputStepZz"
/>
</div>
</div>
</div>
<button
class="uk-button uk-button-default uk-margin uk-width-1-1"
@click="zeroRequest()"
>
Zero coordinates
</button>
</div>
</li>
<li class="uk-open">
<a class="uk-accordion-title" href="#">Move-to</a>
<div class="uk-accordion-content">
<form @submit.prevent="handleSubmit">
<!-- Text boxes to set and view position -->
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
<div>
<label class="uk-form-label" for="form-stacked-text">x</label>
<div class="uk-form-controls">
<input
v-model="setPosition.x"
class="uk-input uk-form-small"
type="number"
name="inputPositionX"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">y</label>
<div class="uk-form-controls">
<input
v-model="setPosition.y"
class="uk-input uk-form-small"
type="number"
name="inputPositionY"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">z</label>
<div class="uk-form-controls">
<input
v-model="setPosition.z"
class="uk-input uk-form-small"
type="number"
name="inputPositionZx"
/>
</div>
</div>
</div>
<p>
<button
type="submit"
class="uk-button uk-button-default uk-float-right uk-width-1-1"
>
Move
</button>
</p>
</form>
</div>
</li>
<!--Show autofocus if default plugin is enabled-->
<li v-show="fastAutofocusUri || normalAutofocusUri" class="uk-open">
<a class="uk-accordion-title" href="#">Autofocus</a>
<div class="uk-accordion-content">
<div class="uk-grid-small uk-child-width-expand" uk-grid>
<div v-show="!isAutofocusing || isAutofocusing == 1">
<taskSubmitter
v-if="fastAutofocusUri"
:submit-url="fastAutofocusUri"
:submit-data="{ dz: 2000 }"
:submit-label="'Fast'"
:button-primary="false"
:submit-on-event="'globalFastAutofocusEvent'"
@taskStarted="isAutofocusing = 1"
@finished="isAutofocusing = 0"
></taskSubmitter>
</div>
<div v-show="!isAutofocusing || isAutofocusing == 2">
<taskSubmitter
v-if="normalAutofocusUri"
:submit-url="normalAutofocusUri"
:submit-data="{ dz: [-60, -30, 0, 30, 60] }"
:submit-label="'Medium'"
:button-primary="false"
@taskStarted="isAutofocusing = 2"
@finished="isAutofocusing = 0"
></taskSubmitter>
</div>
<div v-show="!isAutofocusing || isAutofocusing == 3">
<taskSubmitter
v-if="normalAutofocusUri"
:submit-url="normalAutofocusUri"
:submit-data="{ dz: [-20, -10, 0, 10, 20] }"
:submit-label="'Fine'"
:button-primary="false"
@taskStarted="isAutofocusing = 3"
@finished="isAutofocusing = 0"
></taskSubmitter>
</div>
</div>
</div>
</li>
</ul>
</div>
<div v-else class="uk-text-warning">
<p>Stage positioning is disabled since no stage is connected.</p>
<p>
Please check all data and power connections to your motor controller
board.
</p>
</div>
</div>
</template>
<script>
import axios from "axios";
import taskSubmitter from "../genericComponents/taskSubmitter";
// Export main app
export default {
name: "PaneNavigate",
components: {
taskSubmitter
},
data: function() {
return {
stepXy: 200,
stepZz: 50,
setPosition: null,
isAutofocusing: 0,
moveLock: false,
fastAutofocusUri: null,
normalAutofocusUri: null,
moveInImageCoordinatesUri: null
};
},
computed: {
moveActionUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/stage/move`;
},
zeroActionUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/stage/zero`;
},
positionStatusUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/state/stage/position`;
},
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
}
},
mounted() {
// A global signal listener to perform a move action
this.$root.$on("globalMoveEvent", (x, y, z, absolute) => {
this.moveRequest(x, y, z, absolute);
});
// A global signal listener to perform a move action in pixels
this.$root.$on("globalMoveInImageCoordinatesEvent", (x, y, absolute) => {
this.moveInImageCoordinatesRequest(x, y, absolute);
});
// A global signal listener to perform a move in multiples of a step size
this.$root.$on("globalMoveStepEvent", (x_steps, y_steps, z_steps) => {
this.moveRequest(
x_steps * this.stepXy,
y_steps * this.stepXy,
z_steps * this.stepZz,
false
);
});
// Update the current position in text boxes
this.updatePosition();
// Look for autofocus plugin
this.updateAutofocusUri();
this.updateMoveInImageCoordinatesUri();
},
beforeDestroy() {
// Remove global signal listener to perform a move action
this.$root.$off("globalMoveEvent");
this.$root.$off("globalMoveInImageCoordinatesEvent");
this.$root.$off("globalMoveStepEvent");
},
methods: {
handleSubmit: function() {
this.moveRequest(
this.setPosition.x,
this.setPosition.y,
this.setPosition.z,
true
);
},
moveRequest: function(x, y, z, absolute) {
console.log(`Sending move request of ${x}, ${y}, ${z}`);
// If not movement-locked
if (!this.moveLock) {
// Lock move requests
this.moveLock = true;
// Send move request
axios
.post(this.moveActionUri, {
x: x,
y: y,
z: z,
absolute: absolute
})
.then(() => {
this.updatePosition(); // Update the position in text boxes
})
.catch(error => {
this.modalError(error); // Let mixin handle error
})
.then(() => {
this.moveLock = false; // Release the move lock
});
}
},
moveInImageCoordinatesRequest: function(x, y) {
console.log(`Sending move request in image coordinates: ${x}, ${y}`);
// If not movement-locked
if (!this.moveLock) {
// Lock move requests
this.moveLock = true;
if (!this.moveInImageCoordinatesUri) {
this.modalError(
"Moving in image coordinates is not supported - you will need to upgrade your microscope's software."
);
}
// Send move request
axios
.post(this.moveInImageCoordinatesUri, {
x: y, // NB the coordinates are numpy/PIL style, meaning X and Y are swapped.
y: x
})
.then(() => {
this.updatePosition(); // Update the position in text boxes
})
.catch(error => {
this.modalError(error); // Let mixin handle error
})
.then(() => {
this.moveLock = false; // Release the move lock
});
}
},
zeroRequest: function() {
// Send move request
axios
.post(this.zeroActionUri)
.then(() => {
this.updatePosition(); // Update the position in text boxes
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updatePosition: function() {
axios
.get(this.positionStatusUri)
.then(response => {
this.setPosition = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateAutofocusUri: function() {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.autofocus"
);
// if ScanPlugin is enabled
if (foundExtension) {
// Get plugin action link
this.fastAutofocusUri = foundExtension.links.fast_autofocus.href;
this.normalAutofocusUri = foundExtension.links.autofocus.href;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateMoveInImageCoordinatesUri: function() {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.camera_stage_mapping"
);
if (foundExtension) {
// Get plugin action link
this.moveInImageCoordinatesUri =
foundExtension.links.move_in_image_coordinates.href;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
}
};
</script>

View file

@ -0,0 +1,70 @@
<template>
<div>
<label>{{ label }}</label>
<div class="uk-form-controls">
<div v-for="option in options" :key="option">
<label>
<input
class="uk-checkbox"
type="checkbox"
:value="option"
:checked="value && value.includes(option)"
v-bind="$attrs"
@change="updateValue($event.target)"
/>
{{ option }}
</label>
</div>
</div>
</div>
</template>
<script>
export default {
name: "CheckList",
props: {
value: {
type: Array,
required: false,
default: function() {
return [];
}
},
options: {
type: Array,
required: true
},
name: {
type: String,
required: true
},
label: {
type: String,
required: true
}
},
methods: {
updateValue(target) {
var newSelected = this.value != null ? [...this.value] : []; // Clone value array
if (target.checked) {
if (!newSelected.includes(target.value)) {
newSelected.push(target.value);
}
} else {
if (newSelected.includes(target.value)) {
newSelected = newSelected.filter(function(value) {
return value != target.value;
});
}
}
this.$emit("input", newSelected);
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,29 @@
<template>
<div>
<p v-html="content"></p>
</div>
</template>
<script>
export default {
name: "HtmlBlock",
props: {
label: {
type: String,
required: false,
default: ""
},
name: {
type: String,
required: true
},
content: {
type: String,
required: true
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,124 @@
<template>
<div>
<form @submit.prevent="handleMetadataSubmit">
<div class="uk-margin-remove uk-flex uk-flex-middle">
<div
class="uk-margin-remove-top uk-padding-remove uk-grid-small uk-width-expand"
uk-grid
>
<div class="uk-margin-remove uk-width-1-2">
<input
ref="textboxKey"
v-model="newMetadata.key"
class="uk-input uk-form-width-small uk-form-small"
type="text"
name="flavor"
placeholder="Key"
/>
</div>
<div class="uk-margin-remove uk-width-1-2">
<input
v-model="newMetadata.value"
class="uk-input uk-form-width-small uk-form-small"
type="text"
name="flavor"
placeholder="Value"
@keyup.enter="handleMetadataSubmit()"
/>
</div>
</div>
<a
href="#"
class="uk-icon uk-margin-left"
@click="handleMetadataSubmit()"
><i class="material-icons">add_circle</i></a
>
</div>
</form>
<div
v-for="(val, key) in value"
:key="key"
class="uk-width-1-1 uk-margin-small uk-margin-remove-left uk-margin-remove-right uk-flex uk-flex-middle"
>
<div class="uk-margin-remove-top uk-padding-remove uk-width-expand">
<labelInput
:name="key"
:label="key"
:value="value[key]"
@input="value[key] = $event"
/>
</div>
<a href="#" class="uk-icon uk-width-auto" @click="delMetadataKey(key)"
><i class="material-icons">delete</i></a
>
</div>
</div>
</template>
<script>
import labelInput from "../fieldComponents/labelInput";
export default {
name: "KeyvalList",
components: {
labelInput
},
props: {
value: {
type: Object,
required: true
}
},
data: function() {
return {
newMetadata: {
key: "",
value: ""
}
};
},
methods: {
handleMetadataSubmit: function() {
var newSelected = {};
if (this.value != null) {
Object.assign(newSelected, this.value);
}
newSelected[this.newMetadata.key] = this.newMetadata.value;
this.newMetadata.key = "";
this.newMetadata.value = "";
this.$emit("input", newSelected);
// Move focus back to key textbox
this.$refs.textboxKey.focus();
},
delMetadataKey: function(key) {
var newSelected = {};
if (this.value != null) {
Object.assign(newSelected, this.value);
}
this.$delete(newSelected, key);
this.$emit("input", newSelected);
},
modifyValue: function(e, v) {
console.log(e);
console.log(v);
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,65 @@
<template>
<div>
<label class="uk-form-label uk-text-bold">{{ label }}</label>
<div
uk-tooltip="title: Click to edit value; delay: 250"
@click="setEditing(true)"
>
<div v-show="editing == false">
<label> {{ value }} </label>
</div>
<input
v-show="editing == true"
ref="textinput"
class="uk-input uk-form-small"
type="text"
:name="name"
:value="value"
autofocus
@blur="setEditing(false)"
@keyup.enter="setEditing(false)"
@input="$emit('input', $event.target.value)"
/>
</div>
</div>
</template>
<script>
export default {
name: "LabelInput",
props: {
label: {
type: String,
required: true
},
name: {
type: String,
required: true
},
value: {
type: String,
required: true
}
},
data: function() {
return {
editing: false
};
},
methods: {
setEditing(editing) {
this.editing = editing;
if (editing == true) {
this.$nextTick(() => this.$refs.textinput.focus());
}
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,44 @@
<template>
<div>
<label class="uk-form-label">{{ label }}</label>
<input
class="uk-input uk-form-small"
type="number"
:name="name"
:value="value"
:placeholder="placeholder"
v-bind="$attrs"
@input="$emit('input', Number($event.target.value))"
/>
</div>
</template>
<script>
export default {
name: "NumberInput",
props: {
value: {
type: Number,
required: false,
default: 0
},
placeholder: {
type: Number,
required: false,
default: 0
},
name: {
type: String,
required: true
},
label: {
type: String,
required: true
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,49 @@
<template>
<div>
<label>{{ label }}</label>
<div class="uk-form-controls">
<div v-for="option in options" :key="option">
<label
><input
class="uk-radio"
type="radio"
:name="name"
:value="option"
:checked="value == option"
@input="$emit('input', $event.target.value)"
/>
{{ option }}</label
>
</div>
</div>
</div>
</template>
<script>
export default {
name: "RadioList",
props: {
value: {
type: String,
required: false,
default: ""
},
options: {
type: Array,
required: true
},
name: {
type: String,
required: true
},
label: {
type: String,
required: true
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,44 @@
<template>
<div>
<label class="uk-form-label">{{ label }}</label>
<select
class="uk-select uk-form-small"
:value="value"
v-bind="$attrs"
@input="$emit('input', $event.target.value)"
>
<option v-for="option in options" :key="option">
{{ option }}
</option>
</select>
</div>
</template>
<script>
export default {
name: "SelectList",
props: {
value: {
type: String,
required: false,
default: ""
},
options: {
type: Array,
required: true
},
name: {
type: String,
required: true
},
label: {
type: String,
required: true
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,79 @@
<template>
<div>
<form @submit.prevent="handleTagSubmit">
<div class="uk-margin-small uk-flex uk-flex-middle">
<div class="uk-margin-remove-top uk-width-expand">
<div class="uk-inline uk-width-1-1">
<span class="uk-form-icon"
><i class="material-icons">label</i></span
>
<input
v-model="newTag"
class="uk-input uk-form-small"
type="text"
name="flavor"
placeholder="Tag"
/>
</div>
</div>
<a href="#" class="uk-icon uk-margin-left" @click="handleTagSubmit()"
><i class="material-icons">add_circle</i></a
>
</div>
</form>
<span
v-for="tag in value"
:key="tag"
class="uk-label uk-margin-small-right deletable-label"
@click="delTag(tag)"
>
{{ tag }}
</span>
</div>
</template>
<script>
export default {
name: "TagList",
props: {
value: {
type: Array,
required: true
}
},
data: function() {
return {
newTag: ""
};
},
methods: {
handleTagSubmit: function() {
var newSelected = this.value != null ? [...this.value] : []; // Clone value array
newSelected.push(this.newTag);
this.newTag = "";
this.$emit("input", newSelected);
},
delTag: function(tag) {
var newSelected = this.value != null ? [...this.value] : []; // Clone value array
if (newSelected.includes(tag)) {
newSelected = newSelected.filter(function(value) {
return value != tag;
});
}
this.$emit("input", newSelected);
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,45 @@
<template>
<div>
<label v-if="label" class="uk-form-label">{{ label }}</label>
<input
class="uk-input uk-form-small"
type="text"
:name="name"
:value="value"
:placeholder="placeholder"
v-bind="$attrs"
@input="$emit('input', $event.target.value)"
/>
</div>
</template>
<script>
export default {
name: "TextInput",
props: {
placeholder: {
type: String,
required: false,
default: null
},
label: {
type: String,
required: false,
default: null
},
name: {
type: String,
required: true
},
value: {
type: String,
required: false,
default: ""
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,157 @@
<template>
<div
class="progress uk-margin-top uk-margin-horizontal-remove uk-padding-remove"
>
<div class="indeterminate"></div>
</div>
</template>
<script>
export default {
name: "ProgressBar",
props: {},
computed: {
tooltipOptions: function() {
var title = this.id.charAt(0).toUpperCase() + this.id.slice(1);
return `pos: right; title: ${title}; delay: 500`;
},
classObject: function() {
return {
"tabicon-active": this.currentTab == this.id,
"uk-disabled": this.requireConnection && !this.$store.getters.ready
};
}
},
methods: {
setThisTab(event) {
this.$emit("set-tab", event, this.id);
}
}
};
</script>
<style lang="less" scoped>
@import "../../assets/less/theme.less";
.progress {
position: relative;
height: 5px;
display: block;
width: 100%;
background-color: rgba(180, 180, 180, 0.15);
border-radius: 2px;
background-clip: padding-box;
margin: 0.5rem 0 1rem 0;
overflow: hidden;
}
.progress .determinate {
position: absolute;
background-color: inherit;
top: 0;
bottom: 0;
transition: width 0.3s linear;
}
.progress .indeterminate,
.progress .determinate {
background-color: @global-primary-background;
}
.hook-inverse() {
.progress .indeterminate,
.progress .determinate {
background-color: @inverse-primary-muted-color;
}
}
.progress .indeterminate:before {
content: "";
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
-webkit-animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395)
infinite;
animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
}
.progress .indeterminate:after {
content: "";
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
-webkit-animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
infinite;
animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
infinite;
-webkit-animation-delay: 1.15s;
animation-delay: 1.15s;
}
@-webkit-keyframes indeterminate {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@keyframes indeterminate {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@-webkit-keyframes indeterminate-short {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
@keyframes indeterminate-short {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
</style>

View file

@ -0,0 +1,40 @@
<template>
<div
v-if="!(requireConnection && !$store.getters.ready)"
:hidden="currentTab != tabID"
class="uk-width-expand uk-height-1-1"
>
<slot></slot>
</div>
</template>
<script>
export default {
name: "TabContent",
props: {
tabID: {
type: String,
required: true
},
currentTab: {
type: String,
required: true
},
requireConnection: Boolean
},
computed: {},
methods: {}
};
</script>
<style lang="less" scoped>
.section-heading {
display: block;
font-size: 12px;
text-transform: uppercase;
line-height: 20px;
cursor: default;
}
</style>

View file

@ -0,0 +1,112 @@
<template>
<a
href="#"
class="uk-link"
:class="classObject"
:uk-tooltip="tooltipOptions"
@click="setThisTab"
>
<slot></slot>
<div v-if="showTitle" class="tabtitle">
{{ computedTitle }}
</div>
</a>
</template>
<script>
export default {
name: "TabIcon",
props: {
tabID: {
type: String,
required: true
},
title: {
type: String,
required: false,
default: undefined
},
showTitle: {
type: Boolean,
required: false,
default: true
},
showTooltip: {
type: Boolean,
required: false,
default: true
},
currentTab: {
type: String,
required: true
},
clickCallback: {
type: Function,
required: false,
default: null
},
requireConnection: Boolean
},
computed: {
computedTitle: function() {
if (this.title !== undefined) {
return this.title;
} else {
// Get the last section of a fully qualified name
var topName = this.tabID.split(".").pop();
// Make first character uppercase, then add the rest of the string
return topName.charAt(0).toUpperCase() + topName.slice(1);
}
},
tooltipOptions: function() {
if (this.showTooltip) {
return `pos: right; title: ${this.computedTitle}; delay: 500`;
} else {
return false;
}
},
classObject: function() {
return {
"tabicon-active": this.currentTab == this.tabID,
"uk-disabled": this.requireConnection && !this.$store.getters.ready
};
}
},
methods: {
setThisTab(event) {
this.$emit("set-tab", event, this.tabID);
if (this.clickCallback) {
this.clickCallback();
}
}
}
};
</script>
<style lang="less" scoped>
// Custom UIkit CSS modifications
@import "../../assets/less/theme.less";
.tabicon-active {
color: #fff !important;
background-color: @global-primary-background !important;
box-shadow: @small-shadow;
}
.tabtitle {
max-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 85%;
}
a:hover,
.uk-link:hover {
text-decoration: none !important;
}
</style>

View file

@ -0,0 +1,209 @@
<template>
<div>
<div
class="progress uk-margin-top uk-margin-horizontal-remove uk-padding-remove"
>
<div
v-if="progress"
class="determinate"
:style="barWidthFromProgress"
></div>
<div v-else class="indeterminate"></div>
</div>
<button
type="button"
class="uk-button uk-button-danger uk-form-small uk-float-right uk-width-1-1"
@click="terminateTask()"
>
Terminate
</button>
</div>
</template>
<script>
import axios from "axios";
export default {
name: "TaskProgress",
props: {
taskId: {
type: String,
required: true
},
pollInterval: {
type: Number,
required: false,
default: 500
}
},
data: function() {
return {
polling: null,
progress: null
};
},
computed: {
barWidthFromProgress: function() {
var progress = this.progress <= 100 ? this.progress : 100;
var styleString = `width: ${progress}%`;
return styleString;
}
},
created() {
this.polling = setInterval(() => {
this.pollProgress();
}, this.pollInterval);
},
beforeDestroy() {
clearInterval(this.polling);
},
methods: {
pollProgress: function() {
console.log("Starting progress polling");
axios
.get(`${this.$store.getters.uriV2}/tasks/${this.taskId}`)
.then(response => {
console.log("PROGRESS RESPONSE: ", response.data.progress);
this.progress = response.data.progress;
});
},
terminateTask: function() {
axios
.delete(`${this.$store.getters.uriV2}/tasks/${this.taskId}`)
.then(response => {
console.log("TERMINATION RESPONSE: ", response.data);
});
}
}
};
</script>
<style lang="less" scoped>
@import "../../assets/less/theme.less";
.progress {
position: relative;
height: 5px;
display: block;
width: 100%;
background-color: rgba(180, 180, 180, 0.15);
border-radius: 2px;
background-clip: padding-box;
margin: 0.5rem 0 1rem 0;
overflow: hidden;
}
.progress .determinate {
position: absolute;
background-color: inherit;
top: 0;
bottom: 0;
transition: width 0.3s linear;
}
.progress .indeterminate,
.progress .determinate {
background-color: @global-primary-background;
}
.hook-inverse() {
.progress .indeterminate,
.progress .determinate {
background-color: @inverse-primary-muted-color;
}
}
.progress .indeterminate:before {
content: "";
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
-webkit-animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395)
infinite;
animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
}
.progress .indeterminate:after {
content: "";
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
-webkit-animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
infinite;
animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
infinite;
-webkit-animation-delay: 1.15s;
animation-delay: 1.15s;
}
@-webkit-keyframes indeterminate {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@keyframes indeterminate {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@-webkit-keyframes indeterminate-short {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
@keyframes indeterminate-short {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
</style>

View file

@ -0,0 +1,373 @@
<template>
<div class="uk-margin-remove uk-padding-remove">
<div v-if="taskStarted" ref="isPollingElement">
<div class="progress uk-margin-small">
<div
v-if="progress"
class="determinate"
:style="barWidthFromProgress"
></div>
<div v-else class="indeterminate"></div>
</div>
<button
v-if="canTerminate && taskRunning"
type="button"
class="uk-button uk-button-danger uk-margin-remove uk-float-right uk-width-1-1"
@click="terminateTask()"
>
Terminate
</button>
</div>
<div>
<button
type="button"
:hidden="taskStarted"
class="uk-button uk-margin-remove uk-width-1-1"
:class="[buttonPrimary ? 'uk-button-primary' : 'uk-button-default']"
@click="bootstrapTask()"
>
{{ submitLabel }}
</button>
</div>
</div>
</template>
<script>
import axios from "axios";
export default {
name: "TaskSubmitter",
props: {
submitUrl: {
type: String,
required: true
},
submitData: {
type: [Object, Array],
required: false,
default: () => ({})
},
pollInterval: {
type: Number,
required: false,
default: 0.5
},
submitLabel: {
type: String,
required: false,
default: "Submit"
},
canTerminate: {
type: Boolean,
required: false,
default: true
},
requiresConfirmation: {
type: Boolean,
required: false,
default: false
},
confirmationMessage: {
type: String,
required: false,
default: "Start task?"
},
buttonPrimary: {
type: Boolean,
required: false,
default: true
},
submitOnEvent: {
type: String,
required: false,
default: null
}
},
data: function() {
return {
taskId: null,
polling: null,
progress: null,
taskStarted: false,
taskRunning: false
};
},
computed: {
barWidthFromProgress: function() {
var progress = this.progress <= 100 ? this.progress : 100;
var styleString = `width: ${progress}%`;
return styleString;
}
},
created() {},
mounted() {
// A global signal listener to perform a move action
if (this.submitOnEvent) {
this.$root.$on(this.submitOnEvent, () => {
this.bootstrapTask();
});
}
},
beforeDestroy() {
if (this.submitOnEvent) {
this.$root.$off(this.submitOnEvent);
}
},
methods: {
bootstrapTask: function() {
if (this.requiresConfirmation) {
this.modalConfirm(this.confirmationMessage).then(
() => {
this.startTask();
},
() => {}
);
} else {
this.startTask();
}
},
startTask: function() {
console.log("Task start clicked");
this.$emit("submit", this.submitData);
// Send a request to start a task
console.log("Submitting to ", this.submitUrl);
this.taskStarted = true;
this.$emit("taskStarted", this.taskId);
axios
.post(this.submitUrl, this.submitData)
// Get the returned Task ID
.then(response => {
// Fetch the task ID
console.log("Task ID: " + response.data.id);
this.taskId = response.data.id;
// Start the store polling TaskId for success
this.taskRunning = true;
this.$emit("taskRunning", this.taskId);
// Return the poll-task promise (starts polling the task)
return this.pollTask(this.taskId, this.pollInterval);
})
.catch(() => {
this.taskStarted = false;
})
.then(response => {
// Do something with the final response
console.log("Emitting onResponse: ", response);
this.$emit("response", response);
this.$emit("finished");
})
.catch(error => {
if (!error) {
error = Error("Unknown error");
}
console.log("Emitting onError: ", error);
this.$emit("error", error);
this.$emit("finished");
})
.finally(() => {
console.log("Cleaning up after task.");
// Reset taskRunning and taskId
this.taskRunning = false;
this.taskStarted = false;
this.taskId = null;
// Update the form data if we're self-updating
if (this.selfUpdate) {
this.getFormData();
}
});
},
pollTask: function(taskId, interval) {
interval = interval * 1000 || 500;
var checkCondition = (resolve, reject) => {
// If the condition is met, we're done!
axios
.get(`${this.$store.getters.uriV2}/tasks/${taskId}`)
.then(response => {
console.log(response.data.status);
var result = response.data.status;
// If the task ends with success
if (result == "success") {
resolve(response.data);
}
// If task ends with an error
else if (result == "error") {
// Pass the error string back with reject
reject(new Error(response.data.return));
}
// If task ends with termination
else if (result == "terminated") {
// Pass a generic termination error back with reject
reject(new Error("Task terminated"));
}
// If task ends in any other way
else if (result != "running") {
// Pass status string as error
reject(new Error(result));
}
// Didn't match and too much time, reject!
else {
// Since the task is still running, we can update the progress bar
this.progress = response.data.progress;
// Check again after timeout
setTimeout(checkCondition, interval, resolve, reject);
}
});
};
return new Promise(checkCondition);
},
pollProgress: function() {
console.log("Starting progress polling");
axios
.get(`${this.$store.getters.uriV2}/tasks/${this.taskId}`)
.then(response => {
console.log("PROGRESS RESPONSE: ", response.data.progress);
this.progress = response.data.progress;
});
},
terminateTask: function() {
axios
.delete(`${this.$store.getters.uriV2}/tasks/${this.taskId}`)
.then(response => {
console.log("TERMINATION RESPONSE: ", response.data);
});
}
}
};
</script>
<style lang="less" scoped>
@import "../../assets/less/theme.less";
.progress {
position: relative;
height: 5px;
display: block;
width: 100%;
background-color: rgba(180, 180, 180, 0.15);
border-radius: 2px;
background-clip: padding-box;
margin: 0.5rem 0 1rem 0;
overflow: hidden;
}
.progress .determinate {
position: absolute;
background-color: inherit;
top: 0;
bottom: 0;
transition: width 0.3s linear;
}
.progress .indeterminate,
.progress .determinate {
background-color: @global-primary-background;
}
.hook-inverse() {
.progress .indeterminate,
.progress .determinate {
background-color: @inverse-primary-muted-color;
}
}
.progress .indeterminate:before {
content: "";
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
-webkit-animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395)
infinite;
animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
}
.progress .indeterminate:after {
content: "";
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
-webkit-animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
infinite;
animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
infinite;
-webkit-animation-delay: 1.15s;
animation-delay: 1.15s;
}
@-webkit-keyframes indeterminate {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@keyframes indeterminate {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@-webkit-keyframes indeterminate-short {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
@keyframes indeterminate-short {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
</style>

View file

@ -0,0 +1,38 @@
<template>
<div class="uk-flex uk-flex-column uk-flex-center uk-height-1-1">
<div
v-if="$store.state.waiting"
class="uk-align-center"
uk-spinner="ratio: 3"
></div>
<div v-if="$store.state.waiting" class="uk-align-center">Loading...</div>
<i class="material-icons uk-align-center error-icon">error_outline</i>
<div v-if="$store.state.error" class="uk-align-center">
{{ $store.state.error }}
</div>
<div class="uk-align-center">
<devTools class="uk-width-medium"></devTools>
</div>
</div>
</template>
<script>
import devTools from "./controlComponents/devTools.vue";
// Export main app
export default {
name: "LoadingContent",
components: { devTools },
data: function() {
return {};
}
};
</script>
<style scoped lang="less">
.error-icon {
font-size: 120px;
}
</style>

View file

@ -0,0 +1,318 @@
<template>
<div id="modal-example" ref="calibrationModalEl" uk-modal="bg-close: false;">
<div v-if="ready" class="uk-modal-dialog uk-modal-body">
<h2 class="uk-modal-title">Microscope Calibration</h2>
<div v-show="stepValue == 0">
<p>
<b
>Some important microscope calibration data is currently missing.</b
>
</p>
<p>
Your microscope will still function, however some functionality will
be limited, and image quality will likely suffer.
</p>
<p>
<b>Click Next to begin microscope calibration.</b>
</p>
</div>
<div v-show="stepValue == 1">
<h3>Lens-shading</h3>
<div v-if="isLSTCalibrated">
<p>
<b
>Your lens-shading table has already been calibrated. Click Next
to move on.</b
>
</p>
</div>
<div v-else>
<p>
<b
>Follow the important steps below before starting lens-shading
calibration!</b
>
</p>
<ul class="uk-list uk-list-bullet">
<li>Remove any samples from your microscope</li>
<li>Ensure your illumination is on and properly fixed in place</li>
</ul>
<miniStreamDisplay
v-if="stepValue == 1"
class="mini-preview"
></miniStreamDisplay>
<p>Once you're ready, click auto-calibrate.</p>
<cameraCalibrationSettings
:show-extra-settings="false"
></cameraCalibrationSettings>
</div>
</div>
<div v-show="stepValue == 2">
<h3>Camera-stage mapping</h3>
<div v-if="isCSMCalibrated">
<p>
<b
>Your camera-stage mapping has already been calibrated. Click Next
to move on.</b
>
</p>
</div>
<div v-else-if="!canCSMCalibrated">
<p>
<b
>No stage connected. Please skip this step, or connect a valid
stage, then reboot your microscope.</b
>
</p>
</div>
<div v-else>
<p>
<b
>Follow the important steps below before starting camera-stage
mapping calibration!</b
>
</p>
<ul class="uk-list uk-list-bullet">
<li>Insert a clearly visible sample to the microscope</li>
<li>
Ensure the sample is reasonably well centered on the microscope
camera
</li>
</ul>
<miniStreamDisplay
v-if="stepValue == 2"
class="mini-preview"
></miniStreamDisplay>
<p>Once you're ready, click auto-calibrate.</p>
<cameraStageMappingSettings
:show-extra-settings="false"
></cameraStageMappingSettings>
</div>
</div>
<div v-show="stepValue == 3">
<p>
<b>Calibration complete</b>
</p>
<p>
Click Finish to return to your microscope, or Restart to re-run the
calibration routine
</p>
</div>
<p class="uk-text-right">
<button
v-show="stepValue == 0"
class="uk-button uk-button-default uk-modal-close"
type="button"
>
Cancel
</button>
<button
v-show="stepValue == 3"
class="uk-button uk-button-default"
type="button"
@click="stepValue = 0"
>
Restart
</button>
<button
v-show="stepValue < 3"
class="uk-button uk-button-primary uk-margin-left"
type="button"
@click="increment()"
>
Next
</button>
<button
v-show="stepValue == 3"
class="uk-button uk-button-primary uk-margin-left"
type="button"
@click="hide()"
>
Finish
</button>
</p>
</div>
</div>
</template>
<script>
import axios from "axios";
import cameraCalibrationSettings from "../viewComponents/settingsComponents/cameraCalibrationSettings.vue";
import cameraStageMappingSettings from "../viewComponents/settingsComponents/cameraStageMappingSettings.vue";
import miniStreamDisplay from "../viewComponents/miniStreamDisplay.vue";
export default {
name: "CalibrationModal",
components: {
cameraCalibrationSettings,
cameraStageMappingSettings,
miniStreamDisplay
},
props: {
availablePlugins: {
type: Array,
required: true
}
},
data: function() {
return {
ready: false,
stepValue: 0,
settings: {},
config: {}
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
},
configUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/configuration`;
},
availablePluginTitles: function() {
return this.availablePlugins.map(a => a.title);
},
isCSMCalibrated: function() {
if (this.settings.extensions["org.openflexure.camera_stage_mapping"]) {
return true;
} else {
return false;
}
},
isLSTCalibrated: function() {
if (
this.settings.camera.picamera &&
this.settings.camera.picamera.lens_shading_table
) {
return true;
} else {
return false;
}
},
canCSMCalibrated: function() {
// Assert CSM extension is enabled
var extensionEnabled = this.availablePluginTitles.includes(
"org.openflexure.camera_stage_mapping"
);
// Assert real stage is connected
var stageConnected = this.config.stage.type !== "MissingStage";
// Combine
return stageConnected && extensionEnabled;
},
canLSTCalibrated: function() {
// Assert LST extension is enabled
var extensionEnabled = this.availablePluginTitles.includes(
"org.openflexure.calibration.picamera"
);
// Assert real camera is connected
var cameraConnected = this.config.camera.type !== "MissingCamera";
// Combine
return cameraConnected && extensionEnabled;
},
isUseful: function() {
var CSMUseful = this.canCSMCalibrated && !this.isCSMCalibrated;
var LSTUseful = this.canLSTCalibrated && !this.isLSTCalibrated;
return CSMUseful || LSTUseful;
}
},
mounted() {
this.$refs["calibrationModalEl"].addEventListener("hidden", this.onHide);
},
methods: {
show: function() {
// Get current settings
this.getSettings()
.then(() => {
return this.getConfig();
})
.then(() => {
// Check if this calibration wizard can actually do anything useful
if (this.isUseful) {
this.ready = true;
this.stepValue = 0;
// Show the modal
var el = this.$refs["calibrationModalEl"];
this.showModalElement(el); // Calls the mixin
} else {
// If not useful, we just return the onClose event immediately
this.onHide();
}
});
},
hide: function() {
// Show the modal
var el = this.$refs["calibrationModalEl"];
this.hideModalElement(el); // Calls the mixin
this.ready = false;
},
onHide: function() {
console.log("UIKit modal hidden");
this.$emit("onClose");
},
getSettings: function() {
return axios
.get(this.settingsUri)
.then(response => {
this.settings = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
getConfig: function() {
return axios
.get(this.configUri)
.then(response => {
this.config = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
decrement: function() {
if (this.stepValue > 0) {
this.stepValue = this.stepValue - 1;
}
},
increment: function() {
// Upper bound on section number
if (this.stepValue < 3) {
this.stepValue = this.stepValue + 1;
return true;
}
}
}
};
</script>
<style scoped>
.mini-preview {
width: 75%;
margin-left: auto;
margin-right: auto;
border: 1px solid #555;
}
</style>

View file

@ -0,0 +1,257 @@
<template>
<div class="uk-padding-small">
<div class="uk-flex">
<div class="uk-text-bold uk-text-uppercase uk-width-expand">
{{ name }}
</div>
<a href="#" class="uk-icon uk-width-auto" @click="updateForm()"
><i class="material-icons">cached</i></a
>
</div>
<form ref="formContainer" class="uk-form-stacked" @submit.prevent="">
<div v-for="(field, index) in schema" :key="index">
<div
v-if="Array.isArray(field)"
class="uk-grid-small uk-width-1-1 uk-child-width-expand"
uk-grid
>
<div v-for="(subfield, subindex) in field" :key="subindex">
<component
:is="subfield.fieldType"
v-model="formData[subfield.name]"
v-bind="subfield"
>
</component>
</div>
</div>
<component
:is="field.fieldType"
v-model="formData[field.name]"
v-bind="field"
>
</component>
</div>
<div v-if="isTask" class="uk-margin">
<taskSubmitter
:submit-url="submitApiUri"
:submit-data="formData"
:submit-label="submitLabel"
@submit="onTaskSubmit"
@response="onTaskResponse"
@error="onTaskError"
>
</taskSubmitter>
</div>
<div v-else class="uk-margin">
<button
type="button"
class="uk-button uk-button-primary uk-width-1-1"
@click="newQuickRequest(formData)"
>
{{ submitLabel }}
</button>
</div>
</form>
</div>
</template>
<script>
import axios from "axios";
import numberInput from "../fieldComponents/numberInput";
import selectList from "../fieldComponents/selectList";
import textInput from "../fieldComponents/textInput";
import htmlBlock from "../fieldComponents/htmlBlock";
import radioList from "../fieldComponents/radioList";
import checkList from "../fieldComponents/checkList";
import tagList from "../fieldComponents/tagList";
import keyvalList from "../fieldComponents/keyvalList";
import taskSubmitter from "../genericComponents/taskSubmitter";
export default {
name: "JsonForm",
components: {
numberInput,
selectList,
textInput,
htmlBlock,
radioList,
checkList,
tagList,
keyvalList,
taskSubmitter
},
props: {
name: {
type: String,
required: false,
default: "Plugin"
},
schema: {
type: Array,
required: true
},
route: {
type: String,
required: true
},
isTask: {
type: Boolean,
required: false,
default: false
},
submitLabel: {
type: String,
required: false,
default: "Submit"
},
emitOnResponse: {
type: String,
required: false,
default: null
}
},
data: function() {
return {
formData: {}
};
},
computed: {
pluginApiUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
},
submitApiUri: function() {
// TODO: This could probably be handled more explicitally
return this.pluginApiUri + this.route;
}
},
watch: {
// Whenever the form schema updates, re-check server for field values
schema: function() {
this.updateFormValues();
}
},
created() {
this.initialiseFormData();
},
methods: {
initialiseFormData() {
/*
This function initialises the form data.
Limitations in Vue mean that newly created formData properties are not reactive.
GETting formData values from the server creates the properties, but the form components
don't get updated because of this limitation.
Here, we step through the form schema, and properly create reactive properties for each component.
*/
for (const field of this.schema) {
if (Array.isArray(field)) {
var defaultValue; // Initial value of the form component
for (const subfield of field) {
// If a default value is given in the schema, use this
if (subfield.value) {
defaultValue = subfield.value;
} else if (subfield.default) {
defaultValue = subfield.default;
} else {
defaultValue = null;
}
this.$set(this.formData, subfield.name, defaultValue);
}
} else {
// If a default value is given in the schema, use this
if (field.value) {
defaultValue = field.value;
} else if (field.default) {
defaultValue = field.default;
} else {
defaultValue = null;
}
this.$set(this.formData, field.name, defaultValue);
}
}
},
updateFormValues() {
for (const field of this.schema) {
if (Array.isArray(field)) {
for (const subfield of field) {
// If a default value is given in the schema, use this
if (subfield.value) {
this.$set(this.formData, subfield.name, subfield.value);
}
}
} else {
if (field.value) {
this.$set(this.formData, field.name, field.value);
}
}
}
},
updateForm() {
// Trigger a plugin form update
console.log("Emitting reloadForms");
this.$emit("reloadForms");
},
onSubmissionCompleted: function() {
if (this.emitOnResponse) {
this.$root.$emit(this.emitOnResponse);
}
this.updateForm();
},
newQuickRequest: function(params) {
console.log(this.submitApiUri);
console.log(params);
// Send a quick request
axios
.post(this.submitApiUri, params)
.then(response => {
// Do something with the response
console.log(response);
// Do all the finished request stuff
this.onSubmissionCompleted();
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onTaskSubmit: function() {},
onTaskResponse: function(responseData) {
console.log("Task finished with response data: ", responseData);
// Do all the finished request stuff
this.onSubmissionCompleted();
},
onTaskError: function(error) {
this.modalError(error);
}
}
};
</script>
<style scoped>
.flex-container {
display: flex;
}
.flex-container > div {
flex-basis: 100%;
}
</style>

View file

@ -0,0 +1,61 @@
<template>
<div class="uk-padding-small">
<div v-if="error" class="uk-padding-small uk-text-danger">
<b>{{ error }}</b>
</div>
<component
:is="componentName"
v-if="ready"
:component-base-u-r-l="ApiRootURL"
></component>
</div>
</template>
<script>
export default {
name: "WebComponentLoader",
props: {
componentURL: {
type: String,
required: true
},
componentName: {
type: String,
required: true
}
},
data: function() {
return {
ready: false,
error: null
};
},
computed: {
ApiRootURL: function() {
return `${this.$store.getters.baseUri}/api/v2`;
}
},
mounted() {
// Method 2
this.$loadScript(this.componentURL)
.then(() => {
const el = customElements.get(this.componentName);
if (el) {
this.ready = true;
} else {
this.error = `Error: No component ${this.componentName} found.`;
}
this.ready = true;
})
.catch(() => {
this.ready = false;
});
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,35 @@
<template>
<!-- Grid managing tab content -->
<div class="uk-height-1-1 view-component">
<div class="uk-padding-small">
<statusPane class="uk-width-large" />
</div>
<div class="uk-padding-small">
<h2>Links</h2>
<a
class="uk-link"
target="_blank"
href="https://gitlab.com/openflexure/openflexure-microscope-server/-/issues"
>Report an issue</a
>
</div>
<div class="uk-padding-small">
<h2>Developer tools</h2>
<devTools class="uk-width-large" />
</div>
</div>
</template>
<script>
import devTools from "../controlComponents/devTools.vue";
import statusPane from "../viewComponents/statusPane.vue";
export default {
name: "AboutContent",
components: {
devTools,
statusPane
}
};
</script>

View file

@ -0,0 +1,25 @@
<template>
<!-- Grid managing tab content -->
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="control-component">
<paneCapture />
</div>
<div class="view-component uk-width-expand">
<streamDisplay />
</div>
</div>
</template>
<script>
import paneCapture from "../controlComponents/paneCapture";
import streamDisplay from "../viewComponents/streamDisplay.vue";
export default {
name: "NavigateContent",
components: {
paneCapture,
streamDisplay
}
};
</script>

View file

@ -0,0 +1,89 @@
<template>
<!-- Grid managing tab content -->
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="control-component">
<vue-friendly-iframe v-if="frame" :src="frame.href"></vue-friendly-iframe>
<div v-else-if="webComponent">
<WebComponentLoader
:component-u-r-l="webComponent.href"
:component-name="webComponent.name"
/>
</div>
<!-- Handle OpenFlexure Forms -->
<div
v-for="form in forms"
v-else-if="forms"
:key="`${form.route}/${form.name}`.replace(/\s+/g, '-').toLowerCase()"
class="uk-height-1-1 uk-width-1-1"
>
<JsonForm
:name="form.name"
:route="form.route"
:is-task="form.isTask"
:submit-label="form.submitLabel"
:schema="form.schema"
:emit-on-response="form.emitOnResponse"
v-on="$listeners"
/>
</div>
</div>
<div class="view-component uk-width-expand">
<galleryDisplay v-if="viewPanel == 'gallery'" />
<settingsDisplay v-else-if="viewPanel == 'settings'" />
<streamDisplay v-else />
</div>
</div>
</template>
<script>
import JsonForm from "../pluginComponents/JsonForm";
import WebComponentLoader from "../pluginComponents/WebComponentLoader";
import streamDisplay from "../viewComponents/streamDisplay.vue";
import galleryDisplay from "../viewComponents/galleryDisplay.vue";
import settingsDisplay from "../viewComponents/settingsDisplay.vue";
export default {
name: "ExtensionContent",
components: {
JsonForm,
WebComponentLoader,
streamDisplay,
galleryDisplay,
settingsDisplay
},
props: {
forms: {
type: Array,
required: false,
default: () => []
},
webComponent: {
type: Object,
required: false,
default: null
},
frame: {
type: Object,
required: false,
default: null
},
viewPanel: {
type: String,
required: false,
default: "stream"
}
}
};
</script>
<style>
.vue-friendly-iframe {
height: 100%;
}
.vue-friendly-iframe iframe {
height: 100%;
}
</style>

View file

@ -0,0 +1,19 @@
<template>
<!-- Grid managing tab content -->
<div class="view-component uk-height-1-1 uk-width-expand">
<galleryDisplay />
</div>
</template>
<script>
import galleryDisplay from "../viewComponents/galleryDisplay.vue";
export default {
name: "NavigateContent",
components: {
galleryDisplay
}
};
</script>

View file

@ -0,0 +1,25 @@
<template>
<!-- Grid managing tab content -->
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="control-component">
<paneNavigate />
</div>
<div class="view-component uk-width-expand">
<streamDisplay />
</div>
</div>
</template>
<script>
import paneNavigate from "../controlComponents/paneNavigate";
import streamDisplay from "../viewComponents/streamDisplay.vue";
export default {
name: "NavigateContent",
components: {
paneNavigate,
streamDisplay
}
};
</script>

View file

@ -0,0 +1,20 @@
<template>
<!-- Grid managing tab content -->
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="view-component uk-width-expand uk-padding-small">
<settingsDisplay />
</div>
</div>
</template>
<script>
import settingsDisplay from "../viewComponents/settingsDisplay.vue";
export default {
name: "NavigateContent",
components: {
settingsDisplay
}
};
</script>

View file

@ -0,0 +1,20 @@
<template>
<!-- Grid managing tab content -->
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="view-component uk-width-expand">
<streamDisplay />
</div>
</div>
</template>
<script>
import streamDisplay from "../viewComponents/streamDisplay.vue";
export default {
name: "ViewContent",
components: {
streamDisplay
}
};
</script>

View file

@ -0,0 +1,168 @@
<template>
<div
class="hostCard uk-card uk-card-default uk-padding-remove uk-width-medium"
:class="{ 'uk-card-secondary': $store.state.globalSettings.darkMode }"
>
<div class="uk-card-media-top">
<div class="snapshot-container uk-width-1-1 uk-height-auto">
<img
class="uk-width-1-1"
:data-src="snapshotSrc"
:src="snapshotSrc"
width="300"
height="225"
/>
<div
v-if="!snapshotAvailable"
class="uk-position-cover uk-flex uk-flex-center uk-flex-middle"
>
Snapshot unavailable
</div>
</div>
</div>
<div class="uk-card-body uk-padding-small">
<div
class="uk-margin-small uk-margin-remove-left uk-margin-remove-right uk-flex uk-flex-middle"
>
<div class="uk-width-expand host-description">
<div class="host-description">
<b>{{ name }}</b>
</div>
<div class="host-description">{{ hostname }}:{{ port }}</div>
</div>
<a
v-if="deletable"
href="#"
class="uk-icon uk-width-auto host-delete"
@click="$emit('delete')"
><i class="material-icons">delete</i></a
>
</div>
</div>
<div class="uk-card-footer uk-padding-small">
<button
class="uk-button uk-button-default uk-form-small uk-float-right uk-margin uk-margin-remove-top uk-width-1-1"
@click="$emit('connect')"
>
Connect
</button>
</div>
</div>
</template>
<script>
import axios from "axios";
// Export main app
export default {
name: "HostCard",
props: {
name: {
type: String,
required: true
},
hostname: {
type: String,
required: true
},
port: {
type: Number,
required: true
},
deletable: {
type: Boolean,
required: false,
default: true
}
},
data: function() {
return {
snapshotRule: "/api/v2/streams/snapshot",
snapshotSrc: "",
snapshotAvailable: false,
polling: null
};
},
computed: {
routesURL: function() {
return `http://${this.hostname}:${this.port}/routes`;
},
snapshotURL: function() {
return `http://${this.hostname}:${this.port}${this.snapshotRule}`;
}
},
mounted() {
// Try to load the microscope snapshot when mounted
this.checkSnapshotAvailable();
},
beforeDestroy() {
// Clear the polling timer
if (this.polling !== null) {
clearInterval(this.polling);
}
},
methods: {
checkSnapshotAvailable: function() {
// Fetches the list of routes from the host, and starts
// a snapshot timer if the snapshot route is available
// Send tag request
axios
.get(this.routesURL)
.then(response => {
// If the host has a snapshot route available
if (this.snapshotRule in response.data) {
// Tell the view that a snapshot is available
this.snapshotAvailable = true;
// Update the snapshot URL
this.updateSnapshotURL();
// If not already polling, start polling
if (this.polling === null) {
this.updateSnapshotPoll();
}
}
})
.catch(() => {
// Tell the view that a snapshot is not available, and ignore
this.snapshotAvailable = false;
});
},
updateSnapshotURL: function() {
// Set the snapshot image src to the snapshot URL,
// adding a timestamp argument to act as a cache-breaker
this.snapshotSrc = `${this.snapshotURL}?${Date.now()}`;
},
updateSnapshotPoll: function() {
// Start a timer to call updateSnapshotURL periodically
this.polling = setInterval(() => {
this.updateSnapshotURL();
}, 15000);
}
}
};
</script>
<style>
.host-description {
text-overflow: ellipsis;
overflow: hidden;
}
.snapshot-container {
position: relative;
}
img[data-src][src*="data:image"] {
background: rgba(127, 127, 127, 0.1);
}
</style>

View file

@ -0,0 +1,329 @@
<template>
<div
class="capture-card uk-card uk-card-default uk-padding-remove uk-width-medium"
:class="{ 'uk-card-secondary': $store.state.globalSettings.darkMode }"
>
<div class="uk-card-media-top">
<a class="lightbox-link" :href="imgURL" :data-caption="fileName">
<img
class="uk-width-1-1"
:data-src="thumbURL"
:alt="captureState.metadata.image.id"
width="300"
height="225"
uk-img
/>
</a>
</div>
<div class="uk-card-body uk-padding-small">
<div
class="uk-width-1-1 uk-margin-small uk-margin-remove-left uk-margin-remove-right"
uk-grid
>
<div class="uk-margin-remove-top uk-padding-remove uk-width-expand">
{{ fileName }}
</div>
<div class="uk-margin-remove-top uk-padding-remove uk-width-auto">
<a href="#" class="uk-icon" @click="delCaptureConfirm()">
<i class="material-icons">delete</i>
</a>
</div>
</div>
<div
class="uk-text-meta uk-margin-remove-top uk-padding-remove uk-width-expand"
>
<time>{{ captureState.metadata.image.acquisitionDate }}</time>
</div>
<div
class="uk-text-meta uk-margin-remove-top uk-padding-remove uk-width-auto"
>
<a :href="metadataModalTarget" uk-toggle>More...</a>
</div>
</div>
<div class="uk-card-footer uk-padding-small">
<div v-for="tag in tags" :key="tag" class="uk-display-inline">
<span
v-if="tag === 'temporary'"
class="uk-label uk-label-danger uk-margin-small-right"
uk-tooltip="title: Capture will be removed automatically; delay: 500"
>Temporary</span
>
<span
v-else
class="uk-label uk-margin-small-right deletable-label"
@click="delTagConfirm(tag)"
>
{{ tag }}
</span>
</div>
<a :href="tagModalTarget" uk-toggle>
<span class="uk-label uk-label-success uk-margin-small-right">Add</span>
</a>
</div>
<!-- Metadata modal -->
<div :id="metadataModalID" uk-modal>
<div
class="uk-modal-dialog uk-modal-body"
:class="{
'uk-light uk-background-secondary':
$store.state.globalSettings.darkMode
}"
>
<button class="uk-modal-close-default" type="button" uk-close></button>
<h2 class="uk-modal-title">{{ fileName }}</h2>
<p><b>Path: </b>{{ captureState.path }}</p>
<p><b>Time: </b>{{ captureState.metadata.image.acquisitionDate }}</p>
<p><b>ID: </b>{{ captureState.metadata.image.id }}</p>
<p><b>Format: </b>{{ captureState.metadata.image.format }}</p>
<hr />
<div v-for="(value, key) in annotations" :key="key">
<p>
<b>{{ key }}: </b>{{ value }}
</p>
</div>
<hr />
<div class="uk-flex-bottom" uk-grid>
<div class="uk-width-2-3">
<keyvalList v-model="newAnnotations" />
</div>
<div class="uk-width-1-3">
<button
class="uk-button uk-button-primary uk-form-small uk-width-1-1"
@click="handleAnnotationsSubmit()"
>
Add annotation
</button>
</div>
</div>
</div>
</div>
<!-- New tag modal -->
<div :id="tagModalID" uk-modal>
<form
class="uk-modal-dialog uk-modal-body uk-margin-auto-vertical"
:class="{
'uk-light uk-background-secondary':
$store.state.globalSettings.darkMode
}"
@submit.prevent="handleTagSubmit"
>
<div class="uk-inline">
<span class="uk-form-icon"><i class="material-icons">label</i></span>
<input
v-model="newTag"
autofocus
class="uk-input uk-form-width-medium uk-form-small"
type="text"
name="tagname"
placeholder="tag"
/>
<button
class="uk-button uk-button-default uk-margin-left uk-form-small uk-modal-close"
type="button"
>
Cancel
</button>
<button
type="submit"
class="uk-button uk-button-primary uk-margin-left uk-form-small"
>
Save
</button>
</div>
</form>
</div>
</div>
</template>
<script>
import UIkit from "uikit";
import axios from "axios";
import keyvalList from "../../fieldComponents/keyvalList";
// Export main app
export default {
name: "CaptureCard",
components: {
keyvalList
},
props: {
captureState: {
type: Object,
required: true
}
},
data: function() {
return {
tags: [],
newTag: "",
annotations: {},
newAnnotations: {}
};
},
computed: {
fileName: function() {
return this.captureState.name // If this.captureState.filename exists
? this.captureState.name // Use this.captureState.filename
: this.captureState.metadata.filename; // Otherwise use old this.captureState.metadata.filename
},
tagModalID: function() {
return this.makeModalName("tag-modal-");
},
tagModalTarget: function() {
return "#" + this.tagModalID;
},
metadataModalID: function() {
return this.makeModalName("metadata-modal-");
},
metadataModalTarget: function() {
return "#" + this.metadataModalID;
},
thumbURL: function() {
return `${this.captureState.links.download.href}?thumbnail=true`;
},
imgURL: function() {
return this.captureState.links.download.href;
},
tagsURL: function() {
return this.captureState.links.tags.href;
},
annotationsURL: function() {
return this.captureState.links.annotations.href;
},
captureURL: function() {
return this.captureState.links.self.href;
}
},
created: function() {
this.getTagRequest();
this.getAnnotationsRequest();
},
methods: {
handleTagSubmit: function(event) {
if (this.newTag !== "") {
this.newTagRequest(this.newTag);
this.newTag = "";
}
UIkit.modal(event.target.parentNode).hide();
},
handleAnnotationsSubmit: function() {
this.putAnnotationsRequest(this.newAnnotations);
this.newAnnotations = {};
},
delCaptureConfirm: function() {
var context = this;
this.modalConfirm("Permanantly delete capture?").then(function() {
context.delCaptureRequest();
});
},
delCaptureRequest: function() {
// Send tag DELETE request
axios
.delete(this.captureURL)
.then(() => {
// Emit signal to update capture list
this.$root.$emit("globalUpdateCaptures");
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
newTagRequest: function(tagString) {
// Send tag PUT request
axios
.put(this.tagsURL, [tagString])
.then(() => {
// Update tag array
this.getTagRequest();
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
putAnnotationsRequest: function(annotationsObject) {
// Send metadata PUT request
axios
.put(this.annotationsURL, annotationsObject)
.then(() => {
// Update metadata object
this.getAnnotationsRequest();
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
delTagConfirm: function(tagString) {
var context = this;
this.modalConfirm(`Remove tag '${tagString}'?`).then(function() {
context.delTagRequest(tagString);
});
},
delTagRequest: function(tagString) {
console.log(tagString);
// Send tag DELETE request
axios
.delete(this.tagsURL, { data: [tagString] })
.then(() => {
// Update tag array
this.getTagRequest();
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
getTagRequest: function() {
// Send tag request
axios
.get(this.tagsURL)
.then(response => {
this.tags = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
getAnnotationsRequest: function() {
// Send tag request
axios
.get(this.annotationsURL)
.then(response => {
this.annotations = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
makeModalName: function(prefix) {
return prefix + this.captureState.metadata.image.id;
}
}
};
</script>

View file

@ -0,0 +1,149 @@
<template>
<div
class="capture-card uk-card uk-card-primary uk-padding-remove uk-width-medium"
>
<div class="uk-card-media-top">
<a href="#">
<img
class="uk-width-1-1"
:data-src="thumbnail"
:alt="metadata.image.id"
width="300"
height="225"
uk-img
@click="$root.$emit('globalUpdateCaptureFolder', metadata.image.id)"
/>
</a>
</div>
<div class="uk-card-body uk-padding-small">
<div
class="uk-width-1-1 uk-margin-small uk-margin-remove-left uk-margin-remove-right"
uk-grid
>
<div class="uk-margin-remove-top uk-padding-remove uk-width-expand">
<b>{{ metadata.type || "Dataset" }}: </b> {{ metadata.image.name }}
</div>
<div class="uk-margin-remove-top uk-padding-remove uk-width-auto">
<a href="#" class="uk-icon" @click="delAllConfirm()">
<i class="material-icons">delete</i>
</a>
</div>
</div>
<div
class="uk-text-meta uk-margin-remove-top uk-padding-remove uk-width-expand"
>
<time>{{ metadata.image.acquisitionDate }}</time>
</div>
<div
class="uk-text-meta uk-margin-remove-top uk-padding-remove uk-width-auto"
>
<a :href="metadataModalTarget" uk-toggle>More...</a>
</div>
</div>
<div class="uk-card-footer uk-padding-small">
<span
v-for="tag in metadata.image.tags"
:key="tag"
class="uk-label uk-margin-small-right deletable-label"
>
{{ tag }}
</span>
</div>
<div :id="metadataModalID" uk-modal>
<div class="uk-modal-dialog uk-modal-body">
<button class="uk-modal-close-default" type="button" uk-close></button>
<h2 class="uk-modal-title">{{ metadata.image.name }}</h2>
<p><b>Time: </b>{{ metadata.image.acquisitionDate }}</p>
<p><b>ID: </b>{{ metadata.image.id }}</p>
<hr />
<div v-for="(value, key) in metadata.image.annotations" :key="key">
<p>
<b>{{ key }}: </b>{{ value }}
</p>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
// Export main app
export default {
name: "ScanCard",
props: {
metadata: {
type: Object,
required: true
},
thumbnail: {
type: String,
required: true
},
captures: {
type: Array,
required: true
}
},
computed: {
tagModalID: function() {
return this.makeModalName("tag-modal-");
},
tagModalTarget: function() {
return "#" + this.tagModalID;
},
metadataModalID: function() {
return this.makeModalName("metadata-modal-");
},
metadataModalTarget: function() {
return "#" + this.metadataModalID;
},
allURLs: function() {
var urls = [];
for (var capture of this.captures) {
urls.push(capture.links.self.href);
}
return urls;
}
},
methods: {
makeModalName: function(prefix) {
return prefix + this.metadata.image.id;
},
delAllConfirm: function() {
var context = this;
this.modalConfirm(
"Permanantly delete all captures in this dataset?"
).then(function() {
context.deleteAll();
});
},
deleteAll: function() {
axios
.all(this.allURLs.map(l => axios.delete(l)))
.then
//axios.spread(function(...res) {
// all requests are now complete
//console.log(res);
//})
()
.then(() => {
// Emit signal to update capture list
this.$root.$emit("globalUpdateCaptures");
});
}
}
};
</script>

View file

@ -0,0 +1,184 @@
<template>
<div v-if="zipBuilderUri" class="uk-width-small">
<div v-show="downloadReady">
<button
:disabled="isDownloading"
type="button"
class="uk-button uk-button-default uk-form-small uk-width-1-1"
@click="downloadWithAxios()"
>
{{ isDownloading ? `${downloadProgress}%` : "Download" }}
</button>
</div>
<div v-show="!downloadReady">
<taskSubmitter
v-if="zipBuilderUri"
:can-terminate="false"
:submit-url="zipBuilderUri"
:submit-label="'Create ZIP'"
:submit-data="captureIds"
@submit="onSubmit"
@response="onResponse"
@error="onError"
>
</taskSubmitter>
</div>
</div>
</template>
<script>
import axios from "axios";
import taskSubmitter from "../../genericComponents/taskSubmitter";
// Export main app
export default {
name: "ZipDownloader",
components: {
taskSubmitter
},
props: {
captureIds: {
type: Array,
required: true
}
},
data: function() {
return {
downloadProgress: 0,
isDownloading: false,
downloadReady: false,
downloadUrl: null,
zipBuilderUri: null,
zipGetterUri: null,
lastSessionId: null
};
},
computed: {
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
}
},
mounted: function() {
this.updateZipperUri();
},
created: function() {
// Watch for host 'ready', then update status
this.unwatchStoreFunction = this.$store.watch(
(state, getters) => {
return getters.ready;
},
ready => {
if (ready) {
// If the connection is now ready, update zipper URL
this.updateZipperUri();
}
}
);
},
methods: {
updateZipperUri: function() {
if (this.$store.state.available) {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.zipbuilder"
);
// if ZipBuilderPlugin is enabled
if (foundExtension) {
// Get plugin action links
this.zipBuilderUri = foundExtension.links.build.href;
this.zipGetterUri = foundExtension.links.get.href;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
} else {
this.zipBuilderUri = null;
this.zipGetterUri = null;
}
},
resetZipper: function() {
this.downloadReady = false;
},
forceFileDownload(response) {
// Start file download by creating and clicking an invisible link
// One day I hope for a better solution to exist for this...
// A man can dream.....
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `${this.lastSessionId}.zip`); //or any other extension
document.body.appendChild(link);
link.click();
},
downloadWithAxios() {
// Configure progress indicator and response type
let config = {
responseType: "blob",
onDownloadProgress: progressEvent => {
this.downloadProgress = Math.floor(
(progressEvent.loaded * 100) / progressEvent.total
);
}
};
// Set downloading flag
this.isDownloading = true;
// Start download
axios
.get(this.downloadUrl, config)
.then(response => {
this.forceFileDownload(response);
this.deleteLastZip();
this.resetZipper();
})
.catch(error => {
this.modalError(error); // Let mixin handle error
})
.finally(() => {
this.isDownloading = false;
});
},
deleteLastZip() {
axios
.delete(this.downloadUrl)
.then(response => {
console.log(response);
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onResponse: function(response) {
this.lastSessionId = response.return.id;
this.downloadUrl = `${this.zipGetterUri}/${this.lastSessionId}`;
this.downloadReady = true;
},
onSubmit: function(submitData) {
console.log("SUBMITTED");
console.log(submitData);
},
onError: function(error) {
this.modalError(error); // Let mixin handle error
}
}
};
</script>

View file

@ -0,0 +1,409 @@
<template>
<div class="galleryDisplay uk-padding uk-padding-remove-top">
<!-- Gallery nav bar -->
<nav
class="gallery-navbar uk-navbar-container uk-navbar-transparent"
uk-navbar="mode: click"
>
<!-- Left side controls -->
<div
class="uk-navbar-left uk-padding-remove-top uk-padding-remove-bottom"
>
<ul class="uk-navbar-nav">
<li :class="[sortDescending ? 'uk-active' : '']">
<a class="uk-icon" href="#" @click="sortDescending = true">
<i class="material-icons">keyboard_arrow_down</i>
</a>
</li>
<li :class="[!sortDescending ? 'uk-active' : '']">
<a class="uk-icon" href="#" @click="sortDescending = false">
<i class="material-icons">keyboard_arrow_up</i>
</a>
</li>
<li>
<a href="#">Filter</a>
<div
class="uk-navbar-dropdown"
:class="{
'uk-light uk-background-secondary':
$store.state.globalSettings.darkMode
}"
>
<ul class="uk-nav uk-navbar-dropdown-nav">
<form class="uk-form-stacked">
<div
v-for="tag in allTags"
:key="tag"
class="uk-margin-small"
>
<label>
<input
:id="tag"
v-model="checkedTags"
class="uk-checkbox"
type="checkbox"
:value="tag"
checked
/>
{{ tag }}
</label>
</div>
</form>
</ul>
</div>
</li>
</ul>
</div>
<!-- Right side buttons -->
<div class="uk-navbar-right">
<div class="uk-grid">
<div>
<button
type="button"
class="uk-button uk-button-default uk-width-1-1"
@click="updateCaptures()"
>
Refresh Captures
</button>
</div>
<div>
<ZipDownloader :capture-ids="Object.keys(filteredCaptures)" />
</div>
</div>
</div>
</nav>
<!-- Gallery -->
<div
v-if="$store.getters.ready"
class="uk-padding-remove-top"
uk-lightbox="toggle: .lightbox-link"
>
<!-- Folder heading -->
<div
v-if="galleryFolder"
class="gallery-folder-heading uk-flex uk-flex-middle"
>
<a
href="#"
class="uk-icon uk-margin-remove"
@click="galleryFolder = ''"
>
<i class="material-icons">arrow_back</i>
</a>
<div class="uk-margin-left">
<h3 class="uk-margin-remove uk-margin-left">
<b>SCAN</b>
{{ allScans[galleryFolder].metadata.image.name }}
</h3>
</div>
</div>
<!-- Gallery capture cards -->
<div class="gallery-grid">
<div v-for="item in sortedItems" :key="item.metadata.id">
<scanCard
v-if="'isScan' in item"
:metadata="item.metadata"
:thumbnail="item.thumbnail"
:captures="item.captures"
/>
<captureCard v-else :capture-state="item" />
</div>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
import captureCard from "./galleryComponents/captureCard.vue";
import scanCard from "./galleryComponents/scanCard.vue";
import ZipDownloader from "./galleryComponents/zipDownloader";
// Export main app
export default {
name: "GalleryDisplay",
components: {
captureCard,
scanCard,
ZipDownloader
},
data: function() {
return {
captures: [],
checkedTags: [],
sortDescending: true,
galleryFolder: "",
scanTag: "scan",
unwatchStoreFunction: null
};
},
computed: {
capturesUri: function() {
return `${this.$store.getters.baseUri}/api/v2/captures`;
},
allTags: function() {
// Return an array of unique tags across all captures
var tags = [];
for (var capture of this.captures) {
for (var tag of capture.metadata.image.tags) {
if (!tags.includes(tag)) {
tags.push(tag);
}
}
}
return tags.sort();
},
noScanCaptures: function() {
// List of captures that are not part of a scan
var captures = [];
for (var capture of this.captures) {
// Add to capture list if matched
if (!capture.metadata.dataset) {
captures.push(capture);
}
}
return captures;
},
allScans: function() {
// List of scans as capture-like objects
var scans = {};
for (var capture of this.captures) {
var annotations = capture.metadata.image.annotations;
var tags = capture.metadata.image.tags;
var dataset = capture.metadata.dataset;
if (dataset) {
var id = dataset["id"];
// If this scan ID hasn't been seen before
if (!(id in scans)) {
scans[id] = {};
scans[id].isScan = true;
scans[id].captures = [];
scans[id].metadata = {
image: {
name: dataset.name,
acquisitionDate: dataset.acquisitionDate,
id: dataset.id,
tags: [],
annotations: {}
},
type: dataset.type
};
}
// Add the capture object to the scan
scans[id].captures.push(capture);
// Add missing scan metadata, prioritising first capture
for (var key of Object.keys(annotations)) {
if (!(key in scans[id].metadata.image.annotations)) {
scans[id].metadata.image.annotations[key] = annotations[key];
}
}
// Append missing tags
for (var tag of tags) {
if (!scans[id].metadata.image.tags.includes(tag)) {
scans[id].metadata.image.tags.push(tag);
}
}
// Create a preview thumbnail
if (!("thumbnail" in scans[id])) {
scans[
id
].thumbnail = `${capture.links.download.href}?thumbnail=true`;
}
}
}
return scans;
},
scanList: function() {
// List of scans, obtained from this.allScans values
return Object.values(this.allScans);
},
itemList: function() {
// Get list of current items to show
// If galleryFolder (ie inside a scan folder), show scan captures
// Otherwise, show root captures and scan cards
if (this.galleryFolder) {
console.log(this.allScans[this.galleryFolder].captures);
return this.allScans[this.galleryFolder].captures;
} else {
return this.noScanCaptures.concat(this.scanList);
}
},
filteredItems: function() {
// Filter itemList by checkedTags
return this.filterCaptures(this.itemList, this.checkedTags);
},
filteredCaptures: function() {
var captures = {};
for (var item of this.filteredItems) {
// If it's a dataset
if ("captures" in item) {
for (var capture of item.captures) {
// Get the ID of each capture in the set
captures[capture.metadata.image.id] = capture;
}
} else {
// If it's a single capture, get the ID of the capture
captures[item.metadata.image.id] = item;
}
}
return captures;
},
sortedItems: function() {
// Sort filteredItems using sortCaptures function
return this.sortCaptures(this.filteredItems);
}
},
mounted() {
// Update on mount (does nothing if not connected)
this.updateCaptures();
// A global signal listener to perform a gallery refresh
this.$root.$on("globalUpdateCaptures", () => {
this.updateCaptures();
});
// A global signal listener to set the gallery folder
this.$root.$on("globalUpdateCaptureFolder", folder => {
this.galleryFolder = folder;
});
},
created: function() {
// Watch for host 'ready', then update status
this.unwatchStoreFunction = this.$store.watch(
(state, getters) => {
return getters.ready;
},
ready => {
if (ready) {
// If the connection is now ready, update capture list
this.updateCaptures();
} else {
// If the connection is now disconnected, empty capture list
this.captures = {};
}
}
);
},
beforeDestroy() {
// Remove global signal listener to perform a gallery refresh
this.$root.$off("globalUpdateCaptures");
// Remove global signal listener to set the gallery folder
this.$root.$off("globalUpdateCaptureFolder");
// Then we call that function here to unwatch
if (this.unwatchStoreFunction) {
this.unwatchStoreFunction();
this.unwatchStoreFunction = null;
}
},
methods: {
updateCaptures: function() {
if (this.$store.state.available) {
console.log("Updating capture list...");
axios
.get(this.capturesUri)
.then(response => {
this.captures = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
} else {
console.log("Delaying capture update until connection is available");
}
},
filterCaptures: function(list, filterTags) {
// Filter a list of captures by an array of tags
var result = [];
for (var capture of list) {
// Assume exclusion
var includeCapture = false;
// Filter by selected tags
var tags = capture.metadata.image.tags;
let checker = (arr, target) => target.every(v => arr.includes(v));
// True if all tags match
includeCapture = checker(tags, filterTags);
// Add to capture list if matched
if (includeCapture == true) {
result.push(capture);
}
}
return result;
},
sortCaptures: function(list) {
// Sort a list of captures by metadata time
function compare(a, b) {
if (a.metadata.image.acquisitionDate < b.metadata.image.acquisitionDate)
return -1;
if (a.metadata.image.acquisitionDate > b.metadata.image.acquisitionDate)
return 1;
return 0;
}
if (this.sortDescending == true) {
return list.sort(compare).reverse();
} else {
return list.sort(compare);
}
}
}
};
</script>
<style scoped lang="less">
.gallery-navbar {
border-width: 0 0 1px 0;
border-style: solid;
border-color: rgba(180, 180, 180, 0.25);
}
.gallery-navbar,
.gallery-folder-heading {
margin-bottom: 30px;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 320px);
justify-content: center;
}
.gallery-grid > div {
display: inline-block;
margin-bottom: 20px;
}
/deep/ .capture-card {
width: 300px;
height: 100%; // Used to have all cards in a row match their heights
margin-left: auto;
margin-right: auto;
}
</style>

View file

@ -0,0 +1,52 @@
<template>
<div
id="stream-display"
ref="streamDisplay"
class="stream-display uk-width-1-1 uk-height-1-1 scrollTarget"
>
<img
ref="click-frame"
class="uk-align-center uk-margin-remove-bottom"
:src="streamImgUri"
alt="Stream"
/>
</div>
</template>
<script>
// Export main app
export default {
name: "MiniStreamDisplay",
data: function() {
return {};
},
computed: {
streamImgUri: function() {
return `${this.$store.getters.baseUri}/api/v2/streams/mjpeg`;
}
},
methods: {}
};
</script>
<style scoped lang="less">
.stream-display img {
text-align: center;
object-fit: contain;
}
.stream-display {
width: 100%;
height: 100%;
}
.position-relative {
position: relative !important;
}
.text-center {
text-align: center;
}
</style>

View file

@ -0,0 +1,51 @@
<template>
<div id="appSettings">
<h3>Appearance</h3>
<p>
<label>
Theme
<select v-model="appTheme" class="uk-select">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
</label>
</p>
</div>
</template>
<script>
// Export main app
export default {
name: "AppSettings",
data: function() {
return {};
},
computed: {
appTheme: {
get() {
return this.$store.state.globalSettings.appTheme;
},
set(value) {
this.$store.commit("changeSetting", ["appTheme", value]);
}
}
},
watch: {
appTheme() {
console.log("Saving appTheme setting");
this.setLocalStorageObj("appTheme", this.appTheme);
}
},
mounted() {
// Try loading settings from localStorage. If null, don't change.
this.appTheme = this.getLocalStorageObj("appTheme") || this.appTheme;
}
};
</script>
<style lang="less"></style>

View file

@ -0,0 +1,118 @@
<template>
<div>
<!--Show auto calibrate if default plugin is enabled-->
<div v-if="'recalibrate' in recalibrationLinks" class="uk-margin-small">
<taskSubmitter
:can-terminate="false"
:requires-confirmation="true"
:confirmation-message="
'Start recalibration? This may take a while, and the microscope will be locked during this time.'
"
:submit-url="recalibrationLinks.recalibrate.href"
:submit-label="'Auto-Calibrate'"
@response="onRecalibrateResponse"
@error="onRecalibrateError"
>
</taskSubmitter>
</div>
<div v-show="showExtraSettings" class="uk-child-width-expand" uk-grid>
<div v-if="'flatten_lens_shading_table' in recalibrationLinks">
<button
class="uk-button uk-button-danger uk-width-1-1"
@click="flattenLensShadingTableRequest"
>
Disable flat-field correction
</button>
</div>
<div v-if="'delete_lens_shading_table' in recalibrationLinks">
<button
class="uk-button uk-button-danger uk-width-1-1"
@click="deleteLensShadingTableRequest"
>
Adaptive flat-field correction
</button>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
import taskSubmitter from "../../genericComponents/taskSubmitter";
// Export main app
export default {
name: "CameraCalibrationSettings",
components: {
taskSubmitter
},
props: {
showExtraSettings: {
type: Boolean,
required: false,
default: true
}
},
data: function() {
return {
recalibrationLinks: {},
isCalibrating: false
};
},
computed: {
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
}
},
mounted() {
this.updateRecalibrationLinks();
},
methods: {
updateRecalibrationLinks: function() {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.calibration.picamera"
);
// if AutocalibrationPlugin is enabled
if (foundExtension) {
// Get plugin action link
this.recalibrationLinks = foundExtension.links;
} else {
this.recalibrationLinks = {};
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onRecalibrateResponse: function() {
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);
},
deleteLensShadingTableRequest: function() {
axios.post(this.recalibrationLinks.delete_lens_shading_table.href);
}
}
};
</script>
<style lang="less"></style>

View file

@ -0,0 +1,136 @@
<template>
<div v-if="settings" id="cameraSettings">
<div>
<h3>Manual camera settings</h3>
<form @submit.prevent="applyConfigRequest">
<div v-if="settings.picamera">
<!--PiCamera settings block-->
<div v-if="settings.picamera.shutter_speed !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Exposure time</label
>
<div class="uk-form-controls">
<input
v-model="settings.picamera.shutter_speed"
class="uk-input uk-form-small"
type="number"
/>
</div>
</div>
<div v-if="settings.picamera.analog_gain !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Analogue gain</label
>
<div class="uk-form-controls">
<input
v-model="settings.picamera.analog_gain"
class="uk-input uk-form-small"
type="number"
step="0.000001"
/>
</div>
</div>
<div v-if="settings.picamera.digital_gain !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Digital gain</label
>
<div class="uk-form-controls">
<input
v-model="settings.picamera.digital_gain"
class="uk-input uk-form-small"
type="number"
step="0.000001"
/>
</div>
</div>
</div>
<button
type="submit"
class="uk-button uk-button-primary uk-margin-small uk-width-1-1"
>
Apply Settings
</button>
</form>
</div>
<!--Show auto calibrate if default plugin is enabled-->
<h3>Automatic calibration</h3>
<cameraCalibrationSettings></cameraCalibrationSettings>
</div>
</template>
<script>
import axios from "axios";
import cameraCalibrationSettings from "./cameraCalibrationSettings.vue";
// Export main app
export default {
name: "CameraSettings",
components: {
cameraCalibrationSettings
},
data: function() {
return {
settings: {}
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
}
},
mounted() {
this.updateSettings();
},
methods: {
updateSettings: function() {
axios
.get(this.settingsUri)
.then(response => {
this.settings = response.data.camera;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
applyConfigRequest: function() {
console.log("Applying config to the microscope");
var payload = {
camera: {
picamera: {
shutter_speed: this.settings.picamera.shutter_speed,
analog_gain: this.settings.picamera.analog_gain,
digital_gain: this.settings.picamera.digital_gain
}
}
};
// Send request
axios
.put(this.settingsUri, payload)
.then(() => {
return new Promise(r => setTimeout(r, 500));
}) // why is there no built-in for this??!
.then(() => {
// Update local settings
this.updateSettings();
this.modalNotify("Camera settings applied.");
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
}
};
</script>
<style lang="less"></style>

View file

@ -0,0 +1,170 @@
<template>
<div id="cameraStageMappingSettings">
<form @submit.prevent="applyConfigRequest">
<!--Show auto calibrate if default plugin is enabled-->
<div v-if="'calibrate_xy' in recalibrationLinks" class="uk-margin-small">
<taskSubmitter
:button-primary="true"
:can-terminate="false"
:requires-confirmation="true"
:confirmation-message="
'Start recalibration of the stage to the camera? This may take a while, and the microscope will be locked during this time.'
"
:submit-url="recalibrationLinks.calibrate_xy.href"
:submit-label="'Auto-Calibrate using camera'"
@response="onRecalibrateResponse"
@error="onRecalibrateError"
>
</taskSubmitter>
</div>
<button
v-if="'get_calibration' in recalibrationLinks"
v-show="dataAvailable && showExtraSettings"
type="button"
class="uk-button uk-button-default uk-width-1-1"
@click="getCalibrationData()"
>
Download calibration data
</button>
</form>
</div>
</template>
<script>
import axios from "axios";
import taskSubmitter from "../../genericComponents/taskSubmitter";
// Export main app
export default {
name: "CameraStageMappingSettings",
components: {
taskSubmitter
},
props: {
showExtraSettings: {
type: Boolean,
required: false,
default: true
}
},
data: function() {
return {
settings: null,
recalibrationLinks: {},
isCalibrating: false,
dataAvailable: false
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
},
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
}
},
mounted() {
this.updateSettings();
this.updateRecalibrationLinks();
},
methods: {
updateSettings: function() {
// Update links
axios
.get(this.settingsUri)
.then(response => {
this.settings =
response.data.extensions["org.openflexure.camera_stage_mapping"];
})
.catch(error => {
this.modalError(error); // Let mixin handle error
this.settings = {};
});
},
updateCalibrationDataAvailability: function() {
if ("get_calibration" in this.recalibrationLinks) {
axios
.get(this.recalibrationLinks.get_calibration.href)
.then(response => {
console.log("CSM data:");
console.log(response.data);
if (Object.keys(response.data).length === 0) {
this.dataAvailable = false;
} else {
this.dataAvailable = true;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
},
getCalibrationData: function() {
axios
.get(this.recalibrationLinks.get_calibration.href)
.then(response => {
if (response.data != {}) {
const data = JSON.stringify(response.data);
const url = window.URL.createObjectURL(new Blob([data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "csm_calibration.json");
document.body.appendChild(link);
link.click();
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateRecalibrationLinks: function() {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.camera_stage_mapping"
);
// if camera-stage mapping extension is enabled
if (foundExtension) {
// Get plugin action link
this.recalibrationLinks = foundExtension.links;
// Update whether calibration data is available
this.updateCalibrationDataAvailability();
} else {
this.recalibrationLinks = {};
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onRecalibrateResponse: function() {
this.modalNotify("Finished stage-to-camera calibration.");
// Update local settings
this.updateSettings();
},
onRecalibrateError: function(error) {
this.modalError(error); // Let mixin handle error
}
}
};
</script>
<style lang="less">
.center-spinner {
margin-left: auto;
margin-right: auto;
}
</style>

View file

@ -0,0 +1,134 @@
<template>
<div v-if="settings" id="microscopeSettings">
<form @submit.prevent="applyConfigRequest">
<label class="uk-form-label" for="form-stacked-text"
>Backlash compensation</label
>
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
<div>
<label class="uk-form-label" for="form-stacked-text">x</label>
<div class="uk-form-controls">
<input
v-model="settings.stage.backlash.x"
class="uk-input uk-form-small"
type="number"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">y</label>
<div class="uk-form-controls">
<input
v-model="settings.stage.backlash.y"
class="uk-input uk-form-small"
type="number"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">z</label>
<div class="uk-form-controls">
<input
v-model="settings.stage.backlash.z"
class="uk-input uk-form-small"
type="number"
/>
</div>
</div>
</div>
<br />
<div>
<label class="uk-form-label" for="form-stacked-text"
>Microscope name</label
>
<input
v-model="settings.name"
class="uk-input uk-width-1-1 uk-form-small"
name="inputFilename"
placeholder="Leave blank for default"
/>
</div>
<br />
<button
type="submit"
class="uk-button uk-button-primary uk-form-small uk-float-right uk-margin-small uk-width-1-1"
>
Apply Settings
</button>
</form>
</div>
</template>
<script>
import axios from "axios";
// Export main app
export default {
name: "MicroscopeSettings",
data: function() {
return {
settings: null
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
}
},
mounted() {
this.updateSettings();
},
methods: {
updateSettings: function() {
axios
.get(this.settingsUri)
.then(response => {
this.settings = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
applyConfigRequest: function() {
var payload = {
name: this.settings.name,
stage: {
backlash: this.settings.stage.backlash
}
};
console.log(payload);
// Send request to update config
axios
.put(this.settingsUri, payload)
.then(() => {
// Update local settings
this.updateSettings();
this.modalNotify("Microscope settings applied.");
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
}
};
</script>
<style lang="less">
.center-spinner {
margin-left: auto;
margin-right: auto;
}
</style>

View file

@ -0,0 +1,78 @@
<template>
<div id="streamSettings">
<div>
<h3>Stream settings</h3>
<label
><input v-model="disableStream" class="uk-checkbox" type="checkbox" />
Disable live stream</label
>
</div>
<br />
<div>
<h3>Microscope display output</h3>
<div uk-grid>
<div>
<label :class="[{ 'uk-disabled': !this.$store.getters.ready }]"
><input
v-model="autoGpuPreview"
class="uk-checkbox"
type="checkbox"
/>
Enable GPU preview</label
>
</div>
<div>
<label :class="[{ 'uk-disabled': !this.$store.getters.ready }]"
><input v-model="trackWindow" class="uk-checkbox" type="checkbox" />
Track window</label
>
</div>
</div>
</div>
</div>
</template>
<script>
// Export main app
export default {
name: "StreamSettings",
data: function() {
return {};
},
computed: {
disableStream: {
get() {
return this.$store.state.globalSettings.disableStream;
},
set(value) {
this.$store.commit("changeSetting", ["disableStream", value]);
}
},
autoGpuPreview: {
get() {
return this.$store.state.globalSettings.autoGpuPreview;
},
set(value) {
this.$store.commit("changeSetting", ["autoGpuPreview", value]);
this.$root.$emit("globalSafeTogglePreview", value);
}
},
trackWindow: {
get() {
return this.$store.state.globalSettings.trackWindow;
},
set(value) {
this.$store.commit("changeSetting", ["trackWindow", value]);
}
}
}
};
</script>
<style lang="less"></style>

View file

@ -0,0 +1,178 @@
<template>
<!-- Grid managing tab content -->
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="settings-nav">
<ul class="uk-nav uk-nav-default">
<li class="uk-nav-header">Application settings</li>
<li>
<tabIcon
id="settings-display-icon"
tab-i-d="display"
:show-title="false"
:show-tooltip="false"
:require-connection="false"
:current-tab="currentTab"
@set-tab="setTab"
>
Display
</tabIcon>
</li>
<li class="uk-nav-header">Microscope settings</li>
<li>
<tabIcon
id="settings-camera-icon"
tab-i-d="camera"
:show-title="false"
:show-tooltip="false"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
Camera
</tabIcon>
</li>
<li>
<tabIcon
id="settings-mapping-icon"
tab-i-d="mapping"
:show-title="false"
:show-tooltip="false"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
Camera/stage mapping
</tabIcon>
</li>
<li>
<tabIcon
id="settings-microscope-icon"
tab-i-d="microscope"
:show-title="false"
:show-tooltip="false"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
General
</tabIcon>
</li>
</ul>
</div>
<div class="view-component uk-width-expand uk-padding-small">
<tabContent
tab-i-d="display"
:require-connection="false"
:current-tab="currentTab"
>
<div class="settings-pane uk-padding-small">
<appSettings />
<streamSettings />
</div>
</tabContent>
<tabContent
tab-i-d="camera"
:require-connection="true"
:current-tab="currentTab"
>
<div class="settings-pane uk-padding-small">
<cameraSettings />
</div>
</tabContent>
<tabContent
tab-i-d="mapping"
:require-connection="true"
:current-tab="currentTab"
>
<div class="settings-pane uk-padding-small">
<h3>Camera/stage mapping</h3>
<p>
Camera/stage mapping allows the stage to move relative to the camera
view. This enables functions like click-to-move, and more precise
tile scans.
</p>
<cameraStageMappingSettings />
</div>
</tabContent>
<tabContent
tab-i-d="microscope"
:require-connection="true"
:current-tab="currentTab"
>
<div class="settings-pane uk-padding-small">
<h3>Microscope settings</h3>
<microscopeSettings />
</div>
</tabContent>
</div>
</div>
</template>
<script>
import streamSettings from "./settingsComponents/streamSettings.vue";
import microscopeSettings from "./settingsComponents/microscopeSettings.vue";
import cameraSettings from "./settingsComponents/cameraSettings.vue";
import appSettings from "./settingsComponents/appSettings.vue";
import cameraStageMappingSettings from "./settingsComponents/cameraStageMappingSettings.vue";
// Import generic components
import tabIcon from "../genericComponents/tabIcon";
import tabContent from "../genericComponents/tabContent";
// Export main app
export default {
name: "SettingsDisplay",
components: {
streamSettings,
cameraSettings,
microscopeSettings,
cameraStageMappingSettings,
appSettings,
tabIcon,
tabContent
},
data: function() {
return {
selected: "display",
currentTab: "display"
};
},
methods: {
setTab: function(event, tab) {
if (!(this.currentTab == tab)) {
this.currentTab = tab;
}
}
}
};
</script>
<style lang="less" scoped>
// Custom UIkit CSS modifications
@import "../../assets/less/theme.less";
.settings-pane {
}
.settings-nav {
overflow-y: auto;
overflow-x: hidden;
width: 250px;
padding: 10px;
background-color: rgba(180, 180, 180, 0.03);
border-width: 0 1px 0 0;
border-style: solid;
border-color: rgba(180, 180, 180, 0.25);
}
.settings-nav li > a {
padding-left: 6px !important;
border-radius: @button-border-radius;
}
</style>

View file

@ -0,0 +1,202 @@
<template>
<div class="host-input">
<div v-if="configuration">
<div>
<div class="uk-margin-small-bottom">
<b>API origin:</b>
<br />
{{ $store.state.origin }}
<br />
<b>API URL:</b>
<br />
{{ $store.getters.uriV2 }}
</div>
</div>
<hr />
<div v-if="settings">
<b>Device name:</b> <br />
{{ settings.name }}
</div>
<div>
<b>Server version:</b> <br />
{{ configuration.application.version }}
</div>
<div>
<b>Client version:</b> <br />
{{ clientVersionName }}
</div>
<hr />
<div class="uk-margin-small-bottom">
<b>Camera:</b>
<br />
<div v-if="configuration.camera.type != 'MissingCamera'">
{{ configuration.camera.type }}
</div>
<div v-else class="uk-text-danger"><b>No camera connected</b></div>
</div>
<div>
<b>Stage:</b>
<br />
<div v-if="configuration.stage.type != 'MissingStage'">
{{ configuration.stage.type }}
</div>
<div v-else class="uk-text-danger"><b>No stage connected</b></div>
</div>
<hr />
<div class="uk-grid-small uk-child-width-1-2" uk-grid>
<div>
<button
v-show="'shutdown' in systemActionLinks"
class="uk-button uk-button-danger uk-float-right uk-margin uk-margin-remove-top uk-width-1-1"
@click="shutdownRequest"
>
Shutdown
</button>
</div>
<div>
<button
v-show="'reboot' in systemActionLinks"
class="uk-button uk-button-danger uk-float-right uk-margin uk-margin-remove-top uk-width-1-1"
@click="rebootRequest"
>
Restart
</button>
</div>
</div>
</div>
<div v-else-if="$store.state.waiting">
Loading...
</div>
<div v-else-if="$store.state.error">
<b>Error:</b> {{ $store.state.error }}
</div>
<div v-else>No active connection</div>
</div>
</template>
<script>
import axios from "axios";
export default {
name: "StatusPane",
components: {},
data: function() {
return {
configuration: null,
settings: null,
systemActionLinks: {},
clientVersion: process.env.PACKAGE.version
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
},
configurationUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/configuration`;
},
actionsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions`;
},
clientVersionName: function() {
return `${process.env.PACKAGE.version}`;
}
},
mounted: function() {
// Watch for host 'ready', then update configuration
this.updateConfiguration();
this.updateSettings();
this.updateSystemActions();
},
methods: {
updateConfiguration: function() {
axios
.get(this.configurationUri)
.then(response => {
this.configuration = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateSettings: function() {
axios
.get(this.settingsUri)
.then(response => {
this.settings = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateSystemActions: function() {
axios
.get(this.actionsUri)
.then(response => {
if ("reboot" in response.data) {
this.$set(
this.systemActionLinks,
"reboot",
response.data.reboot.links.self.href
);
} else {
delete this.systemActionLinks.reboot;
}
if ("shutdown" in response.data) {
this.$set(
this.systemActionLinks,
"shutdown",
response.data.shutdown.links.self.href
);
} else {
delete this.systemActionLinks.shutdown;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
shutdownRequest: function() {
this.modalConfirm("Shut down microscope?").then(
() => {
if ("shutdown" in this.systemActionLinks) {
this.$store.commit("resetState");
axios.post(this.systemActionLinks.shutdown).catch(error => {
console.log(error); // Be quiet when empty response is recieved
});
}
},
() => {}
);
},
rebootRequest: function() {
this.modalConfirm("Restart microscope?").then(
() => {
if ("reboot" in this.systemActionLinks) {
this.$store.commit("resetState");
axios.post(this.systemActionLinks.reboot).catch(error => {
console.log(error); // Be quiet when empty response is recieved
});
}
},
() => {}
);
}
}
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="less"></style>

View file

@ -0,0 +1,333 @@
<template>
<div
id="stream-display"
ref="streamDisplay"
class="stream-display uk-width-1-1 uk-height-1-1 scrollTarget"
>
<img
v-if="thisStreamOpen"
ref="click-frame"
class="uk-align-center uk-margin-remove-bottom"
:hidden="!streamEnabled"
:src="streamImgUri"
alt="Stream"
@dblclick="clickMonitor"
/>
<div v-if="!streamEnabled" class="uk-height-1-1">
<div v-if="$store.state.waiting" class="uk-position-center">
<div uk-spinner="ratio: 4.5"></div>
</div>
<div
v-else-if="$store.state.globalSettings.disableStream"
class="uk-position-center position-relative text-center"
>
Stream preview disabled
</div>
<div v-else class="uk-position-center position-relative text-center">
No active connection
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
// Export main app
export default {
name: "StreamDisplay",
data: function() {
return {
displaySize: [0, 0],
displayPosition: [0, 0],
fov: [0, 0],
resizeTimeoutId: setTimeout(this.doneResizing, 500)
};
},
computed: {
streamEnabled: function() {
return (
this.$store.getters.ready &&
!this.$store.state.globalSettings.disableStream
);
},
thisStreamOpen: function() {
// Only a single MJPEG connection should be open at a time
return !(this.displaySize[0] == 0) && !(this.displaySize[1] == 0);
},
streamImgUri: function() {
return `${this.$store.getters.baseUri}/api/v2/streams/mjpeg`;
},
startPreviewUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/camera/preview/start`;
},
stopPreviewUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/camera/preview/stop`;
},
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
}
},
mounted() {
console.log(`${this._uid} mounted`);
// A global signal listener to change the GPU preview state
this.$root.$on("globalTogglePreview", state => {
this.previewRequest(state);
});
this.$root.$on("globalSafeTogglePreview", state => {
this.safePreviewRequest(state);
});
// A global signal listener to flash the stream element
this.$root.$on("globalFlashStream", () => {
this.flashStream();
});
// Mutation observer
this.sizeObserver = new ResizeObserver(() => {
this.handleResize(); // For any element attached to the observer, run handleResize() on change
});
// Fetch streamDisplay component by ref
const streamDisplayElement = this.$refs.streamDisplay.parentNode;
// Attach streamDisplay to the size observer
this.sizeObserver.observe(streamDisplayElement);
},
created: function() {
console.log(`${this._uid} created`);
// Send a request to start/stop GPU preview based on global setting
this.safePreviewRequest(this.$store.state.globalSettings.autoGpuPreview);
// Get FOV from settings
this.updateFov();
},
beforeDestroy: function() {
console.log(`${this._uid} being destroyed`);
// Remove global signal listener to change the GPU preview state
this.$root.$off("globalTogglePreview");
// Remove global signal listener to flash the stream element
this.$root.$off("globalFlashStream");
// Disconnect the size observer
this.sizeObserver.disconnect();
// Remove from the array of active streams
this.$store.commit("removeStream", this._uid);
},
methods: {
flashStream: function() {
// Run an animation that flashes the stream (for capture feedback)
let element = this.$refs.streamDisplay;
element.classList.remove("uk-animation-fade");
element.offsetHeight; /* trigger reflow */
element.classList.add("uk-animation-fade");
setTimeout(function() {
element.classList.remove("uk-animation-fade");
}, 800);
},
clickMonitor: function(event) {
// Calculate steps from event coordinates and store config FOV
let xCoordinate = event.offsetX;
let yCoordinate = event.offsetY;
// Simply scaling by naturalHeight/offsetHeight may give the wrong answer!
// because we use content-fit: contain in the stylesheet, the img element
// may be larger than the picture. So, we must determine whether the
// width or height is setting the scaling factor, and use a uniform scale
// factor.
let scale = Math.max(
event.target.naturalWidth / event.target.offsetWidth,
event.target.naturalHeight / event.target.offsetHeight
);
let xRelative = (0.5 * event.target.offsetWidth - xCoordinate) * scale;
let yRelative = (0.5 * event.target.offsetHeight - yCoordinate) * scale;
// Emit a signal to move, acted on by panelNavigate.vue
this.$root.$emit(
"globalMoveInImageCoordinatesEvent",
-xRelative,
-yRelative
);
},
handleResize: function() {
// Only fires resize event after no resize in 500ms (prevents resize event spam)
clearTimeout(this.resizeTimeoutId);
this.resizeTimeoutId = setTimeout(this.handleDoneResize, 250);
},
handleDoneResize: function() {
// Recalculate size
console.log(`Recalculating frame size for ${this._uid}`);
this.recalculateSize();
// Handle closed stream
if (this.displaySize[0] == 0 && this.displaySize[1] == 0) {
console.log(`${this._uid} is zero`);
// If this stream was previously active
if (this.$store.state.activeStreams[this._uid] == true) {
console.log(`${this._uid} was active`);
console.log(`STREAM ${this._uid} CLOSED`);
this.$store.commit("removeStream", this._uid);
// If all streams are closed, request the GPU preview close
var a = Object.values(this.$store.state.activeStreams);
let allClosed = a.every(v => v === false);
if (allClosed) {
this.safePreviewRequest(false);
}
}
// If resized to anything other than zero
} else {
console.log(`STREAM ${this._uid} OPEN`);
this.$store.commit("addStream", this._uid);
if (this.$store.state.globalSettings.autoGpuPreview == true) {
// Start the preview immediately
this.safePreviewRequest(true);
// Send another start preview request after a timeout
/*
The internal logic of this component means that a stop GPU preview
request will only ever be sent if ALL stream components are invisible.
However, when switching tabs, sometimes the previous component will
react to becoming invisible before the new tab has become visible.
This results in a stop-preview request being sent JUST before the new
start preview request is sent. In an ideal network, this is fine, however
due to network jitter, these requests will occasionally be recieved out
of order, causing the preview to start in the new location, and then
quickly stop again.
Preventing this properly will involve some clever server-side logic
probably, however as a pit-stop solution, we always send another
start-preview request after 1 second. This means that either:
1. The initial requests happened in order, in which case this follow-up
request does nothing, or
2. The requests leapfrog eachother, stopping the preview, in which case
the follow-up request restarts the preview in the correct location.
*/
setTimeout(() => this.safePreviewRequest(true), 250);
}
}
},
recalculateSize: function() {
// Calculate stream size
let element = this.$refs.streamDisplay.parentNode;
let bound = element.getBoundingClientRect();
let elementSize = [bound.width, bound.height];
let elementPositionOnWindow = [bound.left, bound.top];
let windowPositionOnDisplay = [window.screenX, window.screenY];
let windowChromeHeight = window.outerHeight - window.innerHeight;
let elementPositionOnDisplay = [
Math.max(0, windowPositionOnDisplay[0] + elementPositionOnWindow[0]),
Math.max(
0,
windowPositionOnDisplay[1] +
elementPositionOnWindow[1] +
windowChromeHeight
)
];
this.displaySize = elementSize;
this.displayPosition = elementPositionOnDisplay;
},
safePreviewRequest: function(state) {
// previewRequest, but only stopping preview if all streams are invisible
// and only starting preview if any stream is visible
var a = Object.values(this.$store.state.activeStreams);
let allClosed = a.every(v => v === false);
// If all streams are closed, don't start GPU preview
if (state === true && allClosed) {
return false;
}
// If not all streams are closed, don't stop GPU preview
if (state === false && !allClosed) {
return false;
}
return this.previewRequest(state);
},
previewRequest: function(state) {
// If requesting starting the stream, but this component is inactive, skip
if (this.$store.getters.ready == true) {
var requestUri = null;
// Create URI
if (state == true) {
requestUri = this.startPreviewUri;
} else {
requestUri = this.stopPreviewUri;
}
// Generate payload if tracking window position
var payload = {};
if (
this.$store.state.globalSettings.trackWindow == true &&
state == true
) {
// Recalculate frame dimensions and position
this.recalculateSize();
// Copy data into payload array
payload = {
window: [
this.displayPosition[0],
this.displayPosition[1],
this.displaySize[0],
this.displaySize[1]
]
};
}
// Send preview request
console.log(`${this._uid} toggled preview to ${state}`);
axios
.post(requestUri, payload)
.then(() => {})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
},
updateFov: function() {
console.log("Updating FOV");
// Get the current field-of-view setting from the server
axios
.get(`${this.settingsUri}/fov`)
.then(response => {
if (response.data) {
this.fov = response.data;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
}
};
</script>
<style scoped lang="less">
.stream-display img {
height: 100%;
text-align: center;
object-fit: contain;
}
.stream-display {
width: 100%;
height: 100%;
}
.position-relative {
position: relative !important;
}
.text-center {
text-align: center;
}
</style>

View file

@ -0,0 +1,158 @@
import Vue from "vue";
import App from "./App.vue";
import store from "./store";
import UIkit from "uikit";
import VueTour from "vue-tour";
import LoadScript from "vue-plugin-load-script";
import VueFriendlyIframe from "vue-friendly-iframe";
require("vue-tour/dist/vue-tour.css");
// Import MD icons
import "material-design-icons/iconfont/material-icons.css";
// UIKit overrides
UIkit.mixin(
{
data: {
animation: false
}
},
"accordion"
);
// Use load-script module
Vue.use(LoadScript);
// Use vue-tour module
Vue.use(VueTour);
// Use Friendly Iframe module
Vue.use(VueFriendlyIframe);
Vue.config.productionTip = false;
Vue.mixin({
methods: {
modalConfirm: function(modalText) {
var context = this;
// Stop GPU preview to show modal
context.$root.$emit("globalTogglePreview", false);
var showModal = function(resolve, reject) {
UIkit.modal
.confirm(modalText, { stack: true })
.then(
function() {
resolve();
},
function() {
reject();
}
)
.finally(function() {
// Reenable the GPU preview, if it was active before the modal
console.log("Re-enabling GPU preview");
if (context.$store.state.globalSettings.autoGpuPreview) {
console.log("Re-enabling preview");
context.$root.$emit("globalTogglePreview", true);
}
});
};
return new Promise(showModal);
},
modalNotify: function(message, status = "success") {
UIkit.notification({
message: message,
status: status
});
},
modalDialog: function(title, message) {
UIkit.modal.dialog(
`
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<h2 class="uk-modal-title">${title}</h2>
</div>
<div class="uk-modal-body">
<p>${message}</p>
</div>
`,
{ stack: true }
);
},
modalError: function(error) {
var errormsg = this.getErrorMessage(error);
this.$store.commit("setErrorMessage", errormsg);
UIkit.notification({
message: `${errormsg}`,
status: "danger"
});
},
getErrorMessage: function(error) {
var errormsg = "";
// If a response was obtained
if (error.response) {
// If the response is a nicely formatted JSON response from the server
if (error.response.data.message) {
errormsg = `${error.response.status}: ${error.response.data.message}`;
console.log(errormsg);
}
// If the response is just some generic error response
else {
errormsg = `${error.response.status}: ${error.response.data}`;
console.log(errormsg);
}
// If the error occured during the request
} else if (error.request) {
errormsg = `${error.message}`;
console.log(errormsg);
// Everything else
} else {
errormsg = `${error.message}`;
console.log(errormsg);
}
return errormsg;
},
showModalElement: function(element) {
UIkit.modal(element).show();
},
hideModalElement: function(element) {
UIkit.modal(element).hide();
},
toggleModalElement: function(element) {
UIkit.modal(element).toggle();
},
getLocalStorageObj: function(keyName) {
if (localStorage.getItem(keyName)) {
try {
return JSON.parse(localStorage.getItem(keyName));
} catch (e) {
console.log("Malformed entry. Removing from localStorage");
localStorage.removeItem(keyName);
return null;
}
}
},
setLocalStorageObj: function(keyName, object) {
const parsed = JSON.stringify(object);
localStorage.setItem(keyName, parsed);
}
}
});
new Vue({
store,
render: h => h(App)
}).$mount("#app");

View file

@ -0,0 +1,58 @@
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
export default new Vuex.Store({
state: {
origin: window.location.origin,
available: false,
waiting: false,
error: "",
globalSettings: {
disableStream: false,
autoGpuPreview: false,
trackWindow: true,
appTheme: "system"
},
activeStreams: {}
},
mutations: {
changeOrigin(state, origin) {
state.origin = origin;
},
changeWaiting(state, waiting) {
state.waiting = waiting;
},
changeSetting(state, [key, value]) {
state.globalSettings[key] = value;
},
resetState(state) {
state.waiting = false;
state.available = false;
state.error = null;
},
setConnected(state) {
state.waiting = false;
state.available = true;
},
setErrorMessage(state, msg) {
state.error = msg;
},
addStream(state, id) {
state.activeStreams[id] = true;
},
removeStream(state, id) {
state.activeStreams[id] = false;
}
},
actions: {},
getters: {
uriV2: state => `${state.origin}/api/v2`,
baseUri: state => state.origin,
ready: state => state.available
}
});

View file

@ -0,0 +1,15 @@
var webpack = require("webpack");
module.exports = {
configureWebpack: {
plugins: [
new webpack.DefinePlugin({
"process.env": {
PACKAGE: JSON.stringify(require("./package.json"))
}
})
]
},
publicPath: ""
};

View file

@ -1,16 +1,38 @@
import logging
import os
import errno
from werkzeug.exceptions import BadRequest
from flask import url_for
from flask import url_for, Blueprint, current_app
from . import gui
class JsonPayload:
def view_class_from_endpoint(endpoint: str):
return current_app.view_functions[endpoint].view_class
def blueprint_for_module(module_name, api_ver=2, suffix=""):
return Blueprint(
blueprint_name_for_module(module_name, api_ver=api_ver, suffix=suffix),
module_name,
)
def blueprint_name_for_module(module_name, api_ver=2, suffix=""):
bp_name = module_name.split(".")[-1]
return f"v{api_ver}_{bp_name}_blueprint{suffix}"
class JsonResponse:
def __init__(self, request):
"""
Object to wrap up simple functionality for parsing a JSON response.
"""
# Try to load as json
try:
self.json = request.get_json() #: dict: Dictionary representation of request JSON
self.json = (
request.get_json()
) #: dict: Dictionary representation of request JSON
# If malformed JSON is passed, make an empty dictionary
except BadRequest as e:
logging.error(e)
@ -55,15 +77,12 @@ def gen(camera):
# the obtained frame is a jpeg
frame = camera.get_frame()
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n')
yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n")
def get_bool(get_arg):
"""Convert GET request argument string to a Python bool"""
if (get_arg == 'true' or
get_arg == 'True' or
get_arg == '1'):
if get_arg == "true" or get_arg == "True" or get_arg == "1":
return True
else:
return False
@ -80,10 +99,36 @@ def list_routes(app):
endpoint = rule.endpoint
methods = list(rule.methods)
url = url_for(rule.endpoint, **options)
line = {
'endpoint': endpoint,
'methods': methods
}
line = {"endpoint": endpoint, "methods": methods}
output[url] = line
return output
return output
def create_file(config_path):
if not os.path.exists(os.path.dirname(config_path)):
try:
os.makedirs(os.path.dirname(config_path))
except OSError as exc: # Guard against race condition
if exc.errno != errno.EEXIST:
raise
def init_default_extensions(extension_dir):
global _DEFAULT_EXTENSION_INIT
os.makedirs(extension_dir, exist_ok=True)
default_ext_path = os.path.join(extension_dir, "defaults.py")
if not os.path.isfile(default_ext_path): # If user extensions file doesn't exist
logging.warning(
"No extension file found at {}. Creating...".format(extension_dir)
)
create_file(default_ext_path)
logging.info("Populating {}...".format(default_ext_path))
with open(default_ext_path, "w") as outfile:
outfile.write(_DEFAULT_EXTENSION_INIT)
_DEFAULT_EXTENSION_INIT = "from openflexure_microscope.api.default_extensions import *"

View file

@ -0,0 +1,61 @@
import copy
import logging
from functools import wraps
def clean_rule(rule: str):
while rule[0] == "/":
rule = rule[1:]
return f"{rule}"
def build_gui_from_dict(gui_description, extension_object):
# Make a working copy of GUI description
api_gui = copy.deepcopy(gui_description)
logging.debug(extension_object)
logging.debug(extension_object._rules)
# Expand shorthand routes into full relative URLs
if "forms" in gui_description and isinstance(api_gui["forms"], list):
for form in api_gui["forms"]:
# Clean leading slashes from rule
if "route" in form:
form["route"] = clean_rule(form["route"])
# Match rule in extension object
if "route" in form and form["route"] in extension_object._rules.keys():
form["route"] = extension_object._rules[form["route"]]["urls"][0]
else:
logging.warn(
"No valid expandable route found for {}".format(form["route"])
)
# Inject extension information
api_gui["id"] = extension_object.name
api_gui["version"] = extension_object.version
return api_gui
def build_gui_from_func(func, extension_object):
@wraps(func)
def wrapped(*args, **kwargs):
return build_gui_from_dict(func(*args, **kwargs), extension_object)
return wrapped
def build_gui(gui_description, extension_object):
# If given a function that generates a GUI dictionary
if callable(gui_description):
# Wrap in the route expander
return build_gui_from_func(gui_description, extension_object)
# If given a dictionary directly
elif isinstance(gui_description, dict):
# Build a GUI generator function
def gui_description_func():
return gui_description
# Wrap in the route expander
return build_gui_from_func(gui_description_func, extension_object)
else:
raise RuntimeError("GUI description must be a function or a dictionary")

View file

@ -1 +0,0 @@
from . import camera, stage, base, plugins, task

View file

@ -1,236 +0,0 @@
from openflexure_microscope.api.utilities import gen, JsonPayload
from openflexure_microscope.api.v1.views import MicroscopeView
from flask import Response, Blueprint, jsonify, request
import logging
class StreamAPI(MicroscopeView):
def get(self):
"""
Real-time MJPEG stream from the microscope camera
.. :quickref: State; Camera stream
:>header Accept: image/jpeg
:>header Content-Type: image/jpeg
:status 200: stream active
"""
# Restart stream worker thread
self.microscope.camera.start_worker()
return Response(
gen(self.microscope.camera),
mimetype='multipart/x-mixed-replace; boundary=frame')
class StateAPI(MicroscopeView):
def get(self):
"""
JSON representation of the microscope object.
.. :quickref: State; Microscope state
**Example request**:
.. sourcecode:: http
GET /state/ HTTP/1.1
Accept: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"camera": {
"preview_active": false,
"record_active": false,
"stream_active": true
},
"plugin": {},
"stage": {
"backlash": {
"x": 128,
"y": 128,
"z": 128
},
"position": {
"x": -8080,
"y": 5665,
"z": -12600
}
}
}
:>header Accept: application/json
:>header Content-Type: application/json
:status 200: state available
"""
return jsonify(self.microscope.state)
class ConfigAPI(MicroscopeView):
def get(self):
"""
JSON representation of the microscope config.
.. :quickref: Config; Get microscope config
**Example request**:
.. sourcecode:: http
GET /config/ HTTP/1.1
Accept: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": "0e2c6fac5421429aac67c7903107bdd8",
"name": "docuscope-2000",
"fov": [4100, 3146],
"camera_settings": {
"image_resolution": [2592, 1944],
"numpy_resolution": [1312, 976],
"video_resolution": [832, 624],
"jpeg_quality": 75,
"picamera_settings": {
"analog_gain": 1.0,
"digital_gain": 1.0,
"awb_gains": [0.92578125, 2.94921875],
"awb_mode": "off",
"exposure_mode": "off",
"framerate": 24.0,
"saturation": 0,
"shutter_speed": 5378
},
},
"stage_settings": {
"backlash": {
"x": 256,
"y": 256,
"z": 0
}
}
"plugins": [
"openflexure_microscope.plugins.default.autofocus:AutofocusPlugin",
"openflexure_microscope.plugins.default.scan:ScanPlugin",
"openflexure_microscope.plugins.default.camera_calibration:Plugin"
]
}
:>header Accept: application/json
:>header Content-Type: application/json
:status 200: state available
"""
return jsonify(self.microscope.read_config(json_safe=True))
def post(self):
"""
Modify microscope configuration
.. :quickref: Config; Set microscope config
**Example request**:
.. sourcecode:: http
POST /config HTTP/1.1
Accept: application/json
{
"id": "0e2c6fac5421429aac67c7903107bdd8",
"name": "docuscope-2000",
"fov": [4100, 3146],
"camera_settings": {
"image_resolution": [2592, 1944],
"numpy_resolution": [1312, 976],
"video_resolution": [832, 624],
"jpeg_quality": 75,
"picamera_settings": {
"analog_gain": 1.0,
"digital_gain": 1.0,
"awb_gains": [0.92578125, 2.94921875],
"awb_mode": "off",
"exposure_mode": "off",
"framerate": 24.0,
"saturation": 0,
"shutter_speed": 5378
},
},
"stage_settings": {
"backlash": {
"x": 256,
"y": 256,
"z": 0
}
}
"plugins": [
"openflexure_microscope.plugins.default.autofocus:AutofocusPlugin",
"openflexure_microscope.plugins.default.scan:ScanPlugin",
"openflexure_microscope.plugins.default.camera_calibration:Plugin"
]
}
:>header Accept: application/json
:<json string id: Unique string identifier of the microscope.
:<json string name: Friendly name for the microscope
:<json array fov: Field of view (motor steps per full width and height of frame)
:<json json camera_settings: - **image_resolution** *(array)*: Resolution of full image captures
- **numpy_resolution** *(array)*: Resolution of full numpy array captures
- **video_resolution** *(array)*: Resolution of video recordings, low res image captures, and the preview stream
- **jpeg_quality** *(int)*: Quality in which to store JPEG capture data
- **picamera_settings** *(json)*: Key-value pairs to apply directly to any attached PiCamera object
:<json json stage_settings: - **backlash** *(json)*: x, y, and z backlash compensation, in motor steps
:<json array plugins: Array of plugin paths to load. Requires reloading the microscope object after applying
:<header Content-Type: application/json
:status 200: capture created
"""
payload = JsonPayload(request)
logging.debug("Updating settings from POST request:")
logging.debug(payload.json)
self.microscope.apply_config(payload.json)
self.microscope.save_config()
return jsonify(self.microscope.read_config(json_safe=True))
def construct_blueprint(microscope_obj):
blueprint = Blueprint('base_blueprint', __name__)
blueprint.add_url_rule(
'/stream',
view_func=StreamAPI.as_view('stream', microscope=microscope_obj)
)
blueprint.add_url_rule(
'/state',
view_func=StateAPI.as_view('state', microscope=microscope_obj)
)
blueprint.add_url_rule(
'/config',
view_func=ConfigAPI.as_view('config', microscope=microscope_obj)
)
return blueprint

View file

@ -1,57 +0,0 @@
from openflexure_microscope.api.v1.views import MicroscopeView
from flask import Blueprint
from . import capture, record, preview, function
def construct_blueprint(microscope_obj):
blueprint = Blueprint('camera_blueprint', __name__)
# Metadata routes
blueprint.add_url_rule(
'/capture/<capture_id>/metadata/<filename>',
view_func=capture.MetadataAPI.as_view('metadata_download', microscope=microscope_obj))
blueprint.add_url_rule(
'/capture/<capture_id>/metadata',
view_func=capture.MetadataRedirectAPI.as_view('metadata_download_redirect', microscope=microscope_obj))
# Tag routes
blueprint.add_url_rule(
'/capture/<capture_id>/tags',
view_func=capture.TagsAPI.as_view('capture_tags', microscope=microscope_obj))
# Capture routes
blueprint.add_url_rule(
'/capture/<capture_id>/download/<filename>',
view_func=capture.DownloadAPI.as_view('capture_download', microscope=microscope_obj))
blueprint.add_url_rule(
'/capture/<capture_id>/download',
view_func=capture.DownloadRedirectAPI.as_view('capture_download_redirect', microscope=microscope_obj))
blueprint.add_url_rule(
'/capture/<capture_id>/',
view_func=capture.CaptureAPI.as_view('capture', microscope=microscope_obj))
blueprint.add_url_rule(
'/capture/',
view_func=capture.ListAPI.as_view('capture_list', microscope=microscope_obj))
# Preview routes
blueprint.add_url_rule(
'/preview/<string:operation>',
view_func=preview.GPUPreviewAPI.as_view('gpu_preview', microscope=microscope_obj))
# Function routes
blueprint.add_url_rule(
'/overlay',
view_func=function.OverlayAPI.as_view('overlay', microscope=microscope_obj))
blueprint.add_url_rule(
'/zoom',
view_func=function.ZoomAPI.as_view('zoom', microscope=microscope_obj))
return(blueprint)

View file

@ -1,512 +0,0 @@
from openflexure_microscope.api.utilities import get_bool, JsonPayload
from openflexure_microscope.api.v1.views import MicroscopeView
from openflexure_microscope.utilities import filter_dict
from flask import Response, jsonify, request, abort, url_for, redirect, send_file
class ListAPI(MicroscopeView):
def get(self):
"""
Get list of image captures.
.. :quickref: Captures; Get collection of captures
:>header Accept: application/json
:query include_unavailable: return json representations of captures that have been completely deleted
:>jsonarr boolean available: availability of capture data
:>jsonarr string filename: filename of capture
:>jsonarr string id: unique id of the capture object
:>jsonarr boolean keep_on_disk: keep the capture file on microscope after closing
:>jsonarr boolean locked: file locked for modifications (mostly used for video recording)
:>jsonarr string path: path on pi storage to the capture file, if available
:>jsonarr boolean stream: capture stored in-memory as a BytesIO stream
:>jsonarr json uri: - **download** *(string)*: api uri to the capture file download
- **state** *(string)*: api uri to the capture json representation
:>header Content-Type: application/json
:status 200: capture found
:status 404: no capture found with that id
"""
include_unavailable = get_bool(request.args.get('include_unavailable'))
if include_unavailable:
captures = [image.state for image in self.microscope.camera.images]
else:
captures = [image.state for image in self.microscope.camera.images if image.state['available']]
return jsonify(captures)
def delete(self):
"""
Delete all captures (not yet implemented)
.. :quickref: Captures; Delete all captures
"""
for image in self.microscope.camera.images:
image.delete()
captures = [image.state for image in self.microscope.camera.images]
return jsonify(captures)
def post(self):
"""
Create a new image capture.
.. :quickref: Captures; New capture
**Example request**:
.. sourcecode:: http
POST /camera/capture HTTP/1.1
Accept: application/json
{
"filename": "myfirstcapture",
"temporary": false,
"use_video_port": true,
"bayer": true,
"size": {
"width": 640,
"height": 480
}
}
:>header Accept: application/json
:<json string filename: filename of stored capture
:<json boolean temporary: delete the capture file on microscope after closing
:<json boolean use_video_port: capture still image from the video port
:<json boolean bayer: keep raw capture data in the image file
:<json json size: - **x** *(int)*: x-axis resize
- **y** *(int)*: y-axis resize
:>json boolean available: availability of capture data
:>json string filename: filename of capture
:>json string id: unique id of the capture object
:>json boolean temporary: delete the capture file on microscope after closing
:>json boolean locked: file locked for modifications (mostly used for video recording)
:>json string path: path on pi storage to the capture file, if available
:>json boolean stream: capture stored in-memory as a BytesIO stream
:>json json uri: - **download** *(string)*: api uri to the capture file download
- **state** *(string)*: api uri to the capture json representation
:<header Content-Type: application/json
:status 200: capture created
"""
payload = JsonPayload(request)
filename = payload.param('filename')
temporary = payload.param('temporary', default=False, convert=bool)
use_video_port = payload.param('use_video_port', default=False, convert=bool)
bayer = payload.param('bayer', default=True, convert=bool)
metadata = payload.param('metadata', default={}, convert=dict)
tags = payload.param('tags', default=[], convert=list)
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)
# Explicitally acquire lock (prevents empty files being created if lock is unavailable)
with self.microscope.camera.lock:
output = self.microscope.camera.new_image(
write_to_file=True,
temporary=temporary,
filename=filename)
self.microscope.camera.capture(
output,
use_video_port=use_video_port,
resize=resize,
bayer=bayer)
metadata.update({
'position': self.microscope.state['stage']['position'],
'microscope_id': self.microscope.id,
'microscope_name': self.microscope.name
})
output.put_metadata(metadata)
output.put_tags(tags)
return jsonify(output.state)
class CaptureAPI(MicroscopeView):
def get(self, capture_id):
"""
Get JSON representation of a capture
.. :quickref: Capture; Get capture
**Example request**:
.. sourcecode:: http
GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/ HTTP/1.1
Accept: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"available": true,
"bytestream": false,
"keep_on_disk": false,
"locked": false,
"metadata": {
"filename": "2018-11-16_10-21-53.jpeg",
"id": "d0b2067abbb946f19351e075c5e7cd5b",
"path": "capture/2018-11-16_10-21-53.jpeg",
"time": "2018-11-16_10-21-53"
}
"uri": {
"download": "/api/v1/capture/d0b2067abbb946f19351e075c5e7cd5b/download",
"state": "/api/v1/capture/d0b2067abbb946f19351e075c5e7cd5b/"
}
}
:>json boolean available: availability of capture data
:>json string filename: filename of capture
:>json string id: unique id of the capture object
:>json boolean keep_on_disk: keep the capture file on microscope after closing
:>json boolean locked: file locked for modifications (mostly used for video recording)
:>json string path: path on pi storage to the capture file, if available
:>json boolean stream: capture stored in-memory as a BytesIO stream
:>json json uri: - **download** *(string)*: api uri to the capture file download
- **state** *(string)*: api uri to the capture json representation
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj:
return abort(404) # 404 Not Found
# Get capture state
capture_metadata = capture_obj.state
# Add API routes to returned state
uri_dict = {
'uri': {'state': '{}'.format(url_for('.capture', capture_id=capture_obj.id))}
}
# If available, also add download link
if capture_metadata['available']:
uri_dict['uri']['download'] = '{}download/{}'.format(
url_for('.capture', capture_id=capture_obj.id),
capture_obj.filename
)
capture_metadata.update(uri_dict)
return jsonify(capture_metadata)
def delete(self, capture_id):
"""
Delete all capture data from the Pi, even if `keep_on_disk=true;`.
.. :quickref: Capture; Delete capture.
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj:
return abort(404) # 404 Not Found
capture_obj.delete()
return jsonify({"return": capture_id})
def put(self, capture_id):
"""
Add arbitrary metadata to the capture (stored in an accompanying capture `.yaml` file)
.. :quickref: Capture; Update capture metadata
**Example request**:
.. sourcecode:: http
PUT /camera/capture/d0b2067abbb946f19351e075c5e7cd5b HTTP/1.1
Accept: application/json
{
"user_id": "ofm_1234",
"patient_number": 1452,
}
:>header Accept: application/json
:<header Content-Type: application/json
:status 200: metadata updated
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj:
return abort(404) # 404 Not Found
data_dict = JsonPayload(request).json
capture_obj.put_metadata(data_dict)
capture_metadata = capture_obj.state
return jsonify(capture_metadata)
class DownloadRedirectAPI(MicroscopeView):
def get(self, capture_id):
"""
Return image data for a capture.
Return capture data as an image file with the requested filename.
I.e., `/(capture_id)/download/foo.jpeg` will download the image as
`foo.jpeg`, regardless of the capture's initially set filename.
Route automatically redirects to download the capture under it's currently set filename.
I.e., `/(capture_id)/download` will
redirect to `/(capture_id)/download/(filename)`.
.. :quickref: Capture; Download capture image
**Example request**:
.. sourcecode:: http
GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/download/2018-11-20_16-04-17.jpeg HTTP/1.1
Accept: image/jpeg
:>header Accept: image/jpeg
:query thumbnail: return an image thumbnail e.g. ?thumbnail=true
:>header Content-Type: image/jpeg
:status 200: capture data found
:status 404: no capture found with that id
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj or not capture_obj.state['available']:
return abort(404) # 404 Not Found
thumbnail = get_bool(request.args.get('thumbnail'))
return redirect(url_for(
'.capture_download',
capture_id=capture_id,
filename=capture_obj.filename,
thumbnail=thumbnail
), code=307)
class DownloadAPI(MicroscopeView):
def get(self, capture_id, filename):
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj or not capture_obj.state['available']:
return abort(404) # 404 Not Found
thumbnail = get_bool(request.args.get('thumbnail'))
# If no filename is specified, redirect to the capture's currently set filename
if not filename:
return redirect(url_for(
'capture_download',
capture_id=capture_id,
filename=capture_obj.filename,
thumbnail=thumbnail
), code=307)
# Download the image data using the requested filename
if thumbnail:
img = capture_obj.thumbnail
else:
img = capture_obj.data
return send_file(
img,
mimetype='image/jpeg')
class MetadataRedirectAPI(MicroscopeView):
def get(self, capture_id):
"""
Return metadatadata for a capture.
Return capture metadata as a YAML file with the requested filename.
I.e., `/(capture_id)/download/bar.yaml` will download the file as
`bar.yaml`, regardless of the capture's initially set filename.
Route automatically redirects to download the capture metadata under it's currently set filename.
I.e., `/(capture_id)/download` will
redirect to `/(capture_id)/download/(filename)`.
.. :quickref: Capture; Download capture metadata
**Example request**:
.. sourcecode:: http
GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/metadata/2018-11-20_16-04-17.yaml HTTP/1.1
Accept: text/yaml
:>header Accept: text/yaml
:query as_attachment: return the image as an attachment download e.g. ?as_attachment=true
:>header Content-Type: text/yaml
:status 200: capture data found
:status 404: no capture found with that id
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj or not capture_obj.state['available']:
return abort(404) # 404 Not Found
return redirect(url_for(
'.metadata_download',
capture_id=capture_id,
filename=capture_obj.metadataname
), code=307)
class MetadataAPI(MicroscopeView):
def get(self, capture_id, filename):
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj or not capture_obj.state['available']:
return abort(404) # 404 Not Found
# If no filename is specified, redirect to the capture's currently set filename
if not filename:
return redirect(url_for(
'capture_download',
capture_id=capture_id,
filename=capture_obj.metadataname
), code=307)
# Download the metadata using the requested filename
data = capture_obj.yaml
return Response(
data,
mimetype="text/yaml")
class TagsAPI(MicroscopeView):
def get(self, capture_id):
"""
Return tag list for a capture.
.. :quickref: Capture; Show capture tags
**Example request**:
.. sourcecode:: http
GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1
Accept: text/yaml
:>header Accept: text/yaml
:>header Content-Type: text/yaml
:status 200: capture data found
:status 404: no capture found with that id
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj or not capture_obj.state['available']:
return abort(404) # 404 Not Found
metadata_tags = filter_dict(capture_obj.state, ['metadata', 'tags'])
return jsonify(metadata_tags)
def put(self, capture_id):
"""
Add tags to the capture
.. :quickref: Capture; Update capture tags
**Example request**:
.. sourcecode:: http
PUT /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1
Accept: application/json
["tests", "mytag", "someothertag"]
:>header Accept: application/json
:<header Content-Type: application/json
:status 200: metadata updated
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj or not capture_obj.state['available']:
return abort(404) # 404 Not Found
data_dict = JsonPayload(request).json
if type(data_dict) != list:
return abort(400)
capture_obj.put_tags(data_dict)
metadata_tags = filter_dict(capture_obj.state, ['metadata', 'tags'])
return jsonify(metadata_tags)
def delete(self, capture_id):
"""
Delete tags from the capture
.. :quickref: Capture; Delete tags.
**Example request**:
.. sourcecode:: http
DELETE /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1
Accept: application/json
["tests", "mytag"]
:>header Accept: application/json
:<header Content-Type: application/json
:status 200: metadata updated
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj:
return abort(404) # 404 Not Found
data_dict = JsonPayload(request).json
if type(data_dict) != list:
return abort(400)
for tag in data_dict:
capture_obj.delete_tag(str(tag))
metadata_tags = filter_dict(capture_obj.state, ['metadata', 'tags'])
return jsonify(metadata_tags)

View file

@ -1,103 +0,0 @@
from openflexure_microscope.api.v1.views import MicroscopeView
from openflexure_microscope.api.utilities import JsonPayload
from flask import jsonify, request
class ZoomAPI(MicroscopeView):
def get(self):
"""
Get the current zoom value
.. :quickref: Zoom; Get the zoom value
:>header Accept: application/json
:<header Content-Type: application/json
:status 200: preview started/stopped
"""
zoom_value = self.microscope.camera.state['zoom_value']
return jsonify({'zoom_value': zoom_value})
def post(self):
"""
Change the current zoom value
.. :quickref: Zoom; Set the zoom value
**Example requests**:
.. sourcecode:: http
POST /camera/zoom HTTP/1.1
Accept: application/json
{
"zoom_value": 2.5,
}
:>header Accept: application/json
:<header Content-Type: application/json
:status 200: preview started/stopped
"""
payload = JsonPayload(request)
zoom_value = payload.param('zoom_value', default=1.0, convert=float)
self.microscope.camera.set_zoom(zoom_value)
return jsonify(self.microscope.camera.state)
class OverlayAPI(MicroscopeView):
def get(self):
"""
Get overlay text
.. :quickref: Overlay; Get camera overlay text
:>header Accept: application/json
:<header Content-Type: application/json
:status 200: preview started/stopped
"""
text = self.microscope.camera.camera.annotate_text
size = self.microscope.camera.camera.annotate_text_size
return jsonify({'text': text, 'size': size})
def post(self):
"""
Set overlay text
.. :quickref: Overlay; Set camera overlay text
**Example requests**:
.. sourcecode:: http
POST /camera/overlay HTTP/1.1
Accept: application/json
{
"text": "2019/01/15 14:48",
"size": 50
}
:>header Accept: application/json
:<header Content-Type: application/json
:status 200: preview started/stopped
"""
payload = JsonPayload(request)
text = payload.param('text', default="", convert=str)
size = payload.param('size', default=50, convert=int)
self.microscope.camera.camera.annotate_text = text
self.microscope.camera.camera.annotate_text_size = size
return jsonify({'text': text, 'size': size})

View file

@ -1,55 +0,0 @@
from openflexure_microscope.api.utilities import JsonPayload
from openflexure_microscope.api.v1.views import MicroscopeView
from flask import jsonify, request
import logging
class GPUPreviewAPI(MicroscopeView):
def post(self, operation):
"""
Start or stop the onboard GPU preview.
Optional "window" parameter can be passed to control the position and size of the preview window,
in the format ``[x, y, width, height]``.
.. :quickref: GPU Preview; Start/stop preview
**Example requests**:
.. sourcecode:: http
POST /camera/preview/start HTTP/1.1
Accept: application/json
{
"window": [0, 0, 480, 320],
}
.. sourcecode:: http
POST /camera/preview/stop HTTP/1.1
Accept: application/json
:>header Accept: application/json
:<header Content-Type: application/json
:status 200: preview started/stopped
"""
if operation == "start":
payload = JsonPayload(request)
window = payload.param('window', default=[])
logging.debug(window)
if len(window) != 4:
fullscreen = True
window = None
else:
fullscreen = False
window = [int(w) for w in window]
self.microscope.camera.start_preview(fullscreen=fullscreen, window=window)
elif operation == "stop":
self.microscope.camera.stop_preview()
return jsonify(self.microscope.state)

View file

@ -1,108 +0,0 @@
from openflexure_microscope.api.v1.views import MicroscopeViewPlugin
from flask import Blueprint, jsonify
from openflexure_microscope.api.v1.views import MicroscopeView
import logging
import warnings
class PluginSchemaAPI(MicroscopeView):
def get(self):
"""
Return the current plugin schemas
.. :quickref: Plugin; Get schemas
Returns an array of present plugin schemas (describing plugin user interfaces.)
Please note, this is *not* a list of all enabled plugins, only those with associated
user interface forms.
A complete list of enabled plugins can be found in the microscope state.
"""
out = self.microscope.plugin.schemas
return jsonify(out)
def construct_blueprint(microscope_obj):
blueprint = Blueprint('plugin_blueprint', __name__)
# Create a base route to return plugin API schemas, if any exist
blueprint.add_url_rule(
'/',
view_func=PluginSchemaAPI.as_view('plugin_api_schema', microscope=microscope_obj)
)
all_routes = []
# For each plugin attached to the microscope object
for plugin_name, plugin_obj in microscope_obj.plugin.plugins:
# If plugin contains valid endpoints
if hasattr(plugin_obj, 'api_views') and isinstance(plugin_obj.api_views, dict):
# We'll keep a record of how each route was expanded
expanded_routes = {}
# For each defined endpoint
for view_route, view_class in plugin_obj.api_views.items():
# Remove all leading slashes from view route
cleaned_route = view_route
while cleaned_route[0] == '/':
cleaned_route = cleaned_route[1:]
# Construct a full view route from the plugin name
full_view_route = "/{}/{}".format(plugin_name, cleaned_route)
logging.debug(full_view_route)
# Record how the view_route got expanded
expanded_routes[view_route] = full_view_route
# Check if endpoint name clashes
if full_view_route not in all_routes and issubclass(view_class, MicroscopeViewPlugin):
# Add route to main route dictionary
all_routes.append(full_view_route)
# Create a Python-safe name for the route
plugin_route_id = 'plugin{}'.format(full_view_route).replace('/', '_')
# Add route to the plugins blueprint
blueprint.add_url_rule(
full_view_route,
view_func=view_class.as_view(
plugin_route_id,
microscope=microscope_obj,
plugin=plugin_obj
)
)
else:
warnings.warn(
"An endpoint /{} has already been loaded. Skipping {}.".format(
full_view_route,
view_class
)
)
# If plugin includes an API schema
if hasattr(plugin_obj, 'api_schema') and isinstance(plugin_obj.api_schema, dict):
schema = plugin_obj.api_schema
# TODO: Validate schema? We need to make sure no single plugin can break all plugins.
schema['id'] = plugin_name
if 'forms' in schema and isinstance(schema['forms'], list):
for form in schema['forms']:
if 'route' in form and form['route'] in expanded_routes.keys():
form['route'] = expanded_routes[form['route']]
else:
logging.warn("No valid expandable route found for {}".format(form['route']))
# Store the complete schema in Microscope().plugin.schema
microscope_obj.plugin.schemas.append(schema)
else:
warnings.warn(
"No valid 'api_views' dictionary found in {}".format(plugin_obj)
)
return blueprint

View file

@ -1,102 +0,0 @@
from openflexure_microscope.api.utilities import JsonPayload
from openflexure_microscope.api.v1.views import MicroscopeView
from openflexure_microscope.utilities import axes_to_array, filter_dict
from flask import Blueprint, jsonify, request
import logging
class PositionAPI(MicroscopeView):
def get(self):
"""
Return current x, y and z positions of the stage.
The response is formatted as a sub-section of the general microscope state.
.. :quickref: Position; Get current position
**Example request**:
.. sourcecode:: http
GET /stage/position/ HTTP/1.1
Accept: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"stage": {
"position": {
"x": -6629,
"y": 7489,
"z": -3844
}
}
}
"""
out = filter_dict(self.microscope.state, ['stage', 'position'])
return jsonify(out)
def post(self):
"""
Set x, y and z positions of the stage.
.. :quickref: Position; Update current position
:reqheader Accept: application/json
:<json boolean absolute: (true) move to absolute position, (false) move by relative amount
:<json boolean force: allow moving by more than programmed limit
:<json int x: x steps
:<json int y: y steps
:<json int z: z steps
"""
# Create response object
payload = JsonPayload(request)
logging.debug(payload.json)
# Construct position array
position = [0, 0, 0]
# Handle absolute positioning (calculate a relative move from current position and target)
if (payload.param('absolute') is True) and (self.microscope.stage): # Only if stage exists
target_position = axes_to_array(payload.json, ['x', 'y', 'z'])
logging.debug("TARGET: {}".format(target_position))
position = [target_position[i] - self.microscope.stage.position[i] for i in range(3)]
logging.debug("DELTA: {}".format(position))
else:
# Get coordinates from payload
position = axes_to_array(payload.json, ['x', 'y', 'z'], [0, 0, 0])
logging.debug(position)
# Move if stage exists
if self.microscope.stage:
# Explicitally acquire lock
with self.microscope.stage.lock:
self.microscope.stage.move_rel(position)
out = filter_dict(self.microscope.state, ['stage', 'position'])
return jsonify(out)
def construct_blueprint(microscope_obj):
blueprint = Blueprint('stage_blueprint', __name__)
blueprint.add_url_rule(
'/position',
view_func=PositionAPI.as_view('position', microscope=microscope_obj)
)
return blueprint

View file

@ -1,153 +0,0 @@
from openflexure_microscope.api.v1.views import MicroscopeView
from flask import jsonify, abort, Blueprint
class TaskListAPI(MicroscopeView):
def get(self):
"""
Get list of long-running tasks.
.. :quickref: Tasks; Get collection of tasks
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
[
{
"end_time": "2019-01-23 16-33-33",
"id": "db13a66787e1419bb06b1504e4d80b0c",
"return": [
0.848622546386467,
0.6106785018091292,
],
"start_time": "2019-01-23 16-33-13",
"status": "success"
},
{
"end_time": null,
"id": "df46558cc8844924821bd0181881871e",
"return": null,
"start_time": "2019-01-23 16-34-54",
"status": "running"
}
]
:>header Accept: application/json
:query include_unavailable: return json representations of captures that have been completely deleted
:>header Content-Type: application/json
"""
data = self.microscope.task.state
return jsonify(data)
def delete(self):
"""
Clean list of long-running tasks (running tasks persist).
.. :quickref: Tasks; Clean collection of tasks
:>header Accept: application/json
:>header Content-Type: application/json
"""
self.microscope.task.clean()
data = self.microscope.task.state
return jsonify(data)
class TaskAPI(MicroscopeView):
def get(self, task_id):
"""
Get JSON representation of a task
.. :quickref: Tasks; Get task
**Example request**:
.. sourcecode:: http
GET /task/db13a66787e1419bb06b1504e4d80b0c/ HTTP/1.1
Accept: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"end_time": "2019-01-23 16-33-33",
"id": "db13a66787e1419bb06b1504e4d80b0c",
"return": [
0.848622546386467,
0.6106785018091292,
],
"start_time": "2019-01-23 16-33-13",
"status": "success"
}
"""
task = self.microscope.task.task_from_id(task_id)
if not task_id:
return abort(404) # 404 Not Found
# Return task state
return jsonify(task.state)
def delete(self, task_id):
"""
Remove a particular task (fails if task is currently running).
.. :quickref: Tasks; Remove a task
:>header Accept: application/json
:>header Content-Type: application/json
"""
success = self.microscope.task.delete(task_id)
if success:
data = {
'status': 'success',
'message': 'task successfully deleted'
}
else:
data = {
'status': 'fail',
'message': 'task could not be deleted, as it is currently running or does not exist'
}
return jsonify(data)
def construct_blueprint(microscope_obj):
blueprint = Blueprint('task_blueprint', __name__)
blueprint.add_url_rule(
'/',
view_func=TaskListAPI.as_view('task_list', microscope=microscope_obj)
)
blueprint.add_url_rule(
'/<task_id>/',
view_func=TaskAPI.as_view('task', microscope=microscope_obj)
)
return blueprint

View file

@ -1,26 +0,0 @@
from flask.views import MethodView
class MicroscopeView(MethodView):
"""
Create a generic MethodView with a globally available
microscope object passed as an argument.
"""
def __init__(self, microscope, **kwargs):
self.microscope = microscope
MethodView.__init__(self, **kwargs)
class MicroscopeViewPlugin(MicroscopeView):
"""
Create a generic MethodView with a globally available
microscope object passed as an argument, and a plugin
reference stored in 'self'. Initially None.
"""
def __init__(self, microscope, plugin=None, **kwargs):
self.plugin = plugin
MicroscopeView.__init__(self, microscope=microscope, **kwargs)

View file

@ -0,0 +1,4 @@
from .actions import enabled_root_actions
from .captures import *
from .instrument import *
from .streams import *

View file

@ -0,0 +1,86 @@
"""
Top-level representation of enabled actions
"""
from flask import url_for
from . import camera, stage, system
from labthings import current_labthing
from labthings.views import View
from labthings.utilities import description_from_view
_actions = {
"capture": {
"rule": "/camera/capture/",
"view_class": camera.CaptureAPI,
"conditions": True,
},
"ramCapture": {
"rule": "/camera/ram-capture/",
"view_class": camera.RAMCaptureAPI,
"conditions": True,
},
"previewStart": {
"rule": "/camera/preview/start",
"view_class": camera.GPUPreviewStartAPI,
"conditions": True,
},
"previewStop": {
"rule": "/camera/preview/stop",
"view_class": camera.GPUPreviewStopAPI,
"conditions": True,
},
"move": {
"rule": "/stage/move/",
"view_class": stage.MoveStageAPI,
"conditions": True,
},
"zeroStage": {
"rule": "/stage/zero/",
"view_class": stage.ZeroStageAPI,
"conditions": True,
},
"shutdown": {
"rule": "/system/shutdown/",
"view_class": system.ShutdownAPI,
"conditions": system.is_raspberrypi(),
},
"reboot": {
"rule": "/system/reboot/",
"view_class": system.RebootAPI,
"conditions": system.is_raspberrypi(),
},
}
def enabled_root_actions():
global _actions
return {k: v for k, v in _actions.items() if v["conditions"]}
class ActionsView(View):
def get(self):
"""
List of enabled default API actions.
This list does not include any actions added by LabThings extensions, only
those part of the default OpenFlexure Microscope API.
"""
global _actions
actions = {}
for name, action in enabled_root_actions().items():
d = {
"links": {
"self": {
"href": url_for(action["view_class"].endpoint, _external=True),
"mimetype": "application/json",
**description_from_view(action["view_class"]),
}
},
"rule": action["rule"],
"view_class": str(action["view_class"]),
}
actions[name] = d
return actions

View file

@ -0,0 +1,157 @@
from openflexure_microscope.api.utilities import get_bool, JsonResponse
from labthings.views import View, ActionView
from labthings import find_component, fields
from openflexure_microscope.utilities import filter_dict
from openflexure_microscope.api.v2.views.captures import CaptureSchema
import logging
import io
from flask import request, abort, url_for, redirect, send_file
class CaptureAPI(ActionView):
"""
Create a new image capture.
"""
args = {
"filename": fields.String(example="MyFileName"),
"temporary": fields.Boolean(
missing=False, description="Delete capture on shutdown"
),
"use_video_port": fields.Boolean(missing=False),
"bayer": fields.Boolean(
missing=False, description="Store raw bayer data in file"
),
"annotations": fields.Dict(missing={}, example={"Client": "SwaggerUI"}),
"tags": fields.List(fields.String, missing=[], example=["docs"]),
"resize": fields.Dict(
missing=None, example={"width": 640, "height": 480}
), # TODO: Validate keys
}
schema = CaptureSchema()
def post(self, args):
"""
Create a new capture
"""
microscope = find_component("org.openflexure.microscope")
resize = args.get("resize", None)
if resize:
if ("width" in resize) and ("height" in resize):
resize = (
int(resize["width"]),
int(resize["height"]),
) # Convert dict to tuple
else:
abort(404)
# Explicitally acquire lock (prevents empty files being created if lock is unavailable)
with microscope.camera.lock:
return microscope.capture(
filename=args.get("filename"),
temporary=args.get("temporary"),
use_video_port=args.get("use_video_port"),
resize=resize,
bayer=args.get("bayer"),
annotations=args.get("annotations"),
tags=args.get("tags")
)
class RAMCaptureAPI(ActionView):
"""
Take a non-persistant image capture.
"""
args = {
"use_video_port": fields.Boolean(missing=True),
"bayer": fields.Boolean(
missing=False, description="Return with raw bayer data"
),
"resize": fields.Dict(
missing=None, example={"width": 640, "height": 480}
), # TODO: Validate keys
}
responses = {
200: {
"content_type": "image/jpeg"
}
}
def post(self, args):
"""
Take a non-persistant image capture.
"""
microscope = find_component("org.openflexure.microscope")
resize = args.get("resize", None)
if resize:
if ("width" in resize) and ("height" in resize):
resize = (
int(resize["width"]),
int(resize["height"]),
) # Convert dict to tuple
else:
abort(404)
# Open a BytesIO stream to be destroyed once request has returned
with microscope.camera.lock, io.BytesIO() as stream:
microscope.camera.capture(
stream,
use_video_port=args.get("use_video_port"),
resize=resize,
bayer=args.get("bayer"),
)
stream.seek(0)
return send_file(io.BytesIO(stream.getbuffer()), mimetype="image/jpeg")
class GPUPreviewStartAPI(ActionView):
"""
Start the onboard GPU preview.
Optional "window" parameter can be passed to control the position and size of the preview window,
in the format ``[x, y, width, height]``.
"""
args = {"window": fields.List(fields.Integer, missing=[], example=[0, 0, 640, 480])}
def post(self, args):
"""
Start the onboard GPU preview.
"""
microscope = find_component("org.openflexure.microscope")
window = args.get("window")
logging.debug(window)
if len(window) != 4:
fullscreen = True
window = None
else:
fullscreen = False
window = [int(w) for w in window]
microscope.camera.start_preview(fullscreen=fullscreen, window=window)
# TODO: Make schema for microscope state
return microscope.state
class GPUPreviewStopAPI(ActionView):
def post(self):
"""
Stop the onboard GPU preview.
"""
microscope = find_component("org.openflexure.microscope")
microscope.camera.stop_preview()
# TODO: Make schema for microscope state
return microscope.state

View file

@ -0,0 +1,67 @@
from openflexure_microscope.api.utilities import JsonResponse
from labthings.views import View, ActionView
from labthings import find_component, fields
from openflexure_microscope.utilities import axes_to_array, filter_dict
from flask import Blueprint, request
import logging
class MoveStageAPI(ActionView):
args = {
"absolute": fields.Boolean(
missing=False, example=False, description="Move to an absolute position"
),
"x": fields.Int(missing=0, example=100),
"y": fields.Int(missing=0, example=100),
"z": fields.Int(missing=0, example=20),
}
def post(self, args):
"""
Move the microscope stage in x, y, z
"""
microscope = find_component("org.openflexure.microscope")
# Handle absolute positioning (calculate a relative move from current position and target)
if (args.get("absolute")) and (microscope.stage): # Only if stage exists
target_position = axes_to_array(args, ["x", "y", "z"])
logging.debug("TARGET: {}".format(target_position))
position = [
target_position[i] - microscope.stage.position[i] for i in range(3)
]
logging.debug("DELTA: {}".format(position))
else:
# Get coordinates from payload
position = axes_to_array(args, ["x", "y", "z"], [0, 0, 0])
logging.debug(position)
# Move if stage exists
if microscope.stage:
# Explicitally acquire lock with 1s timeout
with microscope.stage.lock(timeout=1):
microscope.stage.move_rel(position)
else:
logging.warning("Unable to move. No stage found.")
# TODO: Make schema for microscope state
return microscope.state["stage"]["position"]
class ZeroStageAPI(ActionView):
def post(self):
"""
Zero the stage coordinates.
Does not move the stage, but rather makes the current position read as [0, 0, 0]
"""
microscope = find_component("org.openflexure.microscope")
with microscope.stage.lock(timeout=1):
microscope.stage.zero_position()
# TODO: Make schema for microscope state
return microscope.state["stage"]

View file

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

Some files were not shown because too many files have changed in this diff Show more