Merge branch 'master' into 'hotfix-2.0.x'
# Conflicts: # openflexure_microscope/api/app.py # poetry.lock # pyproject.toml
This commit is contained in:
commit
6f4791ec57
25 changed files with 11162 additions and 151 deletions
|
|
@ -32,6 +32,8 @@ The root of your ``gui`` dictionary expects 2 properties:
|
||||||
|
|
||||||
``icon`` - The name of a Material Design icon to use for your plugin
|
``icon`` - The name of a Material Design icon to use for your plugin
|
||||||
|
|
||||||
|
``viewPanel`` *(optional)* - Content to display to the right of the extension form. Either ``stream`` (default), ``gallery``, or ``settings``.
|
||||||
|
|
||||||
``forms`` - An array of forms as described below
|
``forms`` - An array of forms as described below
|
||||||
|
|
||||||
Form level
|
Form level
|
||||||
|
|
@ -53,6 +55,8 @@ Each form is described by a JSON object, with the following properties:
|
||||||
|
|
||||||
``schema`` - List of dictionaries. Each dictionary element describes a form component.
|
``schema`` - List of dictionaries. Each dictionary element describes a form component.
|
||||||
|
|
||||||
|
``emitOnResponse`` *(optional)* - OpenFlexure eV event to emit when a response is recieved from the extension (generally avoid unless you know you need this.)
|
||||||
|
|
||||||
Component level
|
Component level
|
||||||
+++++++++++++++
|
+++++++++++++++
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ app, labthing = create_app(
|
||||||
prefix="/api/v2",
|
prefix="/api/v2",
|
||||||
title=f"OpenFlexure Microscope {api_microscope.name}",
|
title=f"OpenFlexure Microscope {api_microscope.name}",
|
||||||
description="Test LabThing-based API for OpenFlexure Microscope",
|
description="Test LabThing-based API for OpenFlexure Microscope",
|
||||||
|
types=["org.openflexure.microscope"],
|
||||||
version=pkg_resources.get_distribution("openflexure_microscope").version,
|
version=pkg_resources.get_distribution("openflexure_microscope").version,
|
||||||
flask_kwargs={"static_url_path": ""},
|
flask_kwargs={"static_url_path": ""},
|
||||||
)
|
)
|
||||||
|
|
@ -169,5 +170,9 @@ def cleanup():
|
||||||
|
|
||||||
atexit.register(cleanup)
|
atexit.register(cleanup)
|
||||||
|
|
||||||
|
# Start the app
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port="5000", threaded=True, debug=True, use_reloader=False)
|
from labthings.server.wsgi import Server
|
||||||
|
|
||||||
|
server = Server(app)
|
||||||
|
server.run(host="0.0.0.0", port=5000, debug=False, zeroconf=True)
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,5 @@ except Exception as e:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Exception loading builtin extension picamera_autocalibrate: \n{traceback.format_exc()}"
|
f"Exception loading builtin extension picamera_autocalibrate: \n{traceback.format_exc()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..example_extensions.custom_element import customelement_extension_v2
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,13 @@ from openflexure_microscope.utilities import set_properties
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import threading
|
|
||||||
import logging
|
import logging
|
||||||
from scipy import ndimage
|
from scipy import ndimage
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from threading import Thread, Event
|
||||||
|
|
||||||
### Autofocus utilities
|
### Autofocus utilities
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,7 +27,7 @@ class JPEGSharpnessMonitor:
|
||||||
self.jpeg_times = []
|
self.jpeg_times = []
|
||||||
self.stage_positions = []
|
self.stage_positions = []
|
||||||
self.stage_times = []
|
self.stage_times = []
|
||||||
self.stop_event = threading.Event()
|
self.stop_event = Event()
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.keep_alive()
|
self.keep_alive()
|
||||||
self.background_thread = None
|
self.background_thread = None
|
||||||
|
|
@ -48,26 +50,31 @@ class JPEGSharpnessMonitor:
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"Start monitoring sharpness by looking at JPEG size"
|
"Start monitoring sharpness by looking at JPEG size"
|
||||||
self.background_thread = threading.Thread(target=self._measure_jpegs)
|
self.background_thread = Thread(target=self._measure_jpegs)
|
||||||
self.background_thread.start()
|
self.background_thread.start()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"Stop the background thread"
|
"Stop the background thread"
|
||||||
self.stop_event.set()
|
self.stop_event.set()
|
||||||
|
print("Joining JPEG thread")
|
||||||
self.background_thread.join()
|
self.background_thread.join()
|
||||||
|
|
||||||
def _measure_jpegs(self):
|
def _measure_jpegs(self):
|
||||||
"Function that runs in a background thread to record sharpness"
|
"Function that runs in a background thread to record sharpness"
|
||||||
logging.info("Starting sharpness measurement in background thread")
|
logging.debug("Starting sharpness measurement in background thread")
|
||||||
self.keep_alive()
|
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():
|
while not self.stop_event.is_set() and not self.should_stop():
|
||||||
self.jpeg_sizes.append(self.jpeg_size())
|
size_now = self.jpeg_size()
|
||||||
self.jpeg_times.append(time.time())
|
time_now = time.time()
|
||||||
|
self.jpeg_sizes.append(size_now)
|
||||||
|
self.jpeg_times.append(time_now)
|
||||||
|
print("Exited JPEG measure loop")
|
||||||
if self.stop_event.is_set():
|
if self.stop_event.is_set():
|
||||||
logging.info("Cleanly stopped sharpness measurement in background thread")
|
logging.debug("Cleanly stopped sharpness measurement in background thread")
|
||||||
if self.should_stop():
|
if self.should_stop():
|
||||||
logging.info("Sharpness measurement timed out and has stopped")
|
logging.debug("Sharpness measurement timed out and has stopped")
|
||||||
|
|
||||||
def jpeg_size(self):
|
def jpeg_size(self):
|
||||||
"""Return the size of a frame from the MJPEG stream"""
|
"""Return the size of a frame from the MJPEG stream"""
|
||||||
|
|
@ -195,7 +202,7 @@ def move_and_find_focus(microscope, dz):
|
||||||
|
|
||||||
def fast_autofocus(microscope, dz=2000, backlash=None):
|
def fast_autofocus(microscope, dz=2000, backlash=None):
|
||||||
"""Perform a down-up-down-up autofocus"""
|
"""Perform a down-up-down-up autofocus"""
|
||||||
with monitor_sharpness(microscope) as m, microscope.camera.lock:
|
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 / 2)
|
||||||
i, z = m.focus_rel(dz)
|
i, z = m.focus_rel(dz)
|
||||||
fz = m.sharpest_z_on_move(i)
|
fz = m.sharpest_z_on_move(i)
|
||||||
|
|
@ -245,17 +252,20 @@ def fast_up_down_up_autofocus(
|
||||||
might slightly hurt accuracy, but is unlikely to be a big issue. Too big
|
might slightly hurt accuracy, but is unlikely to be a big issue. Too big
|
||||||
may cause you to overshoot, which is a problem.
|
may cause you to overshoot, which is a problem.
|
||||||
"""
|
"""
|
||||||
with monitor_sharpness(microscope) as m, microscope.camera.lock:
|
with monitor_sharpness(microscope) as m, microscope.camera.lock, microscope.stage.lock:
|
||||||
# Ensure the MJPEG stream has started
|
# Ensure the MJPEG stream has started
|
||||||
microscope.camera.start_stream_recording()
|
microscope.camera.start_stream_recording()
|
||||||
|
|
||||||
df = dz # TODO: refactor so I actually use dz in the code below!
|
df = dz # TODO: refactor so I actually use dz in the code below!
|
||||||
|
logging.debug("Initial move")
|
||||||
if initial_move_up:
|
if initial_move_up:
|
||||||
m.focus_rel(df / 2)
|
m.focus_rel(df / 2)
|
||||||
# move down
|
# move down
|
||||||
|
logging.debug("Move down")
|
||||||
i, z = m.focus_rel(-df)
|
i, z = m.focus_rel(-df)
|
||||||
# now inspect where the sharpest point is, and estimate the sharpness
|
# now inspect where the sharpest point is, and estimate the sharpness
|
||||||
# (JPEG size) that we should find at the start of the Z stack
|
# (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)
|
jt, jz, js = m.move_data(i)
|
||||||
best_z = jz[np.argmax(js)]
|
best_z = jz[np.argmax(js)]
|
||||||
target_s = np.interp(
|
target_s = np.interp(
|
||||||
|
|
@ -263,11 +273,13 @@ def fast_up_down_up_autofocus(
|
||||||
) # NB jz is decreasing
|
) # NB jz is decreasing
|
||||||
|
|
||||||
# now move to the start of the z stack
|
# now move to the start of the z stack
|
||||||
|
logging.debug("Move to the start of the z stack")
|
||||||
i, z = m.focus_rel(
|
i, z = m.focus_rel(
|
||||||
best_z + target_z - z + mini_backlash
|
best_z + target_z - z + mini_backlash
|
||||||
) # takes us to the start of the stack
|
) # takes us to the start of the stack
|
||||||
|
|
||||||
# We've deliberately undershot - figure out how much further we should move based on the curve
|
# 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()
|
current_js = m.jpeg_size()
|
||||||
imax = np.argmax(js) # we want to crop out just the bit below the peak
|
imax = np.argmax(js) # we want to crop out just the bit below the peak
|
||||||
js = js[imax:] # NB z is in DECREASING order
|
js = js[imax:] # NB z is in DECREASING order
|
||||||
|
|
@ -279,6 +291,7 @@ def fast_up_down_up_autofocus(
|
||||||
|
|
||||||
# So, the Z position corresponding to our current sharpness value is zs[inow]
|
# So, the Z position corresponding to our current sharpness value is zs[inow]
|
||||||
# That means we should move forwards, by best_z - 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]
|
correction_move = best_z + target_z - jz[inow]
|
||||||
logging.debug(
|
logging.debug(
|
||||||
"Fast autofocus scan: correcting backlash by moving {} steps".format(
|
"Fast autofocus scan: correcting backlash by moving {} steps".format(
|
||||||
|
|
@ -317,7 +330,7 @@ class AutofocusAPI(View):
|
||||||
dz = payload.param("dz", default=np.linspace(-300, 300, 7), convert=np.array)
|
dz = payload.param("dz", default=np.linspace(-300, 300, 7), convert=np.array)
|
||||||
|
|
||||||
if microscope.has_real_stage():
|
if microscope.has_real_stage():
|
||||||
logging.info("Running autofocus...")
|
logging.debug("Running autofocus...")
|
||||||
task = taskify(autofocus)(microscope, dz)
|
task = taskify(autofocus)(microscope, dz)
|
||||||
|
|
||||||
# return a handle on the autofocus task
|
# return a handle on the autofocus task
|
||||||
|
|
@ -348,7 +361,7 @@ class FastAutofocusAPI(View):
|
||||||
backlash = 0
|
backlash = 0
|
||||||
|
|
||||||
if microscope.has_real_stage():
|
if microscope.has_real_stage():
|
||||||
logging.info("Running autofocus...")
|
logging.debug("Running autofocus...")
|
||||||
task = taskify(fast_up_down_up_autofocus)(microscope, dz=dz, mini_backlash=backlash)
|
task = taskify(fast_up_down_up_autofocus)(microscope, dz=dz, mini_backlash=backlash)
|
||||||
|
|
||||||
# return a handle on the autofocus task
|
# return a handle on the autofocus task
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,7 @@ def dynamic_form():
|
||||||
return {
|
return {
|
||||||
"icon": "sd_storage",
|
"icon": "sd_storage",
|
||||||
"title": "Storage",
|
"title": "Storage",
|
||||||
|
"viewPanel": "gallery",
|
||||||
"forms": [
|
"forms": [
|
||||||
{
|
{
|
||||||
"name": "Autostorage",
|
"name": "Autostorage",
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,35 @@ from labthings.core.tasks import taskify
|
||||||
|
|
||||||
from flask import abort
|
from flask import abort
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from .recalibrate_utils import recalibrate_camera, auto_expose_and_freeze_settings
|
# 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):
|
def recalibrate(microscope):
|
||||||
"""Reset the camera's settings.
|
"""Reset the camera's settings.
|
||||||
|
|
@ -19,24 +44,10 @@ def recalibrate(microscope):
|
||||||
table such that the background is as uniform as possible
|
table such that the background is as uniform as possible
|
||||||
with a gray level of 230. It takes a little while to run.
|
with a gray level of 230. It takes a little while to run.
|
||||||
"""
|
"""
|
||||||
scamera = microscope.camera
|
with pause_stream(microscope.camera) as scamera:
|
||||||
with scamera.lock:
|
auto_expose_and_freeze_settings(scamera.camera) # scamera.camera is the PiCamera object
|
||||||
assert not scamera.record_active, "Can't recalibrate while recording!"
|
recalibrate_camera(scamera.camera)
|
||||||
streaming = scamera.stream_active
|
microscope.save_settings()
|
||||||
if streaming:
|
|
||||||
logging.info("Stopping stream before recalibration")
|
|
||||||
scamera.stop_stream_recording(resolution=(640, 480))
|
|
||||||
old_resolution = scamera.camera.resolution
|
|
||||||
try:
|
|
||||||
scamera.camera.resolution = (640, 480)
|
|
||||||
auto_expose_and_freeze_settings(scamera.camera)
|
|
||||||
recalibrate_camera(scamera.camera)
|
|
||||||
finally:
|
|
||||||
scamera.camera.resolution = old_resolution
|
|
||||||
microscope.save_settings()
|
|
||||||
if streaming:
|
|
||||||
logging.info("Restarting stream after recalibration")
|
|
||||||
scamera.start_stream_recording()
|
|
||||||
|
|
||||||
|
|
||||||
@ThingAction
|
@ThingAction
|
||||||
|
|
@ -52,9 +63,42 @@ class RecalibrateView(View):
|
||||||
|
|
||||||
return taskify(recalibrate)(microscope)
|
return taskify(recalibrate)(microscope)
|
||||||
|
|
||||||
|
@ThingAction
|
||||||
|
class FlattenLSTView(View):
|
||||||
|
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?")
|
||||||
|
|
||||||
|
@ThingAction
|
||||||
|
class DeleteLSTView(View):
|
||||||
|
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(
|
lst_extension_v2 = BaseExtension(
|
||||||
"org.openflexure.calibration.picamera", version="2.0.0-beta.1"
|
"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(
|
lst_extension_v2.add_method(
|
||||||
|
|
@ -62,3 +106,5 @@ lst_extension_v2.add_method(
|
||||||
)
|
)
|
||||||
|
|
||||||
lst_extension_v2.add_view(RecalibrateView, "/recalibrate")
|
lst_extension_v2.add_view(RecalibrateView, "/recalibrate")
|
||||||
|
lst_extension_v2.add_view(FlattenLSTView, "/flatten_lens_shading_table")
|
||||||
|
lst_extension_v2.add_view(DeleteLSTView, "/delete_lens_shading_table")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
from labthings.server.extensions import BaseExtension
|
||||||
|
from labthings.server.view import View
|
||||||
|
from labthings.server.decorators import ThingProperty, PropertySchema, use_args
|
||||||
|
from labthings.server import fields
|
||||||
|
from labthings.server.find import find_component
|
||||||
|
from labthings.core.utilities import path_relative_to
|
||||||
|
|
||||||
|
from openflexure_microscope.paths import settings_file_path, check_rw
|
||||||
|
from openflexure_microscope.config import OpenflexureSettingsFile
|
||||||
|
from openflexure_microscope.camera.base import BASE_CAPTURE_PATH
|
||||||
|
from openflexure_microscope.camera.capture import build_captures_from_exif
|
||||||
|
|
||||||
|
from openflexure_microscope.api.utilities.gui import build_gui
|
||||||
|
|
||||||
|
from flask import abort, url_for
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
from sys import platform
|
||||||
|
|
||||||
|
|
||||||
|
class CustomElementExtension(BaseExtension):
|
||||||
|
def __init__(self):
|
||||||
|
BaseExtension.__init__(
|
||||||
|
self,
|
||||||
|
"org.openflexure.customelement",
|
||||||
|
description="Testing HTML components in an extension",
|
||||||
|
static_folder=path_relative_to(__file__, "static", "dist"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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"CustomElementExtension found microscope {microscope_obj}")
|
||||||
|
|
||||||
|
|
||||||
|
customelement_extension_v2 = CustomElementExtension()
|
||||||
|
|
||||||
|
|
||||||
|
def wc_func():
|
||||||
|
return {
|
||||||
|
"href": customelement_extension_v2.static_file_url("my-custom-element.min.js"),
|
||||||
|
"name": "my-custom-element",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def gui_func():
|
||||||
|
return {"icon": "pets", "title": "Element", "viewPanel": "stream", "wc": wc_func()}
|
||||||
|
|
||||||
|
|
||||||
|
customelement_extension_v2.add_meta("wc", wc_func)
|
||||||
|
customelement_extension_v2.add_meta(
|
||||||
|
"gui", build_gui(gui_func, customelement_extension_v2)
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
> 1%
|
||||||
|
last 2 versions
|
||||||
1
openflexure_microscope/api/example_extensions/custom_element/static/.gitattributes
vendored
Normal file
1
openflexure_microscope/api/example_extensions/custom_element/static/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
dist/* filter=lfs diff=lfs merge=lfs -text
|
||||||
23
openflexure_microscope/api/example_extensions/custom_element/static/.gitignore
vendored
Normal file
23
openflexure_microscope/api/example_extensions/custom_element/static/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# 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?
|
||||||
|
|
||||||
|
# Override and include dist
|
||||||
|
!/dist
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
'@vue/cli-plugin-babel/preset'
|
||||||
|
]
|
||||||
|
}
|
||||||
3
openflexure_microscope/api/example_extensions/custom_element/static/dist/demo.html
vendored
Normal file
3
openflexure_microscope/api/example_extensions/custom_element/static/dist/demo.html
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:18020f0209272ab81d01b2d68da517fcff98dd07d2ad7fe738c66df75003f7cf
|
||||||
|
size 197
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:94cc5b4e5b3ddb8c6b94ef9b81b49060c582935aedf6c7d1626f4166c1827442
|
||||||
|
size 288006
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ba98b02d051bf6f7d2d4302154512a72ae6ddc1eff261e171fd92a067e1bdf84
|
||||||
|
size 92018
|
||||||
10783
openflexure_microscope/api/example_extensions/custom_element/static/package-lock.json
generated
Normal file
10783
openflexure_microscope/api/example_extensions/custom_element/static/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "vue-web-component-project",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "vue-cli-service build --target wc --inline-vue --name my-custom-element ./src/components/VueWebComponent.vue"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^0.19.2",
|
||||||
|
"core-js": "^3.4.4",
|
||||||
|
"vue": "^2.6.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vue/cli-plugin-babel": "^4.1.0",
|
||||||
|
"@vue/cli-service": "^4.1.0",
|
||||||
|
"vue-template-compiler": "^2.6.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
<template>
|
||||||
|
<div class="my-component-class">
|
||||||
|
<h1>My Vue Web Component</h1>
|
||||||
|
<div>Base URL: {{ componentBaseURL }}</div>
|
||||||
|
<p>Host microscope name: {{ hostDeviceName }}</p>
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
<br />
|
||||||
|
<p>This cheeky little counter has all of its logic contained in a server-side component:</p>
|
||||||
|
<button type="button" v-on:click="decrement()">-</button>
|
||||||
|
<span>{{ value }}</span>
|
||||||
|
<button type="button" v-on:click="increment()">+</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
|
||||||
|
props: {
|
||||||
|
'componentBaseURL': {
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
type: String
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data: function() {
|
||||||
|
return {
|
||||||
|
value: 0,
|
||||||
|
message: "",
|
||||||
|
hostDeviceName: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted () {
|
||||||
|
if (this.componentBaseURL){
|
||||||
|
axios
|
||||||
|
.get(`${this.componentBaseURL}`)
|
||||||
|
.then(response => {
|
||||||
|
console.log(response.data)
|
||||||
|
this.hostDeviceName = response.data.title
|
||||||
|
})
|
||||||
|
.catch(function(error) {
|
||||||
|
console.log("Error reading test json, probabl because of cors or something")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.message = "No componentBaseURL given"
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
decrement: function() {
|
||||||
|
this.value = this.value -1
|
||||||
|
},
|
||||||
|
|
||||||
|
increment: function() {
|
||||||
|
this.value = this.value +1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.my-component-class {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import Vue from 'vue';
|
||||||
|
import wrap from '@vue/web-component-wrapper';
|
||||||
|
import VueWebComponent from './components/VueWebComponent';
|
||||||
|
|
||||||
|
const CustomElement = wrap(Vue, VueWebComponent);
|
||||||
|
|
||||||
|
window.customElements.define('my-custom-element', CustomElement);
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
module.exports = {
|
||||||
|
productionSourceMap: false
|
||||||
|
};
|
||||||
|
|
@ -18,7 +18,16 @@ class MjpegStream(View):
|
||||||
@doc_response(200, mimetype="multipart/x-mixed-replace")
|
@doc_response(200, mimetype="multipart/x-mixed-replace")
|
||||||
def get(self):
|
def get(self):
|
||||||
"""
|
"""
|
||||||
MJPEG stream from the microscope camera
|
MJPEG stream from the microscope camera.
|
||||||
|
|
||||||
|
Note: While the code actually getting frame data from a camera and storing it in
|
||||||
|
camera.frame runs in a thread, the gen(microscope.camera) generator does not.
|
||||||
|
This response is therefore blocking. The generator just yields the most recent
|
||||||
|
frame from the camera object, passed to the Flask response, and then repeats until
|
||||||
|
the connection is closed.
|
||||||
|
|
||||||
|
Without monkey patching with gevent, or using a native threaded server, the stream
|
||||||
|
will block all proceeding requests.
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
microscope = find_component("org.openflexure.microscope")
|
||||||
# Restart stream worker thread
|
# Restart stream worker thread
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,16 @@ import threading
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import gevent
|
||||||
|
|
||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
from .capture import CaptureObject, build_captures_from_exif
|
from .capture import CaptureObject, build_captures_from_exif
|
||||||
from openflexure_microscope.utilities import entry_by_uuid
|
from openflexure_microscope.utilities import entry_by_uuid
|
||||||
from labthings.core.lock import StrictLock
|
from labthings.core.lock import StrictLock
|
||||||
|
from labthings.core.event import ClientEvent
|
||||||
|
|
||||||
from openflexure_microscope.paths import data_file_path
|
from openflexure_microscope.paths import data_file_path
|
||||||
|
|
||||||
|
|
@ -44,51 +48,6 @@ def generate_numbered_basename(obj_list: list) -> str:
|
||||||
return basename
|
return basename
|
||||||
|
|
||||||
|
|
||||||
class CameraEvent(object):
|
|
||||||
"""
|
|
||||||
A frame-signaller object used by any instances or subclasses of BaseCamera.
|
|
||||||
|
|
||||||
An event-like class that signals all active clients when a new frame is available.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.events = {}
|
|
||||||
|
|
||||||
def wait(self, timeout: int = 5):
|
|
||||||
"""Wait for the next frame (invoked from each client's thread)."""
|
|
||||||
ident = threading.get_ident()
|
|
||||||
if ident not in self.events:
|
|
||||||
# this is a new client
|
|
||||||
# add an entry for it in the self.events dict
|
|
||||||
# each entry has two elements, a threading.Event() and a timestamp
|
|
||||||
self.events[ident] = [threading.Event(), time.time()]
|
|
||||||
return self.events[ident][0].wait(timeout)
|
|
||||||
|
|
||||||
def set(self):
|
|
||||||
"""Signal that a new frame is available."""
|
|
||||||
now = time.time()
|
|
||||||
remove = None
|
|
||||||
for ident, event in self.events.items():
|
|
||||||
if not event[0].isSet():
|
|
||||||
# if this client's event is not set, then set it
|
|
||||||
# also update the last set timestamp to now
|
|
||||||
event[0].set()
|
|
||||||
event[1] = now
|
|
||||||
else:
|
|
||||||
# if the client's event is already set, it means the client
|
|
||||||
# did not process a previous frame
|
|
||||||
# if the event stays set for more than 5 seconds, then assume
|
|
||||||
# the client is gone and remove it
|
|
||||||
if now - event[1] > 5:
|
|
||||||
remove = ident
|
|
||||||
if remove:
|
|
||||||
del self.events[remove]
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
"""Clear frame event, once processed."""
|
|
||||||
self.events[threading.get_ident()][0].clear()
|
|
||||||
|
|
||||||
|
|
||||||
class BaseCamera(metaclass=ABCMeta):
|
class BaseCamera(metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
Base implementation of StreamingCamera.
|
Base implementation of StreamingCamera.
|
||||||
|
|
@ -98,11 +57,11 @@ class BaseCamera(metaclass=ABCMeta):
|
||||||
self.thread = None
|
self.thread = None
|
||||||
self.camera = None
|
self.camera = None
|
||||||
|
|
||||||
self.lock = StrictLock(timeout=1)
|
self.lock = StrictLock(timeout=1, name="Camera")
|
||||||
|
|
||||||
self.frame = None
|
self.frame = None
|
||||||
self.last_access = 0
|
self.last_access = 0
|
||||||
self.event = CameraEvent()
|
self.event = ClientEvent()
|
||||||
self.stop = False # Used to indicate that the stream loop should break
|
self.stop = False # Used to indicate that the stream loop should break
|
||||||
|
|
||||||
self.stream_timeout = 20
|
self.stream_timeout = 20
|
||||||
|
|
@ -181,65 +140,6 @@ class BaseCamera(metaclass=ABCMeta):
|
||||||
def rebuild_captures(self):
|
def rebuild_captures(self):
|
||||||
self.images = build_captures_from_exif(self.paths["default"])
|
self.images = build_captures_from_exif(self.paths["default"])
|
||||||
|
|
||||||
# START AND STOP WORKER THREAD
|
|
||||||
|
|
||||||
def start_worker(self, timeout: int = 5) -> bool:
|
|
||||||
"""Start the background camera thread if it isn't running yet."""
|
|
||||||
timeout_time = time.time() + timeout
|
|
||||||
|
|
||||||
self.last_access = time.time()
|
|
||||||
self.stop = False
|
|
||||||
|
|
||||||
if not self.stream_active:
|
|
||||||
# start background frame thread
|
|
||||||
self.thread = threading.Thread(target=self._thread)
|
|
||||||
self.thread.daemon = True
|
|
||||||
self.thread.start()
|
|
||||||
|
|
||||||
# wait until frames are available
|
|
||||||
logging.info("Waiting for frames")
|
|
||||||
while self.get_frame() is None:
|
|
||||||
if time.time() > timeout_time:
|
|
||||||
raise TimeoutError("Timeout waiting for frames.")
|
|
||||||
else:
|
|
||||||
time.sleep(0.1)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def stop_worker(self, timeout: int = 5) -> bool:
|
|
||||||
"""Flag worker thread for stop. Waits for thread close or timeout."""
|
|
||||||
logging.debug("Stopping worker thread")
|
|
||||||
timeout_time = time.time() + timeout
|
|
||||||
|
|
||||||
if self.stream_active:
|
|
||||||
self.stop = True
|
|
||||||
self.thread.join() # Wait for stream thread to exit
|
|
||||||
logging.debug("Waiting for stream thread to exit.")
|
|
||||||
|
|
||||||
while self.stream_active:
|
|
||||||
if time.time() > timeout_time:
|
|
||||||
logging.debug("Timeout waiting for worker thread close.")
|
|
||||||
raise TimeoutError("Timeout waiting for worker thread close.")
|
|
||||||
else:
|
|
||||||
time.sleep(0.1)
|
|
||||||
return True
|
|
||||||
|
|
||||||
# HANDLE STREAM FRAMES
|
|
||||||
|
|
||||||
def get_frame(self):
|
|
||||||
"""Return the current camera frame."""
|
|
||||||
self.last_access = time.time()
|
|
||||||
|
|
||||||
# wait for a signal from the camera thread
|
|
||||||
self.event.wait()
|
|
||||||
self.event.clear()
|
|
||||||
|
|
||||||
return self.frame
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def frames(self):
|
|
||||||
"""Create generator that returns frames from the camera."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
# RETURNING CAPTURES
|
# RETURNING CAPTURES
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -355,6 +255,64 @@ class BaseCamera(metaclass=ABCMeta):
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
# START AND STOP WORKER THREAD
|
||||||
|
|
||||||
|
def start_worker(self, timeout: int = 5) -> bool:
|
||||||
|
"""Start the background camera thread if it isn't running yet."""
|
||||||
|
timeout_time = time.time() + timeout
|
||||||
|
|
||||||
|
self.last_access = time.time()
|
||||||
|
self.stop = False
|
||||||
|
|
||||||
|
if not self.stream_active:
|
||||||
|
# Spawn a greenlet to handle stream
|
||||||
|
# start background frame thread
|
||||||
|
self.thread = gevent.spawn(self._thread)
|
||||||
|
|
||||||
|
# wait until frames are available
|
||||||
|
logging.info("Waiting for frames")
|
||||||
|
while self.get_frame() is None:
|
||||||
|
if time.time() > timeout_time:
|
||||||
|
raise TimeoutError("Timeout waiting for frames.")
|
||||||
|
else:
|
||||||
|
time.sleep(0.1)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop_worker(self, timeout: int = 5) -> bool:
|
||||||
|
"""Flag worker thread for stop. Waits for thread close or timeout."""
|
||||||
|
logging.debug("Stopping worker thread")
|
||||||
|
timeout_time = time.time() + timeout
|
||||||
|
|
||||||
|
if self.stream_active:
|
||||||
|
self.stop = True
|
||||||
|
self.thread.join() # Wait for stream thread to exit
|
||||||
|
logging.debug("Waiting for stream thread to exit.")
|
||||||
|
|
||||||
|
while self.stream_active:
|
||||||
|
if time.time() > timeout_time:
|
||||||
|
logging.debug("Timeout waiting for worker thread close.")
|
||||||
|
raise TimeoutError("Timeout waiting for worker thread close.")
|
||||||
|
else:
|
||||||
|
time.sleep(0.1)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# HANDLE STREAM FRAMES
|
||||||
|
|
||||||
|
def get_frame(self):
|
||||||
|
"""Return the current camera frame."""
|
||||||
|
self.last_access = time.time()
|
||||||
|
|
||||||
|
# wait for a signal from the camera thread
|
||||||
|
self.event.wait()
|
||||||
|
self.event.clear()
|
||||||
|
|
||||||
|
return self.frame
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def frames(self):
|
||||||
|
"""Create generator that returns frames from the camera."""
|
||||||
|
pass
|
||||||
|
|
||||||
# WORKER THREAD
|
# WORKER THREAD
|
||||||
|
|
||||||
def _thread(self):
|
def _thread(self):
|
||||||
|
|
@ -367,7 +325,6 @@ class BaseCamera(metaclass=ABCMeta):
|
||||||
for frame in self.frames_iterator:
|
for frame in self.frames_iterator:
|
||||||
self.frame = frame
|
self.frame = frame
|
||||||
self.event.set() # send signal to clients
|
self.event.set() # send signal to clients
|
||||||
time.sleep(0)
|
|
||||||
|
|
||||||
# Handle timeout
|
# Handle timeout
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import time
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image, ImageFont, ImageDraw
|
from PIL import Image, ImageFont, ImageDraw
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import gevent
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
|
@ -224,7 +225,7 @@ class MissingCamera(BaseCamera):
|
||||||
# While the iterator is not closed
|
# While the iterator is not closed
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
time.sleep(1) # Only serve frames at 1fps
|
gevent.sleep(1) # Only serve frames at 1fps
|
||||||
# Reset stream
|
# Reset stream
|
||||||
self.stream.seek(0)
|
self.stream.seek(0)
|
||||||
self.stream.truncate()
|
self.stream.truncate()
|
||||||
|
|
|
||||||
|
|
@ -531,6 +531,7 @@ class PiCameraStreamer(BaseCamera):
|
||||||
# Set resolution and stop stream recording if necessary
|
# Set resolution and stop stream recording if necessary
|
||||||
if not use_video_port:
|
if not use_video_port:
|
||||||
self.stop_stream_recording()
|
self.stop_stream_recording()
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
self.camera.capture(
|
self.camera.capture(
|
||||||
target,
|
target,
|
||||||
|
|
@ -540,6 +541,7 @@ class PiCameraStreamer(BaseCamera):
|
||||||
bayer=(not use_video_port) and bayer,
|
bayer=(not use_video_port) and bayer,
|
||||||
use_video_port=use_video_port,
|
use_video_port=use_video_port,
|
||||||
)
|
)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
# Set resolution and start stream recording if necessary
|
# Set resolution and start stream recording if necessary
|
||||||
if not use_video_port:
|
if not use_video_port:
|
||||||
|
|
|
||||||
|
|
@ -191,15 +191,10 @@ class ExtensibleSerialInstrument(object):
|
||||||
will keep reading until a termination phrase is reached.
|
will keep reading until a termination phrase is reached.
|
||||||
"""
|
"""
|
||||||
with self.communications_lock:
|
with self.communications_lock:
|
||||||
logging.debug("Flushing input buffer...")
|
|
||||||
self.flush_input_buffer()
|
self.flush_input_buffer()
|
||||||
logging.debug(f"Writing query: {queryString}")
|
|
||||||
self.write(queryString)
|
self.write(queryString)
|
||||||
logging.debug("Query written")
|
|
||||||
if self.ignore_echo == True: # Needs Implementing for a multiline read!
|
if self.ignore_echo == True: # Needs Implementing for a multiline read!
|
||||||
logging.debug("Reading first line...")
|
|
||||||
first_line = self.readline(timeout).strip()
|
first_line = self.readline(timeout).strip()
|
||||||
logging.debug(f"Read finished. Got {first_line}")
|
|
||||||
if first_line == queryString:
|
if first_line == queryString:
|
||||||
return self.readline(timeout).strip()
|
return self.readline(timeout).strip()
|
||||||
else:
|
else:
|
||||||
|
|
@ -209,14 +204,11 @@ class ExtensibleSerialInstrument(object):
|
||||||
if termination_line is not None:
|
if termination_line is not None:
|
||||||
multiline = True
|
multiline = True
|
||||||
if multiline:
|
if multiline:
|
||||||
logging.debug("Reading multiline...")
|
|
||||||
return self.read_multiline(termination_line)
|
return self.read_multiline(termination_line)
|
||||||
else:
|
else:
|
||||||
logging.debug("Reading response...")
|
|
||||||
line = self.readline(
|
line = self.readline(
|
||||||
timeout
|
timeout
|
||||||
).strip() # question: should we strip the final newline?
|
).strip() # question: should we strip the final newline?
|
||||||
logging.debug(f"Read finished. Got {line}")
|
|
||||||
return line
|
return line
|
||||||
|
|
||||||
def parsed_query(
|
def parsed_query(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "poetry.masonry.api"
|
||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "openflexure_microscope"
|
name = "openflexure_microscope"
|
||||||
version = "2.0.1"
|
version = "2.1.0-dev"
|
||||||
description = "Python module, and Flask-based web API, to run the OpenFlexure Microscope."
|
description = "Python module, and Flask-based web API, to run the OpenFlexure Microscope."
|
||||||
|
|
||||||
authors = [
|
authors = [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue