Merge branch 'master' into 'hotfix-2.0.x'

# Conflicts:
#   openflexure_microscope/api/app.py
#   poetry.lock
#   pyproject.toml
This commit is contained in:
Joel Collins 2020-03-31 10:10:33 +00:00
commit 6f4791ec57
25 changed files with 11162 additions and 151 deletions

View file

@ -62,6 +62,7 @@ app, labthing = create_app(
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").version,
flask_kwargs={"static_url_path": ""},
)
@ -169,5 +170,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.server.wsgi import Server
server = Server(app)
server.run(host="0.0.0.0", port=5000, debug=False, zeroconf=True)

View file

@ -13,3 +13,5 @@ except Exception as e:
logging.error(
f"Exception loading builtin extension picamera_autocalibrate: \n{traceback.format_exc()}"
)
from ..example_extensions.custom_element import customelement_extension_v2

View file

@ -8,11 +8,13 @@ from openflexure_microscope.utilities import set_properties
import time
import numpy as np
import threading
import logging
from scipy import ndimage
from contextlib import contextmanager
from threading import Thread, Event
### Autofocus utilities
@ -25,7 +27,7 @@ class JPEGSharpnessMonitor:
self.jpeg_times = []
self.stage_positions = []
self.stage_times = []
self.stop_event = threading.Event()
self.stop_event = Event()
self.timeout = timeout
self.keep_alive()
self.background_thread = None
@ -48,26 +50,31 @@ class JPEGSharpnessMonitor:
def start(self):
"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()
return self
def stop(self):
"Stop the background thread"
self.stop_event.set()
print("Joining JPEG thread")
self.background_thread.join()
def _measure_jpegs(self):
"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()
logging.debug(f"_measure_jpegs stop_event: {self.stop_event.is_set()}")
while not self.stop_event.is_set() and not self.should_stop():
self.jpeg_sizes.append(self.jpeg_size())
self.jpeg_times.append(time.time())
size_now = self.jpeg_size()
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():
logging.info("Cleanly stopped sharpness measurement in background thread")
logging.debug("Cleanly stopped sharpness measurement in background thread")
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):
"""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):
"""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)
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
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
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(
@ -263,11 +273,13 @@ def fast_up_down_up_autofocus(
) # 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
@ -279,6 +291,7 @@ def fast_up_down_up_autofocus(
# 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(
@ -317,7 +330,7 @@ class AutofocusAPI(View):
dz = payload.param("dz", default=np.linspace(-300, 300, 7), convert=np.array)
if microscope.has_real_stage():
logging.info("Running autofocus...")
logging.debug("Running autofocus...")
task = taskify(autofocus)(microscope, dz)
# return a handle on the autofocus task
@ -348,7 +361,7 @@ class FastAutofocusAPI(View):
backlash = 0
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)
# return a handle on the autofocus task

View file

@ -241,6 +241,7 @@ def dynamic_form():
return {
"icon": "sd_storage",
"title": "Storage",
"viewPanel": "gallery",
"forms": [
{
"name": "Autostorage",

View file

@ -7,10 +7,35 @@ from labthings.core.tasks import taskify
from flask import abort
from contextlib import contextmanager
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):
"""Reset the camera's settings.
@ -19,24 +44,10 @@ def recalibrate(microscope):
table such that the background is as uniform as possible
with a gray level of 230. It takes a little while to run.
"""
scamera = microscope.camera
with scamera.lock:
assert not scamera.record_active, "Can't recalibrate while recording!"
streaming = scamera.stream_active
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()
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()
@ThingAction
@ -52,9 +63,42 @@ class RecalibrateView(View):
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(
"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(
@ -62,3 +106,5 @@ lst_extension_v2.add_method(
)
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")

View file

@ -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)
)

View file

@ -0,0 +1,2 @@
> 1%
last 2 versions

View file

@ -0,0 +1 @@
dist/* filter=lfs diff=lfs merge=lfs -text

View 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

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:18020f0209272ab81d01b2d68da517fcff98dd07d2ad7fe738c66df75003f7cf
size 197

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:94cc5b4e5b3ddb8c6b94ef9b81b49060c582935aedf6c7d1626f4166c1827442
size 288006

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ba98b02d051bf6f7d2d4302154512a72ae6ddc1eff261e171fd92a067e1bdf84
size 92018

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

@ -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>

View file

@ -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);

View file

@ -0,0 +1,3 @@
module.exports = {
productionSourceMap: false
};

View file

@ -18,7 +18,16 @@ class MjpegStream(View):
@doc_response(200, mimetype="multipart/x-mixed-replace")
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")
# Restart stream worker thread