Merge branch 'capture-manager' into 'master'
Capture manager See merge request openflexure/openflexure-microscope-server!57
This commit is contained in:
commit
d884680daa
30 changed files with 767 additions and 475 deletions
|
|
@ -1,5 +1,6 @@
|
|||
#!/usr/bin/env python
|
||||
from gevent import monkey
|
||||
|
||||
monkey.patch_all()
|
||||
|
||||
import time
|
||||
|
|
@ -56,6 +57,7 @@ logger.setLevel(logging.INFO)
|
|||
# Log server paths being used
|
||||
logging.info(f"Running with data path {OPENFLEXURE_VAR_PATH}")
|
||||
|
||||
print("Creating app")
|
||||
# Create flask app
|
||||
app, labthing = create_app(
|
||||
__name__,
|
||||
|
|
@ -174,5 +176,6 @@ atexit.register(cleanup)
|
|||
if __name__ == "__main__":
|
||||
from labthings.server.wsgi import Server
|
||||
|
||||
print("Starting OpenFlexure Microscope Server...")
|
||||
server = Server(app)
|
||||
server.run(host="::", port=5000, debug=False, zeroconf=True)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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."""
|
||||
|
|
@ -12,6 +13,7 @@ def handle_extension_error(extension_name):
|
|||
f"Exception loading builtin extension picamera_autocalibrate: \n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
|
||||
with handle_extension_error("autofocus"):
|
||||
from .autofocus import autofocus_extension_v2
|
||||
with handle_extension_error("scan"):
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ class JPEGSharpnessMonitor:
|
|||
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...")
|
||||
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()
|
||||
|
|
@ -106,7 +108,9 @@ class JPEGSharpnessMonitor:
|
|||
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?")
|
||||
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:
|
||||
|
|
@ -120,7 +124,9 @@ class JPEGSharpnessMonitor:
|
|||
"""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?")
|
||||
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):
|
||||
|
|
@ -212,7 +218,9 @@ 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, microscope.stage.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)
|
||||
|
|
@ -262,7 +270,9 @@ 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, microscope.stage.lock:
|
||||
with monitor_sharpness(
|
||||
microscope
|
||||
) as m, microscope.camera.lock, microscope.stage.lock:
|
||||
# Ensure the MJPEG stream has started
|
||||
microscope.camera.start_stream_recording()
|
||||
|
||||
|
|
@ -372,7 +382,9 @@ class FastAutofocusAPI(View):
|
|||
|
||||
if microscope.has_real_stage():
|
||||
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 task
|
||||
|
|
@ -382,12 +394,15 @@ class FastAutofocusAPI(View):
|
|||
|
||||
|
||||
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."
|
||||
"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(
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ from labthings.server.find import find_component
|
|||
|
||||
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.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
|
||||
|
||||
|
|
@ -37,17 +37,17 @@ def get_permissive_locations():
|
|||
]
|
||||
|
||||
|
||||
def get_current_location(camera):
|
||||
return camera.paths.get("default")
|
||||
def get_current_location(capture_manager):
|
||||
return capture_manager.paths.get("default")
|
||||
|
||||
|
||||
def set_current_location(camera, location: str):
|
||||
def set_current_location(capture_manager, location: str):
|
||||
if not os.path.isdir(location):
|
||||
os.makedirs(location)
|
||||
logging.debug("Updating location...")
|
||||
camera.paths.update({"default": location})
|
||||
capture_manager.paths.update({"default": location})
|
||||
logging.debug("Rebuilding captures...")
|
||||
camera.rebuild_captures()
|
||||
capture_manager.rebuild_captures()
|
||||
logging.debug("Capture location changed successfully.")
|
||||
|
||||
|
||||
|
|
@ -92,8 +92,8 @@ class AutostorageExtension(BaseExtension):
|
|||
description="Handle switching capture storage devices",
|
||||
)
|
||||
|
||||
# We'll store a reference to a camera object, who's capture paths will be modified
|
||||
self.camera = None
|
||||
# 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()
|
||||
|
||||
|
|
@ -103,13 +103,15 @@ class AutostorageExtension(BaseExtension):
|
|||
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, "camera"):
|
||||
logging.debug(f"Autostorage extension bound to camera {self.camera}")
|
||||
if hasattr(microscope_obj, "captures"):
|
||||
logging.debug(
|
||||
f"Autostorage extension bound to CaptureManager {self.capture_manager}"
|
||||
)
|
||||
|
||||
# Store a reference to the camera
|
||||
self.camera = microscope_obj.camera
|
||||
# Store a reference to the CaptureManager
|
||||
self.capture_manager = microscope_obj.captures
|
||||
# Store the initial storage location
|
||||
self.initial_location = get_current_location(self.camera)
|
||||
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)
|
||||
|
|
@ -118,29 +120,29 @@ class AutostorageExtension(BaseExtension):
|
|||
|
||||
def check_location(self, location=None):
|
||||
if not location:
|
||||
location = get_current_location(self.camera)
|
||||
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.camera, get_default_location())
|
||||
set_current_location(self.capture_manager, get_default_location())
|
||||
|
||||
def get_locations(self):
|
||||
if self.camera:
|
||||
if self.capture_manager:
|
||||
locations = get_all_locations()
|
||||
|
||||
current_location = get_current_location(self.camera)
|
||||
current_location = get_current_location(self.capture_manager)
|
||||
if current_location not in locations.values():
|
||||
locations.update({"Custom": current_location})
|
||||
# Add location from the cameras settings file
|
||||
# Add location from the CaptureManager settings file
|
||||
return locations
|
||||
else:
|
||||
return {}
|
||||
|
||||
def get_preferred_key(self):
|
||||
current = get_current_location(self.camera)
|
||||
current = get_current_location(self.capture_manager)
|
||||
locations = self.get_locations()
|
||||
|
||||
matches = [k for k, v in locations.items() if v == current]
|
||||
|
|
@ -157,7 +159,7 @@ class AutostorageExtension(BaseExtension):
|
|||
raise KeyError(f"No location named {new_path_key}")
|
||||
|
||||
location = self.get_locations().get(new_path_key)
|
||||
set_current_location(self.camera, location)
|
||||
set_current_location(self.capture_manager, location)
|
||||
|
||||
def key_to_title(self, path_key: str):
|
||||
if not path_key in self.get_locations().keys():
|
||||
|
|
|
|||
|
|
@ -7,13 +7,14 @@ Created on Tue May 26 08:08:14 2015
|
|||
|
||||
import numpy as np
|
||||
|
||||
|
||||
class AttributeDict(dict):
|
||||
"""This class extends a dictionary to have a "create" method for
|
||||
compatibility with h5py attrs objects."""
|
||||
|
||||
|
||||
def create(self, name, data):
|
||||
self[name] = data
|
||||
|
||||
|
||||
def modify(self, name, data):
|
||||
self[name] = data
|
||||
|
||||
|
|
@ -22,7 +23,8 @@ class AttributeDict(dict):
|
|||
for k in list(self.keys()):
|
||||
if isinstance(self[k], np.ndarray):
|
||||
self[k] = np.copy(self[k])
|
||||
|
||||
|
||||
|
||||
def ensure_attribute_dict(obj, copy=False):
|
||||
"""Given a mapping that may or not be an AttributeDict, return an
|
||||
AttributeDict object that either is, or copies the data of, the input."""
|
||||
|
|
@ -33,15 +35,17 @@ def ensure_attribute_dict(obj, copy=False):
|
|||
if copy:
|
||||
out.copy_arrays()
|
||||
return out
|
||||
|
||||
|
||||
|
||||
def ensure_attrs(obj):
|
||||
"""Return an ArrayWithAttrs version of an array-like object, may be the
|
||||
original object if it already has attrs."""
|
||||
if hasattr(obj, 'attrs'):
|
||||
return obj #if it has attrs, do nothing
|
||||
if hasattr(obj, "attrs"):
|
||||
return obj # if it has attrs, do nothing
|
||||
else:
|
||||
return ArrayWithAttrs(obj) #otherwise, wrap it
|
||||
|
||||
return ArrayWithAttrs(obj) # otherwise, wrap it
|
||||
|
||||
|
||||
class ArrayWithAttrs(np.ndarray):
|
||||
"""A numpy ndarray, with an AttributeDict accessible as array.attrs.
|
||||
|
||||
|
|
@ -50,7 +54,7 @@ class ArrayWithAttrs(np.ndarray):
|
|||
a lot to the ``InfoArray`` example in `numpy` documentation on subclassing
|
||||
`numpy.ndarray`.
|
||||
"""
|
||||
|
||||
|
||||
def __new__(cls, input_array, attrs={}):
|
||||
"""Make a new ndarray, based on an existing one, with an attrs dict.
|
||||
|
||||
|
|
@ -64,26 +68,29 @@ class ArrayWithAttrs(np.ndarray):
|
|||
obj.attrs = ensure_attribute_dict(attrs)
|
||||
# return the new object
|
||||
return obj
|
||||
|
||||
|
||||
def __array_finalize__(self, obj):
|
||||
# this is called by numpy when the object is created (__new__ may or
|
||||
# may not get called)
|
||||
if obj is None: return # if obj is None, __new__ was called - do nothing
|
||||
if obj is None:
|
||||
return # if obj is None, __new__ was called - do nothing
|
||||
# if we didn't create the object with __new__, we must add the attrs
|
||||
# dictionary. We copy this from the source object if possible (while
|
||||
# ensuring it's the right type) or create a new, empty one if not.
|
||||
# NB we don't use ensure_attribute_dict because we want to make sure the
|
||||
# dict object is *copied* not merely referenced.
|
||||
self.attrs = ensure_attribute_dict(getattr(obj, 'attrs', {}), copy=True)
|
||||
self.attrs = ensure_attribute_dict(getattr(obj, "attrs", {}), copy=True)
|
||||
|
||||
|
||||
def attribute_bundler(attrs):
|
||||
"""Return a function that bundles the supplied attributes with an array."""
|
||||
|
||||
def bundle_attrs(array):
|
||||
return ArrayWithAttrs(array, attrs=attrs)
|
||||
|
||||
|
||||
class DummyHDF5Group(dict):
|
||||
def __init__(self,dictionary, attrs ={}, name="DummyHDF5Group"):
|
||||
def __init__(self, dictionary, attrs={}, name="DummyHDF5Group"):
|
||||
super(DummyHDF5Group, self).__init__()
|
||||
self.attrs = attrs
|
||||
for key in dictionary:
|
||||
|
|
@ -92,4 +99,4 @@ class DummyHDF5Group(dict):
|
|||
self.basename = name
|
||||
|
||||
file = None
|
||||
parent = None
|
||||
parent = None
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ from numpy.linalg import norm
|
|||
from .camera_stage_tracker import Tracker, move_until_motion_detected
|
||||
import logging
|
||||
|
||||
|
||||
def displacements(positions):
|
||||
"""Calculate the absolute distance of each point from the first point."""
|
||||
return norm(positions - positions[0,:][np.newaxis,:], axis=1)
|
||||
return norm(positions - positions[0, :][np.newaxis, :], axis=1)
|
||||
|
||||
|
||||
def direction_from_points(points):
|
||||
"""Given an Nx2 array of points, figure out the principal component.
|
||||
|
|
@ -25,7 +27,8 @@ def direction_from_points(points):
|
|||
points = points.astype(np.float)
|
||||
points -= np.mean(points, axis=0)[np.newaxis, :]
|
||||
eigenvalues, eigenvectors = np.linalg.eig(np.cov(points.T))
|
||||
return eigenvectors[:,np.argmax(eigenvalues)]
|
||||
return eigenvectors[:, np.argmax(eigenvalues)]
|
||||
|
||||
|
||||
def apply_backlash(x, backlash=0, start_unwound=True):
|
||||
"""Apply a basic model of backlash to a set of coordinates.
|
||||
|
|
@ -42,14 +45,15 @@ def apply_backlash(x, backlash=0, start_unwound=True):
|
|||
y[0] = x[0] + initial_direction * backlash
|
||||
else:
|
||||
y[0] = x[0]
|
||||
for i in range(1,len(x)):
|
||||
d = x[i] - y[i-1]
|
||||
for i in range(1, len(x)):
|
||||
d = x[i] - y[i - 1]
|
||||
if np.abs(d) >= backlash:
|
||||
y[i] = x[i] - np.sign(d) * backlash
|
||||
else:
|
||||
y[i] = y[i-1]
|
||||
y[i] = y[i - 1]
|
||||
return y
|
||||
|
||||
|
||||
def fit_backlash(moves):
|
||||
"""Given a set of linear moves forwards and back, estimate backlash.
|
||||
|
||||
|
|
@ -99,7 +103,7 @@ def fit_backlash(moves):
|
|||
residuals = yfit - (xfit_blsh * m + c)
|
||||
return m, c, np.std(residuals, ddof=3)
|
||||
|
||||
max_backlash = (np.max(xfit) - np.min(xfit))/3
|
||||
max_backlash = (np.max(xfit) - np.min(xfit)) / 3
|
||||
backlash_values = []
|
||||
residual_values = []
|
||||
backlash = 0
|
||||
|
|
@ -107,18 +111,18 @@ def fit_backlash(moves):
|
|||
m, c, residual = fit_motion(xfit, yfit, backlash)
|
||||
residual_values.append(residual)
|
||||
backlash_values.append(backlash)
|
||||
backlash += max(1, backlash/3)
|
||||
backlash += max(1, backlash / 3)
|
||||
|
||||
backlash = backlash_values[np.argmin(residual_values)]
|
||||
m, c, residual = fit_motion(xfit, yfit, backlash)
|
||||
|
||||
fractional_error = residual/norm(np.diff(yfit))
|
||||
fractional_error = residual / norm(np.diff(yfit))
|
||||
if fractional_error > 0.1:
|
||||
raise ValueError("The fit didn't look successful")
|
||||
|
||||
return {
|
||||
"backlash": backlash,
|
||||
"pixels_per_step": m,
|
||||
"backlash": backlash,
|
||||
"pixels_per_step": m,
|
||||
"fractional_error": fractional_error,
|
||||
"stage_direction": stage_direction,
|
||||
"image_direction": image_direction,
|
||||
|
|
@ -126,36 +130,42 @@ def fit_backlash(moves):
|
|||
}
|
||||
|
||||
|
||||
def calibrate_backlash_1d(tracker, move, direction=np.array([1,0,0])):
|
||||
def calibrate_backlash_1d(tracker, move, direction=np.array([1, 0, 0])):
|
||||
"""Figure out reasonable step sizes for calibration, and estimate the backlash."""
|
||||
try: # Ensure that the tracker has a template set
|
||||
try: # Ensure that the tracker has a template set
|
||||
_ = tracker.template
|
||||
except:
|
||||
tracker.acquire_template()
|
||||
assert tracker.stage_positions.shape[0] == 1
|
||||
original_stage_pos = tracker.stage_positions[-1,:]
|
||||
original_stage_pos = tracker.stage_positions[-1, :]
|
||||
|
||||
direction = direction / np.sum(direction**2)**0.5 # ensure "direction" is normalised
|
||||
direction = (
|
||||
direction / np.sum(direction ** 2) ** 0.5
|
||||
) # ensure "direction" is normalised
|
||||
|
||||
logging.info("Moving the stage until we see motion...")
|
||||
# Move the stage until we can see a significant amount of motion
|
||||
i, m = move_until_motion_detected(
|
||||
tracker, move, direction, threshold=tracker.max_safe_displacement * 0.2)
|
||||
|
||||
tracker, move, direction, threshold=tracker.max_safe_displacement * 0.2
|
||||
)
|
||||
|
||||
logging.info("Moving the stage to the edge of the field of view...")
|
||||
i, m = move_until_motion_detected(
|
||||
tracker, move, direction,
|
||||
tracker,
|
||||
move,
|
||||
direction,
|
||||
threshold=tracker.max_safe_displacement * 0.7,
|
||||
multipliers=m/2.0 * np.arange(20),
|
||||
detect_cumulative_motion=True)
|
||||
multipliers=m / 2.0 * np.arange(20),
|
||||
detect_cumulative_motion=True,
|
||||
)
|
||||
exponential_moves = tracker.history
|
||||
|
||||
# Include this final step, and make a rough estimate of the scaling from stage to image
|
||||
stage_pos, image_pos = tracker.history
|
||||
stage_step = stage_pos[-1, :] - stage_pos[-1 - i, :]
|
||||
image_step = image_pos[-1, :] - image_pos[-1 - i, :]
|
||||
steps_per_pixel = norm(stage_step)/norm(image_step)
|
||||
|
||||
steps_per_pixel = norm(stage_step) / norm(image_step)
|
||||
|
||||
# Calculate a step that moves roughly 0.2 times the max. displacement (i.e. 0.1 times the FoV)
|
||||
sensible_step = direction * tracker.max_safe_displacement * 0.2 * steps_per_pixel
|
||||
tracker.reset_history()
|
||||
|
|
@ -167,27 +177,35 @@ def calibrate_backlash_1d(tracker, move, direction=np.array([1,0,0])):
|
|||
starting_stage_pos, starting_camera_pos = tracker.append_point()
|
||||
for i in range(15):
|
||||
move(starting_stage_pos - sensible_step * (i + 1))
|
||||
#print(".", end="")
|
||||
# print(".", end="")
|
||||
stage_pos, image_pos = tracker.append_point()
|
||||
if (i > 3 and tracker.moving_away_from_centre
|
||||
and norm(image_pos) > 0.65 * tracker.max_safe_displacement):
|
||||
break # Stop once we have moved far enough
|
||||
if (
|
||||
i > 3
|
||||
and tracker.moving_away_from_centre
|
||||
and norm(image_pos) > 0.65 * tracker.max_safe_displacement
|
||||
):
|
||||
break # Stop once we have moved far enough
|
||||
|
||||
logging.info("Moving the stage forwards to measure backlash (2/2)")
|
||||
# Move forwards again, in 10 steps
|
||||
starting_stage_pos, starting_camera_pos = tracker.append_point()
|
||||
for i in range(15):
|
||||
move(starting_stage_pos + sensible_step * (i + 1))
|
||||
#print(".", end="")
|
||||
# print(".", end="")
|
||||
stage_pos, image_pos = tracker.append_point()
|
||||
if (i > 3 and tracker.moving_away_from_centre
|
||||
and norm(image_pos) > 0.65 * tracker.max_safe_displacement):
|
||||
break # Stop once we have moved far enough
|
||||
if (
|
||||
i > 3
|
||||
and tracker.moving_away_from_centre
|
||||
and norm(image_pos) > 0.65 * tracker.max_safe_displacement
|
||||
):
|
||||
break # Stop once we have moved far enough
|
||||
linear_moves = tracker.history
|
||||
|
||||
try:
|
||||
res = fit_backlash(linear_moves)
|
||||
backlash_correction = sensible_step / norm(sensible_step) * res["backlash"] * 1.5
|
||||
backlash_correction = (
|
||||
sensible_step / norm(sensible_step) * res["backlash"] * 1.5
|
||||
)
|
||||
|
||||
# Finally, move back to the starting position, doing backlash-corrected moves.
|
||||
logging.info("Moving back to the start, correcting for backlash...")
|
||||
|
|
@ -200,32 +218,39 @@ def calibrate_backlash_1d(tracker, move, direction=np.array([1,0,0])):
|
|||
backlash_corrected_moves = tracker.history
|
||||
move(original_stage_pos - backlash_correction)
|
||||
except ValueError:
|
||||
return {"exponential_moves": exponential_moves, "linear_moves": linear_moves,}
|
||||
return {"exponential_moves": exponential_moves, "linear_moves": linear_moves}
|
||||
finally:
|
||||
# Reset position
|
||||
move(original_stage_pos)
|
||||
|
||||
logging.info(f"Estimated backlash {res['backlash']:.0f} steps")
|
||||
logging.info(f"Stage-to-image ratio {np.abs(res['pixels_per_step']):.3f} pixels/step")
|
||||
logging.info(f"Residuals were about {res['fractional_error']:.2f} times the step size")
|
||||
logging.info(
|
||||
f"Stage-to-image ratio {np.abs(res['pixels_per_step']):.3f} pixels/step"
|
||||
)
|
||||
logging.info(
|
||||
f"Residuals were about {res['fractional_error']:.2f} times the step size"
|
||||
)
|
||||
|
||||
res.update({
|
||||
"exponential_moves": exponential_moves,
|
||||
"linear_moves": linear_moves,
|
||||
"backlash_corrected_moves": backlash_corrected_moves
|
||||
})
|
||||
res.update(
|
||||
{
|
||||
"exponential_moves": exponential_moves,
|
||||
"linear_moves": linear_moves,
|
||||
"backlash_corrected_moves": backlash_corrected_moves,
|
||||
}
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
def plot_1d_backlash_calibration(results):
|
||||
"""Plot the results of a calibration run"""
|
||||
from matplotlib import pyplot as plt
|
||||
f, ax = plt.subplots(1,2)
|
||||
|
||||
|
||||
f, ax = plt.subplots(1, 2)
|
||||
|
||||
for k in ["exponential", "linear", "backlash_corrected"]:
|
||||
moves = results[k+"_moves"]
|
||||
moves = results[k + "_moves"]
|
||||
if moves is not None:
|
||||
ax[0].plot(moves[1][:,0], moves[1][:,1], 'o-')
|
||||
ax[0].plot(moves[1][:, 0], moves[1][:, 1], "o-")
|
||||
ax[0].set_aspect(1, adjustable="datalim")
|
||||
|
||||
image_direction = results["image_direction"]
|
||||
|
|
@ -237,19 +262,20 @@ def plot_1d_backlash_calibration(results):
|
|||
image_1d = np.sum(image_pos * image_direction[np.newaxis, :], axis=1)
|
||||
return stage_1d, image_1d
|
||||
|
||||
ax[1].plot(*convert_moves(results["exponential_moves"]), 'o-')
|
||||
|
||||
ax[1].plot(*convert_moves(results["exponential_moves"]), "o-")
|
||||
|
||||
stage_pos, image_pos = convert_moves(results["linear_moves"])
|
||||
model = apply_backlash(stage_pos, results["backlash"])
|
||||
model *= results["pixels_per_step"]
|
||||
model += np.mean(image_pos) - np.mean(model)
|
||||
ax[1].plot(stage_pos, model, '-')
|
||||
ax[1].plot(stage_pos, image_pos, 'o')
|
||||
ax[1].plot(stage_pos, model, "-")
|
||||
ax[1].plot(stage_pos, image_pos, "o")
|
||||
if results["backlash_corrected_moves"] is not None:
|
||||
ax[1].plot(*convert_moves(results["backlash_corrected_moves"]), '+')
|
||||
ax[1].plot(*convert_moves(results["backlash_corrected_moves"]), "+")
|
||||
|
||||
return f, ax
|
||||
|
||||
|
||||
def image_to_stage_displacement_from_1d(calibrations):
|
||||
"""Combine X and Y calibrations
|
||||
|
||||
|
|
@ -271,9 +297,11 @@ def image_to_stage_displacement_from_1d(calibrations):
|
|||
c_blash = np.abs(cal["backlash"] * cal["stage_direction"])
|
||||
backlash[backlash < c_blash] = c_blash[backlash < c_blash]
|
||||
|
||||
A, res, rank, s = np.linalg.lstsq(image_vectors, stage_vectors) # we solve image*A = stage
|
||||
A, res, rank, s = np.linalg.lstsq(
|
||||
image_vectors, stage_vectors
|
||||
) # we solve image*A = stage
|
||||
return {
|
||||
"image_to_stage_displacement": A,
|
||||
"backlash_vector": backlash,
|
||||
"backlash": np.max(backlash),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,19 +14,22 @@ from camera_stage_tracker import Tracker, move_until_motion_detected
|
|||
|
||||
from functools import partial
|
||||
|
||||
|
||||
def backlash_corrected_move(get_position, move, backlash_amount, pos):
|
||||
"""Make two moves, arriving at `pos` from a consistent direction"""
|
||||
displacement = pos - get_position()
|
||||
backlash_vector = (displacement < 0).astype(np.int)*backlash_amount
|
||||
backlash_vector = (displacement < 0).astype(np.int) * backlash_amount
|
||||
if np.any(backlash_vector > 0):
|
||||
move(pos - backlash_vector)
|
||||
move(pos)
|
||||
|
||||
|
||||
def bake_backlash_corrected_move(get_position, move, backlash_amount):
|
||||
"""Return a function that performs backlash-corrected moves"""
|
||||
return partial(backlash_corrected_move, get_position, move, backlash_amount)
|
||||
|
||||
def calibrate_xy_grid(tracker, move, step = 100, n_steps=4, backlash_compensation=0):
|
||||
|
||||
|
||||
def calibrate_xy_grid(tracker, move, step=100, n_steps=4, backlash_compensation=0):
|
||||
"""Make a series of moves in X and Y to determine the XY components of the pixel-to-sample matrix.
|
||||
|
||||
Arguments:
|
||||
|
|
@ -38,44 +41,50 @@ def calibrate_xy_grid(tracker, move, step = 100, n_steps=4, backlash_compensatio
|
|||
step : float, optional (default 100)
|
||||
The amount to move the stage by. This should move the sample by approximately 1/10th of the field of view.
|
||||
"""
|
||||
try: # Ensure that the tracker has a template set
|
||||
try: # Ensure that the tracker has a template set
|
||||
_ = tracker.template
|
||||
except:
|
||||
tracker.acquire_template()
|
||||
tracker.reset_history() # make sure we get rid of the initial (0,0) point
|
||||
tracker.reset_history() # make sure we get rid of the initial (0,0) point
|
||||
starting_position = tracker.get_position()
|
||||
# Move the stage in a square, recording the displacement from both the stage and the camera
|
||||
try:
|
||||
for x in (np.arange(n_steps) - n_steps/2.0)*step:
|
||||
for y in (np.arange(n_steps) - n_steps/2.0)*step:
|
||||
for x in (np.arange(n_steps) - n_steps / 2.0) * step:
|
||||
for y in (np.arange(n_steps) - n_steps / 2.0) * step:
|
||||
move(starting_position + np.array([x, y, 0]))
|
||||
tracker.append_point()
|
||||
finally:
|
||||
move(starting_position)
|
||||
# We then use least-squares to fit the XY part of the matrix relating
|
||||
# We then use least-squares to fit the XY part of the matrix relating
|
||||
# pixels to distance
|
||||
# stage_positions should be the stage positions, with a zero mean.
|
||||
# image_positions should be the same, but calculated from the images
|
||||
stage_positions, image_positions = tracker.history
|
||||
stage_positions = stage_positions.astype(np.float)
|
||||
stage_positions -= np.mean(stage_positions, axis=0)
|
||||
stage_positions = stage_positions[:,:2] # ensure it's 2d
|
||||
stage_positions = stage_positions[:, :2] # ensure it's 2d
|
||||
image_positions -= np.mean(image_positions, axis=0)
|
||||
#image_positions *= -1 # To get the matrix right, we want the position of each
|
||||
# image relative to the template, rather than the other way around
|
||||
A, res, rank, s = np.linalg.lstsq(image_positions, stage_positions) # we solve pixel_shifts*A = location_shifts
|
||||
# image_positions *= -1 # To get the matrix right, we want the position of each
|
||||
# image relative to the template, rather than the other way around
|
||||
A, res, rank, s = np.linalg.lstsq(
|
||||
image_positions, stage_positions
|
||||
) # we solve pixel_shifts*A = location_shifts
|
||||
|
||||
transformed_image_positions = np.dot(image_positions, A)
|
||||
residuals = transformed_image_positions - stage_positions
|
||||
fractional_error = norm(residuals) / stage_positions.shape[0] step
|
||||
fractional_error = norm(residuals) / stage_positions.shape[0]
|
||||
print(f"Ratio of residuals to displacement is {fractional_error})")
|
||||
if fractional_error > 0.05: # Check it was a reasonably good fit
|
||||
print("Warning: the error fitting measured displacements was %.1f%%" % (fractional_error*100))
|
||||
print(f"Calibrated the pixel-location matrix.\nResiduals were {fractional_error*100:.1f}% of the shift.")
|
||||
|
||||
return {
|
||||
"image_to_stage_displacement": A,
|
||||
"moves": (stage_positions, image_positions),
|
||||
"fractional_error": fractional_error
|
||||
}
|
||||
if fractional_error > 0.05: # Check it was a reasonably good fit
|
||||
print(
|
||||
"Warning: the error fitting measured displacements was %.1f%%"
|
||||
% (fractional_error * 100)
|
||||
)
|
||||
print(
|
||||
f"Calibrated the pixel-location matrix.\nResiduals were {fractional_error*100:.1f}% of the shift."
|
||||
)
|
||||
|
||||
return {
|
||||
"image_to_stage_displacement": A,
|
||||
"moves": (stage_positions, image_positions),
|
||||
"fractional_error": fractional_error,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,11 @@ from numpy.linalg import norm
|
|||
import cv2
|
||||
from scipy import ndimage
|
||||
|
||||
|
||||
def central_half(image):
|
||||
"""Return the central 50% (in X and Y) of an image"""
|
||||
w, h = image.shape[:2]
|
||||
return image[int(w/4):int(3*w/4),int(h/4):int(3*h/4), ...]
|
||||
return image[int(w / 4) : int(3 * w / 4), int(h / 4) : int(3 * h / 4), ...]
|
||||
|
||||
|
||||
def datum_pixel(image):
|
||||
|
|
@ -23,7 +24,8 @@ def datum_pixel(image):
|
|||
try:
|
||||
return np.array(image.datum_pixel)
|
||||
except:
|
||||
return (np.array(image.shape[:2]) - 1) / 2.
|
||||
return (np.array(image.shape[:2]) - 1) / 2.0
|
||||
|
||||
|
||||
def locate_feature_in_image(image, feature, margin=0, restrict=False):
|
||||
"""Find the given feature (small image) and return the position of its datum (or centre) in the image's pixels.
|
||||
|
|
@ -47,31 +49,51 @@ def locate_feature_in_image(image, feature, margin=0, restrict=False):
|
|||
image to yield the position in the sample of the feature you're looking for.
|
||||
"""
|
||||
# The line below is superfluous if we keep the datum-aware code below it.
|
||||
assert image.shape[0] > feature.shape[0] and image.shape[1] > feature.shape[1], "Image must be larger than feature!"
|
||||
assert (
|
||||
image.shape[0] > feature.shape[0] and image.shape[1] > feature.shape[1]
|
||||
), "Image must be larger than feature!"
|
||||
# Check that there's enough space around the feature image
|
||||
lower_margin = datum_pixel(image) - datum_pixel(feature)
|
||||
upper_margin = (image.shape[:2] - datum_pixel(image)) - (feature.shape[:2] - datum_pixel(feature))
|
||||
assert np.all(np.array([lower_margin, upper_margin]) >= margin), "The feature image is too large."
|
||||
#TODO: sensible auto-crop of the template if it's too large?
|
||||
image_shift = np.array((0,0))
|
||||
upper_margin = (image.shape[:2] - datum_pixel(image)) - (
|
||||
feature.shape[:2] - datum_pixel(feature)
|
||||
)
|
||||
assert np.all(
|
||||
np.array([lower_margin, upper_margin]) >= margin
|
||||
), "The feature image is too large."
|
||||
# TODO: sensible auto-crop of the template if it's too large?
|
||||
image_shift = np.array((0, 0))
|
||||
if restrict:
|
||||
# if requested, crop the larger image so that our search area is (2*margin + 1) square.
|
||||
image_shift = np.array(lower_margin - margin,dtype = int)
|
||||
image = image[image_shift[0]:image_shift[0] + feature.shape[0] + 2 * margin + 1,
|
||||
image_shift[1]:image_shift[1] + feature.shape[1] + 2 * margin + 1, ...]
|
||||
image_shift = np.array(lower_margin - margin, dtype=int)
|
||||
image = image[
|
||||
image_shift[0] : image_shift[0] + feature.shape[0] + 2 * margin + 1,
|
||||
image_shift[1] : image_shift[1] + feature.shape[1] + 2 * margin + 1,
|
||||
...,
|
||||
]
|
||||
|
||||
corr = cv2.matchTemplate(image, feature,
|
||||
cv2.TM_SQDIFF_NORMED) # correlate them: NB the match position is the MINIMUM
|
||||
corr = -corr # invert the image so we can find a peak
|
||||
corr += (corr.max() - corr.min()) * 0.1 - corr.max() # background-subtract 90% of maximum
|
||||
corr = cv2.matchTemplate(
|
||||
image, feature, cv2.TM_SQDIFF_NORMED
|
||||
) # correlate them: NB the match position is the MINIMUM
|
||||
corr = -corr # invert the image so we can find a peak
|
||||
corr += (
|
||||
corr.max() - corr.min()
|
||||
) * 0.1 - corr.max() # background-subtract 90% of maximum
|
||||
corr = cv2.threshold(corr, 0, 0, cv2.THRESH_TOZERO)[
|
||||
1] # zero out any negative pixels - but there should always be > 0 nonzero pixels
|
||||
assert np.sum(corr) > 0, "Error: the correlation image doesn't have any nonzero pixels."
|
||||
peak = ndimage.measurements.center_of_mass(corr) # take the centroid (NB this is of grayscale values, not binary)
|
||||
pos = np.array(peak) + image_shift + datum_pixel(feature) # return the position of the feature's datum point.
|
||||
1
|
||||
] # zero out any negative pixels - but there should always be > 0 nonzero pixels
|
||||
assert (
|
||||
np.sum(corr) > 0
|
||||
), "Error: the correlation image doesn't have any nonzero pixels."
|
||||
peak = ndimage.measurements.center_of_mass(
|
||||
corr
|
||||
) # take the centroid (NB this is of grayscale values, not binary)
|
||||
pos = (
|
||||
np.array(peak) + image_shift + datum_pixel(feature)
|
||||
) # return the position of the feature's datum point.
|
||||
return pos
|
||||
|
||||
class Tracker():
|
||||
|
||||
class Tracker:
|
||||
def __init__(self, grab_image, get_position, settle=None):
|
||||
"""A class to manage moving the stage and following motion in the image
|
||||
|
||||
|
|
@ -98,11 +120,11 @@ class Tracker():
|
|||
self.margin = np.array([0, 0])
|
||||
self._template_position = np.array([0.0, 0.0])
|
||||
self.image_shape = None
|
||||
|
||||
|
||||
def get_position(self):
|
||||
"""Get the position of the stage"""
|
||||
return np.array(self._get_position())
|
||||
|
||||
|
||||
def settle(self):
|
||||
"""Wait a short time and discard an image so the stage is no longer wobbling."""
|
||||
if self._settle is not None:
|
||||
|
|
@ -110,7 +132,7 @@ class Tracker():
|
|||
else:
|
||||
time.sleep(0.3)
|
||||
self._grab_image()
|
||||
|
||||
|
||||
@property
|
||||
def template(self):
|
||||
"""The template image (should be a numpy array)"""
|
||||
|
|
@ -118,12 +140,14 @@ class Tracker():
|
|||
raise ValueError("Attempt to use the tracker before setting the template")
|
||||
else:
|
||||
return self._template
|
||||
|
||||
|
||||
@template.setter
|
||||
def template(self, new_value):
|
||||
self._template = new_value
|
||||
|
||||
def acquire_template(self, settle=True, reset_history=True, relative_positions=True):
|
||||
|
||||
def acquire_template(
|
||||
self, settle=True, reset_history=True, relative_positions=True
|
||||
):
|
||||
"""Take a new image, and use it as the template. NB this records the initial point.
|
||||
|
||||
We will wait for the stage to settle, then acquire a new image to use as the template.
|
||||
|
|
@ -151,26 +175,32 @@ class Tracker():
|
|||
self.margin = np.array(image.shape)[:2] - np.array(self.template.shape)[:2]
|
||||
if reset_history:
|
||||
self.reset_history()
|
||||
self._template_position = np.array([0., 0.])
|
||||
self._template_position = np.array([0.0, 0.0])
|
||||
if relative_positions:
|
||||
self._template_position = self.track_image(image) # Position should be zero initially
|
||||
self._template_position = self.track_image(
|
||||
image
|
||||
) # Position should be zero initially
|
||||
self.append_point(settle=False)
|
||||
|
||||
|
||||
@property
|
||||
def max_displacement(self):
|
||||
"""The highest position values that can be tracked"""
|
||||
return self.margin // 2 # TODO: be cleverer about non-trivial values of template_position
|
||||
|
||||
return (
|
||||
self.margin // 2
|
||||
) # TODO: be cleverer about non-trivial values of template_position
|
||||
|
||||
@property
|
||||
def min_displacement(self):
|
||||
"""The lowest position values that can be tracked"""
|
||||
return -self.max_displacement # TODO: be cleverer about non-trivial template_position values
|
||||
|
||||
return (
|
||||
-self.max_displacement
|
||||
) # TODO: be cleverer about non-trivial template_position values
|
||||
|
||||
@property
|
||||
def max_safe_displacement(self):
|
||||
"""The biggest displacement we can safely attempt to track without knowing direction."""
|
||||
return np.min(np.concatenate([self.max_displacement, -self.min_displacement]))
|
||||
|
||||
|
||||
def track_image(self, image):
|
||||
"""Find the position of the image relative to the template
|
||||
|
||||
|
|
@ -182,8 +212,8 @@ class Tracker():
|
|||
a minus sign in front of `locate_feature_in_image` in the source
|
||||
code.
|
||||
"""
|
||||
return - locate_feature_in_image(image, self.template) - self._template_position
|
||||
|
||||
return -locate_feature_in_image(image, self.template) - self._template_position
|
||||
|
||||
def append_point(self, settle=True, image=None):
|
||||
"""Find the current position using both stage and image, and append it"""
|
||||
if settle:
|
||||
|
|
@ -195,22 +225,22 @@ class Tracker():
|
|||
self._image_positions.append(image_pos)
|
||||
self._stage_positions.append(stage_pos)
|
||||
return stage_pos, image_pos
|
||||
|
||||
|
||||
@property
|
||||
def stage_positions(self):
|
||||
"""An array of positions we have moved the stage to"""
|
||||
return np.array(self._stage_positions)
|
||||
|
||||
|
||||
@property
|
||||
def image_positions(self):
|
||||
"""An array of positions we have moved the stage to"""
|
||||
return np.array(self._image_positions)
|
||||
|
||||
|
||||
@property
|
||||
def history(self):
|
||||
"""Return arrays of stage, image positions"""
|
||||
return self.stage_positions, self.image_positions
|
||||
|
||||
|
||||
def reset_history(self, leave_first_point=False):
|
||||
"""Reset the positions and displacements recorded"""
|
||||
if leave_first_point:
|
||||
|
|
@ -232,10 +262,17 @@ class Tracker():
|
|||
if len(self.image_positions) < 2:
|
||||
return None
|
||||
else:
|
||||
return norm(self.image_positions[-1,:]) > norm(self.image_positions[-2])
|
||||
|
||||
|
||||
def move_until_motion_detected(tracker, move, displacement, threshold=10, multipliers=2**np.arange(16), detect_cumulative_motion=False):
|
||||
return norm(self.image_positions[-1, :]) > norm(self.image_positions[-2])
|
||||
|
||||
|
||||
def move_until_motion_detected(
|
||||
tracker,
|
||||
move,
|
||||
displacement,
|
||||
threshold=10,
|
||||
multipliers=2 ** np.arange(16),
|
||||
detect_cumulative_motion=False,
|
||||
):
|
||||
"""Move the stage until we can detect motion in the camera.
|
||||
|
||||
We move the stage in the direction given by ``displacement`` until the
|
||||
|
|
@ -259,14 +296,21 @@ def move_until_motion_detected(tracker, move, displacement, threshold=10, multip
|
|||
`displacement * m`.
|
||||
"""
|
||||
displacement = np.array(displacement)
|
||||
starting_image_position = tracker.image_positions[0 if detect_cumulative_motion else -1, :]
|
||||
starting_image_position = tracker.image_positions[
|
||||
0 if detect_cumulative_motion else -1, :
|
||||
]
|
||||
starting_stage_position = tracker.stage_positions[-1, :]
|
||||
for i, m in enumerate(multipliers):
|
||||
move(starting_stage_position + displacement * m)
|
||||
tracker.append_point()
|
||||
if norm(tracker.image_positions[-1, :] - starting_image_position) >= threshold:
|
||||
return i + 1, m
|
||||
raise Exception("Moved the stage by {} but saw no motion.".format(multipliers[-1] * displacement))
|
||||
raise Exception(
|
||||
"Moved the stage by {} but saw no motion.".format(
|
||||
multipliers[-1] * displacement
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def concatenate_tracker_histories(histories):
|
||||
"""Combine a number of separate tracker history entries into one
|
||||
|
|
@ -286,4 +330,3 @@ def concatenate_tracker_histories(histories):
|
|||
"""
|
||||
components = zip(*histories)
|
||||
return tuple(np.concatenate(c, axis=1) for c in components)
|
||||
|
||||
|
|
@ -6,7 +6,12 @@ This file contains the HTTP API for camera/stage calibration.
|
|||
from labthings.server.view import View
|
||||
from labthings.server.find import find_component
|
||||
from labthings.server.extensions import BaseExtension
|
||||
from labthings.server.decorators import marshal_task, ThingAction, use_args, ThingProperty
|
||||
from labthings.server.decorators import (
|
||||
marshal_task,
|
||||
ThingAction,
|
||||
use_args,
|
||||
ThingProperty,
|
||||
)
|
||||
from labthings.server import fields
|
||||
|
||||
from labthings.core.tasks import taskify
|
||||
|
|
@ -23,7 +28,10 @@ import io
|
|||
import os
|
||||
import json
|
||||
|
||||
from .camera_stage_calibration_1d import calibrate_backlash_1d, image_to_stage_displacement_from_1d
|
||||
from .camera_stage_calibration_1d import (
|
||||
calibrate_backlash_1d,
|
||||
image_to_stage_displacement_from_1d,
|
||||
)
|
||||
from .camera_stage_tracker import Tracker
|
||||
|
||||
from openflexure_microscope.utilities import axes_to_array
|
||||
|
|
@ -33,15 +41,15 @@ from openflexure_microscope.config import JSONEncoder
|
|||
CSM_DATAFILE_NAME = "csm_calibration.json"
|
||||
CSM_DATAFILE_PATH = data_file_path(CSM_DATAFILE_NAME)
|
||||
|
||||
|
||||
class CSMExtension(BaseExtension):
|
||||
"""
|
||||
Use the camera as an encoder, so we can relate camera and stage coordinates
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
BaseExtension.__init__(
|
||||
self,
|
||||
"org.openflexure.camera_stage_mapping",
|
||||
version="0.0.1",
|
||||
self, "org.openflexure.camera_stage_mapping", version="0.0.1"
|
||||
)
|
||||
|
||||
_microscope = None
|
||||
|
|
@ -52,10 +60,10 @@ class CSMExtension(BaseExtension):
|
|||
if self._microscope is None:
|
||||
self._microscope = find_component("org.openflexure.microscope")
|
||||
return self._microscope
|
||||
|
||||
|
||||
def update_settings(self, settings):
|
||||
"""Update the stored extension settings dictionary"""
|
||||
keys = ["extensions",self.name]
|
||||
keys = ["extensions", self.name]
|
||||
dictionary = create_from_path(keys)
|
||||
set_by_path(dictionary, keys, settings)
|
||||
logging.info(f"Updating settings with {dictionary}")
|
||||
|
|
@ -64,20 +72,20 @@ class CSMExtension(BaseExtension):
|
|||
|
||||
def get_settings(self):
|
||||
"""Retrieve the settings for this extension"""
|
||||
keys = ["extensions",self.name]
|
||||
keys = ["extensions", self.name]
|
||||
return get_by_path(self.microscope.read_settings(), keys)
|
||||
|
||||
def camera_stage_functions(self):
|
||||
"""Return functions that allow us to interface with the microscope"""
|
||||
self.microscope.camera.start_worker() # ensure the worker thread is running, so there is an MJPEG stream
|
||||
|
||||
self.microscope.camera.start_worker() # ensure the worker thread is running, so there is an MJPEG stream
|
||||
|
||||
def grab_image():
|
||||
jpeg = self.microscope.camera.get_frame()
|
||||
return np.array(PIL.Image.open(io.BytesIO(jpeg)))
|
||||
|
||||
|
||||
def get_position():
|
||||
return self.microscope.stage.position
|
||||
|
||||
|
||||
move = self.microscope.stage.move_abs
|
||||
|
||||
return grab_image, get_position, move
|
||||
|
|
@ -96,10 +104,10 @@ class CSMExtension(BaseExtension):
|
|||
def calibrate_xy(self):
|
||||
"""Move the microscope's stage in X and Y, to calibrate its relationship to the camera"""
|
||||
logging.info("Calibrating X axis:")
|
||||
cal_x = self.calibrate_1d(np.array([1,0,0]))
|
||||
cal_x = self.calibrate_1d(np.array([1, 0, 0]))
|
||||
logging.info("Calibrating Y axis:")
|
||||
cal_y = self.calibrate_1d(np.array([0,1,0]))
|
||||
|
||||
cal_y = self.calibrate_1d(np.array([0, 1, 0]))
|
||||
|
||||
# Combine X and Y calibrations to make a 2D calibration
|
||||
cal_xy = image_to_stage_displacement_from_1d([cal_x, cal_y])
|
||||
self.update_settings(cal_xy)
|
||||
|
|
@ -110,7 +118,7 @@ class CSMExtension(BaseExtension):
|
|||
"linear_calibration_y": cal_y,
|
||||
}
|
||||
|
||||
with open(CSM_DATAFILE_PATH, 'w') as f:
|
||||
with open(CSM_DATAFILE_PATH, "w") as f:
|
||||
json.dump(data, f, cls=JSONEncoder)
|
||||
|
||||
return data
|
||||
|
|
@ -130,27 +138,28 @@ class CSMExtension(BaseExtension):
|
|||
self.microscope.stage.move_rel([relative_move[0], relative_move[1], 0])
|
||||
|
||||
|
||||
|
||||
|
||||
csm_extension = CSMExtension()
|
||||
|
||||
|
||||
@ThingAction
|
||||
class Calibrate1DView(View):
|
||||
@use_args({
|
||||
"direction": fields.List(fields.Float(), required=True, example=[1,0,0])
|
||||
})
|
||||
@use_args(
|
||||
{"direction": fields.List(fields.Float(), required=True, example=[1, 0, 0])}
|
||||
)
|
||||
@marshal_task
|
||||
def post(self, args):
|
||||
"""Calibrate one axis of the microscope stage against the camera."""
|
||||
|
||||
|
||||
direction = np.array(args.get("direction"))
|
||||
|
||||
task = taskify(csm_extension.calibrate_1d)(direction)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
csm_extension.add_view(Calibrate1DView, "/calibrate_1d")
|
||||
|
||||
|
||||
@ThingAction
|
||||
class CalibrateXYView(View):
|
||||
@marshal_task
|
||||
|
|
@ -160,24 +169,39 @@ class CalibrateXYView(View):
|
|||
|
||||
return task
|
||||
|
||||
|
||||
csm_extension.add_view(CalibrateXYView, "/calibrate_xy")
|
||||
|
||||
|
||||
@ThingAction
|
||||
class MoveInImageCoordinatesView(View):
|
||||
@use_args({
|
||||
"x": fields.Float(description="The number of pixels to move in X", required=True, example=100),
|
||||
"y": fields.Float(description="The number of pixels to move in Y", required=True, example=100),
|
||||
})
|
||||
@use_args(
|
||||
{
|
||||
"x": fields.Float(
|
||||
description="The number of pixels to move in X",
|
||||
required=True,
|
||||
example=100,
|
||||
),
|
||||
"y": fields.Float(
|
||||
description="The number of pixels to move in Y",
|
||||
required=True,
|
||||
example=100,
|
||||
),
|
||||
}
|
||||
)
|
||||
def post(self, args):
|
||||
logging.debug("moving in pixels")
|
||||
"""Move the microscope stage, such that we move by a given number of pixels on the camera"""
|
||||
csm_extension.move_in_image_coordinates(np.array([args.get("x"), args.get("y")]))
|
||||
|
||||
csm_extension.move_in_image_coordinates(
|
||||
np.array([args.get("x"), args.get("y")])
|
||||
)
|
||||
|
||||
return csm_extension.microscope.state["stage"]["position"]
|
||||
|
||||
|
||||
csm_extension.add_view(MoveInImageCoordinatesView, "/move_in_image_coordinates")
|
||||
|
||||
|
||||
@ThingProperty
|
||||
class GetCalibrationFile(View):
|
||||
def get(self):
|
||||
|
|
@ -186,9 +210,10 @@ class GetCalibrationFile(View):
|
|||
datafile_path = CSM_DATAFILE_PATH
|
||||
|
||||
if os.path.isfile(datafile_path):
|
||||
with open(datafile_path, 'rb') as f:
|
||||
with open(datafile_path, "rb") as f:
|
||||
return json.load(f)
|
||||
else:
|
||||
return {}
|
||||
|
||||
csm_extension.add_view(GetCalibrationFile, "/get_calibration")
|
||||
|
||||
csm_extension.add_view(GetCalibrationFile, "/get_calibration")
|
||||
|
|
|
|||
|
|
@ -38,9 +38,11 @@ from past.utils import old_div
|
|||
import numpy as np
|
||||
from array_with_attrs import ArrayWithAttrs, ensure_attrs
|
||||
import cv2
|
||||
#import cv2.cv
|
||||
|
||||
# import cv2.cv
|
||||
from scipy import ndimage
|
||||
|
||||
|
||||
class ImageWithLocation(ArrayWithAttrs):
|
||||
"""An image, as a numpy array, with attributes to provide location information
|
||||
|
||||
|
|
@ -49,9 +51,10 @@ class ImageWithLocation(ArrayWithAttrs):
|
|||
that we use to store the crucial mapping from pixels in the image to position in the
|
||||
sample.
|
||||
"""
|
||||
# def __array_finalize__(self, obj):
|
||||
# """Ensure that the object is a properly set-up ImageWithLocation"""
|
||||
# ArrayWithAttrs.__array_finalize__(self, obj) # Ensure we have self.attrs
|
||||
|
||||
# def __array_finalize__(self, obj):
|
||||
# """Ensure that the object is a properly set-up ImageWithLocation"""
|
||||
# ArrayWithAttrs.__array_finalize__(self, obj) # Ensure we have self.attrs
|
||||
def __getitem__(self, item):
|
||||
"""Update the metadata when we extract a slice"""
|
||||
try:
|
||||
|
|
@ -61,18 +64,24 @@ class ImageWithLocation(ArrayWithAttrs):
|
|||
assert isinstance(item[0], slice), "First index was not a slice"
|
||||
assert isinstance(item[1], slice), "Second index was not a slice"
|
||||
start = np.array([item[i].start for i in range(2)])
|
||||
start = np.where(start == np.array(None), 0, start) # missing start points are equivalent to zero
|
||||
start = np.where(
|
||||
start == np.array(None), 0, start
|
||||
) # missing start points are equivalent to zero
|
||||
step = np.array([item[i].step for i in range(2)])
|
||||
step = np.where(step == np.array(None), 1, step) # missing step is equivalent to step==1
|
||||
step = np.where(
|
||||
step == np.array(None), 1, step
|
||||
) # missing step is equivalent to step==1
|
||||
except:
|
||||
# If the above doesn't work, assume we're not dealing with a 2D slice and give up.
|
||||
return super(ImageWithLocation, self).__getitem__(item) # pass it on up
|
||||
return super(ImageWithLocation, self).__getitem__(item) # pass it on up
|
||||
|
||||
out = super(ImageWithLocation, self).__getitem__(item) # retrieve the slice
|
||||
out.datum_pixel -= start # adjust the datum pixel so it refers to the same part of the image
|
||||
out = super(ImageWithLocation, self).__getitem__(item) # retrieve the slice
|
||||
out.datum_pixel -= (
|
||||
start
|
||||
) # adjust the datum pixel so it refers to the same part of the image
|
||||
# Next, we adjust the constant part of the pixel-sample matrix so pixels stay in the same place
|
||||
location_shift = np.dot(ensure_3d(start), self.pixel_to_sample_matrix[:3,:3])
|
||||
out.pixel_to_sample_matrix[3,:3] += location_shift
|
||||
location_shift = np.dot(ensure_3d(start), self.pixel_to_sample_matrix[:3, :3])
|
||||
out.pixel_to_sample_matrix[3, :3] += location_shift
|
||||
if not np.all(step == 1):
|
||||
# if we're downsampling, remember to scale datum_pixel accordingly
|
||||
out.datum_pixel = old_div(out.datum_pixel, step)
|
||||
|
|
@ -105,18 +114,22 @@ class ImageWithLocation(ArrayWithAttrs):
|
|||
A 2- or 3- element position, to match the size of location passed in.
|
||||
"""
|
||||
l = ensure_2d(location)
|
||||
l = l[:2]-self.pixel_to_sample_matrix[3,:2]
|
||||
p = np.dot(l, np.linalg.inv(self.pixel_to_sample_matrix[:2,:2]))
|
||||
l = l[:2] - self.pixel_to_sample_matrix[3, :2]
|
||||
p = np.dot(l, np.linalg.inv(self.pixel_to_sample_matrix[:2, :2]))
|
||||
if check_bounds:
|
||||
assert np.all(0 <= p[0:2]), "The location was not within the image"
|
||||
assert np.all(p[0:2] <= self.shape[0:2]), "The location was not within the image"
|
||||
assert np.abs(p[2]) < z_tolerance, "The location was too far away from the plane of the image"
|
||||
assert np.all(
|
||||
p[0:2] <= self.shape[0:2]
|
||||
), "The location was not within the image"
|
||||
assert (
|
||||
np.abs(p[2]) < z_tolerance
|
||||
), "The location was too far away from the plane of the image"
|
||||
if len(location) == 2:
|
||||
return p[:2]
|
||||
else:
|
||||
return p[:3]
|
||||
|
||||
def feature_at(self, centre_position, size=(100,100), set_datum_to_centre=True):
|
||||
def feature_at(self, centre_position, size=(100, 100), set_datum_to_centre=True):
|
||||
"""Return a thumbnail cropped out of this image, centred on a particular pixel position.
|
||||
|
||||
This is simply a convenience method that saves typing over the usual slice syntax. Below are two equivalent
|
||||
|
|
@ -139,14 +152,25 @@ class ImageWithLocation(ArrayWithAttrs):
|
|||
float(size[0])
|
||||
float(size[1])
|
||||
except:
|
||||
raise IndexError("Error: arguments of feature_at were invalid: {}, {}".format(centre_position, size))
|
||||
raise IndexError(
|
||||
"Error: arguments of feature_at were invalid: {}, {}".format(
|
||||
centre_position, size
|
||||
)
|
||||
)
|
||||
pos = centre_position
|
||||
|
||||
# For now, rely on numpy to complain if the feature is outside the image. May do bound-checking at some point.
|
||||
# If so, we might need to think carefully about the datum pixel of the resulting image.
|
||||
thumb = self[pos[0] - old_div(size[0],2):pos[0] + old_div(size[0],2), pos[1] - old_div(size[1],2):pos[1] + old_div(size[1],2), ...]
|
||||
thumb = self[
|
||||
pos[0] - old_div(size[0], 2) : pos[0] + old_div(size[0], 2),
|
||||
pos[1] - old_div(size[1], 2) : pos[1] + old_div(size[1], 2),
|
||||
...,
|
||||
]
|
||||
if set_datum_to_centre:
|
||||
thumb.datum_pixel = (old_div(size[0],2), old_div(size[1],2)) # Make the datum point of the new image its centre.
|
||||
thumb.datum_pixel = (
|
||||
old_div(size[0], 2),
|
||||
old_div(size[1], 2),
|
||||
) # Make the datum point of the new image its centre.
|
||||
return thumb
|
||||
|
||||
def downsample(self, n):
|
||||
|
|
@ -156,7 +180,9 @@ class ImageWithLocation(ArrayWithAttrs):
|
|||
to noise. Currently it just decimates (i.e. throws away rows and columns).
|
||||
"""
|
||||
assert n > 0, "The downsampling factor must be an integer greater than 0"
|
||||
return self[::int(n), ::int(n), ...] # The slicing code handles updating metadata
|
||||
return self[
|
||||
:: int(n), :: int(n), ...
|
||||
] # The slicing code handles updating metadata
|
||||
|
||||
@property
|
||||
def datum_pixel(self):
|
||||
|
|
@ -165,14 +191,16 @@ class ImageWithLocation(ArrayWithAttrs):
|
|||
Usually the datum pixel is the central pixel, and if the metadata required is not present,
|
||||
we will silently assume that this is the case.
|
||||
"""
|
||||
datum = self.attrs.get('datum_pixel', old_div((np.array(self.shape[:2]) - 1),2))
|
||||
datum = self.attrs.get(
|
||||
"datum_pixel", old_div((np.array(self.shape[:2]) - 1), 2)
|
||||
)
|
||||
assert len(datum) == 2, "The datum pixel didn't have length 2!"
|
||||
return datum
|
||||
|
||||
@datum_pixel.setter
|
||||
def datum_pixel(self, datum):
|
||||
assert len(datum) == 2, "The datum pixel didn't have length 2!"
|
||||
self.attrs['datum_pixel'] = datum
|
||||
self.attrs["datum_pixel"] = datum
|
||||
|
||||
@property
|
||||
def datum_location(self):
|
||||
|
|
@ -186,34 +214,36 @@ class ImageWithLocation(ArrayWithAttrs):
|
|||
np.dot(p, M) yields a location for the given pixel, where p is [x,y,0,1] and M is this matrix. The location
|
||||
given will be 4 elements long, and will have 1 as the final element.
|
||||
"""
|
||||
M = self.attrs['pixel_to_sample_matrix']
|
||||
M = self.attrs["pixel_to_sample_matrix"]
|
||||
assert M.shape == (4, 4), "The pixel-to-sample matrix is the wrong shape!"
|
||||
assert M.dtype.kind == "f", "The pixel-to-sample matrix is not floating point!"
|
||||
return M
|
||||
|
||||
@pixel_to_sample_matrix.setter
|
||||
def pixel_to_sample_matrix(self, M):
|
||||
M = np.asanyarray(M) #ensure it's an ndarray subclass
|
||||
M = np.asanyarray(M) # ensure it's an ndarray subclass
|
||||
assert M.shape == (4, 4), "The pixel-to-sample matrix must be 4x4!"
|
||||
assert M.dtype.kind == "f", "The pixel-to-sample matrix must be floating point!"
|
||||
self.attrs['pixel_to_sample_matrix'] = M
|
||||
self.attrs["pixel_to_sample_matrix"] = M
|
||||
|
||||
# TODO: split the data type out of this module and put it somewhere sensible
|
||||
|
||||
#TODO: split the data type out of this module and put it somewhere sensible
|
||||
|
||||
def add_location_metadata(image, pixel_to_sample_matrix, datum_pixel=None):
|
||||
"""Wrap an image if needed, and set its pixel to sample matrix."""
|
||||
awa = ensure_attrs(image) # if needed, convert the image to an ArrayWithAttrs
|
||||
awa.attrs['pixel_to_sample_matrix'] = pixel_to_sample_matrix
|
||||
awa = ensure_attrs(image) # if needed, convert the image to an ArrayWithAttrs
|
||||
awa.attrs["pixel_to_sample_matrix"] = pixel_to_sample_matrix
|
||||
if datum_pixel is not None:
|
||||
awa.attrs['datum_pixel'] = datum_pixel
|
||||
awa.attrs["datum_pixel"] = datum_pixel
|
||||
return awa
|
||||
|
||||
|
||||
def datum_pixel(image):
|
||||
"""Get the datum pixel of an image - if no property is present, assume the central pixel."""
|
||||
try:
|
||||
return np.array(image.datum_pixel)
|
||||
except:
|
||||
return (np.array(image.shape[:2]) - 1) / 2.
|
||||
return (np.array(image.shape[:2]) - 1) / 2.0
|
||||
|
||||
|
||||
def ensure_3d(vector):
|
||||
|
|
@ -223,7 +253,9 @@ def ensure_3d(vector):
|
|||
elif len(vector) == 2:
|
||||
return np.array([vector[0], vector[1], 0])
|
||||
else:
|
||||
raise ValueError("Tried to ensure a vector was 3D, but it had neither 2 nor 3 elements!")
|
||||
raise ValueError(
|
||||
"Tried to ensure a vector was 3D, but it had neither 2 nor 3 elements!"
|
||||
)
|
||||
|
||||
|
||||
def ensure_2d(vector):
|
||||
|
|
@ -233,5 +265,6 @@ def ensure_2d(vector):
|
|||
elif len(vector) == 3:
|
||||
return np.array(vector[:2])
|
||||
else:
|
||||
raise ValueError("Tried to ensure a vector was 2D, but it had neither 2 nor 3 elements!")
|
||||
|
||||
raise ValueError(
|
||||
"Tried to ensure a vector was 2D, but it had neither 2 nor 3 elements!"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@ import logging
|
|||
# Type hinting
|
||||
from typing import Tuple
|
||||
|
||||
from .recalibrate_utils import recalibrate_camera, auto_expose_and_freeze_settings, flat_lens_shading_table
|
||||
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):
|
||||
|
|
@ -23,7 +28,9 @@ def pause_stream(scamera, resolution: Tuple[int, int] = None):
|
|||
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."
|
||||
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:
|
||||
|
|
@ -37,6 +44,7 @@ def pause_stream(scamera, resolution: Tuple[int, int] = None):
|
|||
logging.info("Restarting stream in pause_stream context manager")
|
||||
scamera.start_stream_recording()
|
||||
|
||||
|
||||
def recalibrate(microscope):
|
||||
"""Reset the camera's settings.
|
||||
|
||||
|
|
@ -45,7 +53,9 @@ def recalibrate(microscope):
|
|||
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
|
||||
auto_expose_and_freeze_settings(
|
||||
scamera.camera
|
||||
) # scamera.camera is the PiCamera object
|
||||
recalibrate_camera(scamera.camera)
|
||||
microscope.save_settings()
|
||||
|
||||
|
|
@ -63,13 +73,17 @@ 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.")
|
||||
abort(
|
||||
503,
|
||||
"No microscope connected. Unable to flatten the lens shading table.",
|
||||
)
|
||||
|
||||
try:
|
||||
with pause_stream(microscope.camera) as scamera:
|
||||
|
|
@ -78,7 +92,11 @@ class FlattenLSTView(View):
|
|||
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?")
|
||||
abort(
|
||||
503,
|
||||
"Couldn't flatten the lens shading table - do you have the forked PiCamera library installed?",
|
||||
)
|
||||
|
||||
|
||||
@ThingAction
|
||||
class DeleteLSTView(View):
|
||||
|
|
@ -86,7 +104,10 @@ class DeleteLSTView(View):
|
|||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
if not microscope:
|
||||
abort(503, "No microscope connected. Unable to flatten the lens shading table.")
|
||||
abort(
|
||||
503,
|
||||
"No microscope connected. Unable to flatten the lens shading table.",
|
||||
)
|
||||
|
||||
try:
|
||||
with pause_stream(microscope.camera) as scamera:
|
||||
|
|
@ -94,11 +115,16 @@ class DeleteLSTView(View):
|
|||
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?")
|
||||
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."
|
||||
"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(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import datetime
|
|||
from typing import Tuple
|
||||
from functools import reduce
|
||||
|
||||
from openflexure_microscope.camera.base import generate_basename
|
||||
from openflexure_microscope.captures.capture_manager import generate_basename
|
||||
from labthings.server.find import find_component, find_extension
|
||||
from labthings.server.extensions import BaseExtension
|
||||
from labthings.server.decorators import marshal_task, use_args, ThingAction
|
||||
|
|
@ -85,27 +85,19 @@ def capture(
|
|||
filename = "{}_{}_{}_{}".format(basename, *microscope.stage.position)
|
||||
folder = "SCAN_{}".format(basename)
|
||||
|
||||
# Create output object
|
||||
output = microscope.camera.new_image(
|
||||
temporary=temporary, filename=filename, folder=folder
|
||||
# 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,
|
||||
)
|
||||
|
||||
# Capture
|
||||
microscope.camera.capture(
|
||||
output.file, use_video_port=use_video_port, resize=resize, bayer=bayer
|
||||
)
|
||||
|
||||
# Inject system metadata
|
||||
output.put_metadata({"instrument": microscope.metadata})
|
||||
|
||||
# Insert custom metadata
|
||||
output.put_metadata(metadata)
|
||||
|
||||
# Insert custom metadata
|
||||
output.put_annotations(annotations)
|
||||
# Insert custom tags
|
||||
output.put_tags(tags)
|
||||
|
||||
|
||||
### Scanning
|
||||
|
||||
|
|
@ -199,7 +191,7 @@ def tile(
|
|||
# Run slow autofocus. Client should provide dz ~ 50
|
||||
autofocus_extension.autofocus(
|
||||
microscope,
|
||||
range(-3 * autofocus_dz, 4 * autofocus_dz, autofocus_dz)
|
||||
range(-3 * autofocus_dz, 4 * autofocus_dz, autofocus_dz),
|
||||
)
|
||||
logging.debug("Finished autofocus")
|
||||
time.sleep(1)
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ class ZipManager:
|
|||
|
||||
# Get array of captures from IDs
|
||||
capture_list = [
|
||||
microscope.camera.images.get(capture_id) for capture_id in capture_id_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]
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,6 @@ default_microscope = Microscope()
|
|||
|
||||
# Restore loaded capture array to camera object
|
||||
logging.debug("Restoring captures...")
|
||||
default_microscope.camera.rebuild_captures()
|
||||
default_microscope.captures.rebuild_captures()
|
||||
|
||||
logging.debug("Microscope successfully attached!")
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ def enabled_root_actions():
|
|||
return {k: v for k, v in _actions.items() if v["conditions"]}
|
||||
|
||||
|
||||
#@Tag("actions")
|
||||
# @Tag("actions")
|
||||
class ActionsView(View):
|
||||
def get(self):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -63,26 +63,16 @@ class CaptureAPI(View):
|
|||
|
||||
# Explicitally acquire lock (prevents empty files being created if lock is unavailable)
|
||||
with microscope.camera.lock:
|
||||
output = microscope.camera.new_image(
|
||||
temporary=args.get("temporary"), filename=args.get("filename")
|
||||
)
|
||||
|
||||
microscope.camera.capture(
|
||||
output.file,
|
||||
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")
|
||||
)
|
||||
|
||||
# Inject system metadata
|
||||
output.put_metadata({"instrument": microscope.metadata})
|
||||
|
||||
# Insert custom metadata
|
||||
output.put_annotations(args.get("annotations"))
|
||||
# Insert custom tags
|
||||
output.put_tags(args.get("tags"))
|
||||
|
||||
return output
|
||||
|
||||
|
||||
@ThingAction
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ class CaptureList(View):
|
|||
List all image captures
|
||||
"""
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
image_list = microscope.camera.images.values()
|
||||
image_list = microscope.captures.images.values()
|
||||
return image_list
|
||||
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ class CaptureView(View):
|
|||
Description of a single image capture
|
||||
"""
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
capture_obj = microscope.camera.images.get(id)
|
||||
capture_obj = microscope.captures.images.get(id)
|
||||
|
||||
if not capture_obj:
|
||||
return abort(404) # 404 Not Found
|
||||
|
|
@ -124,7 +124,7 @@ class CaptureView(View):
|
|||
Delete a single image capture
|
||||
"""
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
capture_obj = microscope.camera.images.get(id)
|
||||
capture_obj = microscope.captures.images.get(id)
|
||||
|
||||
if not capture_obj:
|
||||
return abort(404) # 404 Not Found
|
||||
|
|
@ -132,7 +132,7 @@ class CaptureView(View):
|
|||
# Delete the capture file
|
||||
capture_obj.delete()
|
||||
# Delete from capture list
|
||||
del microscope.camera.images[id]
|
||||
del microscope.captures.images[id]
|
||||
|
||||
return "", 204
|
||||
|
||||
|
|
@ -145,7 +145,7 @@ class CaptureDownload(View):
|
|||
Image data for a single image capture
|
||||
"""
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
capture_obj = microscope.camera.images.get(id)
|
||||
capture_obj = microscope.captures.images.get(id)
|
||||
|
||||
if not capture_obj:
|
||||
return abort(404) # 404 Not Found
|
||||
|
|
@ -180,7 +180,7 @@ class CaptureTags(View):
|
|||
Get tags associated with a single image capture
|
||||
"""
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
capture_obj = microscope.camera.images.get(id)
|
||||
capture_obj = microscope.captures.images.get(id)
|
||||
|
||||
if not capture_obj:
|
||||
return abort(404) # 404 Not Found
|
||||
|
|
@ -192,7 +192,7 @@ class CaptureTags(View):
|
|||
Add tags to a single image capture
|
||||
"""
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
capture_obj = microscope.camera.images.get(id)
|
||||
capture_obj = microscope.captures.images.get(id)
|
||||
|
||||
if not capture_obj:
|
||||
return abort(404) # 404 Not Found
|
||||
|
|
@ -212,7 +212,7 @@ class CaptureTags(View):
|
|||
Delete tags from a single image capture
|
||||
"""
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
capture_obj = microscope.camera.images.get(id)
|
||||
capture_obj = microscope.captures.images.get(id)
|
||||
|
||||
if not capture_obj:
|
||||
return abort(404) # 404 Not Found
|
||||
|
|
@ -235,7 +235,7 @@ class CaptureAnnotations(View):
|
|||
Get annotations associated with a single image capture
|
||||
"""
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
capture_obj = microscope.camera.images.get(id)
|
||||
capture_obj = microscope.captures.images.get(id)
|
||||
|
||||
if not capture_obj:
|
||||
return abort(404) # 404 Not Found
|
||||
|
|
@ -247,7 +247,7 @@ class CaptureAnnotations(View):
|
|||
Update metadata for a single image capture
|
||||
"""
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
capture_obj = microscope.camera.images.get(id)
|
||||
capture_obj = microscope.captures.images.get(id)
|
||||
|
||||
if not capture_obj:
|
||||
return abort(404) # 404 Not Found
|
||||
|
|
|
|||
|
|
@ -10,43 +10,11 @@ import threading
|
|||
import gevent
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from collections import OrderedDict
|
||||
|
||||
from .capture import CaptureObject, build_captures_from_exif
|
||||
from openflexure_microscope.utilities import entry_by_uuid
|
||||
|
||||
from labthings.core.lock import StrictLock
|
||||
from labthings.core.event import ClientEvent
|
||||
|
||||
from openflexure_microscope.paths import data_file_path
|
||||
|
||||
BASE_CAPTURE_PATH = data_file_path("micrographs")
|
||||
TEMP_CAPTURE_PATH = os.path.join(BASE_CAPTURE_PATH, "tmp")
|
||||
|
||||
|
||||
def last_entry(object_list: list):
|
||||
"""Return the last entry of a list, if the list contains items."""
|
||||
if object_list: # If any images have been captured
|
||||
return object_list[-1] # Return the latest captured image
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def generate_basename():
|
||||
"""Return a default filename based on the capture datetime"""
|
||||
return datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
|
||||
|
||||
def generate_numbered_basename(obj_list: list) -> str:
|
||||
initial_basename = generate_basename()
|
||||
basename = initial_basename
|
||||
# Handle clashing
|
||||
iterator = 1
|
||||
while basename in [obj.basename for obj in obj_list]:
|
||||
basename = initial_basename + "_{}".format(iterator)
|
||||
iterator += 1
|
||||
|
||||
return basename
|
||||
|
||||
|
||||
class BaseCamera(metaclass=ABCMeta):
|
||||
"""
|
||||
|
|
@ -70,12 +38,6 @@ class BaseCamera(metaclass=ABCMeta):
|
|||
self.stream_active = False
|
||||
self.record_active = False
|
||||
|
||||
self.paths = {"default": BASE_CAPTURE_PATH, "temp": TEMP_CAPTURE_PATH}
|
||||
|
||||
# Capture data
|
||||
self.images = OrderedDict()
|
||||
self.videos = OrderedDict()
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def configuration(self):
|
||||
|
|
@ -104,7 +66,7 @@ class BaseCamera(metaclass=ABCMeta):
|
|||
@abstractmethod
|
||||
def read_settings(self) -> dict:
|
||||
"""Return the current settings as a dictionary"""
|
||||
return {"paths": self.paths}
|
||||
return {}
|
||||
|
||||
def __enter__(self):
|
||||
"""Create camera on context enter."""
|
||||
|
|
@ -117,144 +79,10 @@ class BaseCamera(metaclass=ABCMeta):
|
|||
def close(self):
|
||||
"""Close the BaseCamera and all attached StreamObjects."""
|
||||
logging.info("Closing {}".format(self))
|
||||
# Close all StreamObjects
|
||||
for capture_list in [self.images.values(), self.videos.values()]:
|
||||
for stream_object in capture_list:
|
||||
stream_object.close()
|
||||
# Empty temp directory
|
||||
self.clear_tmp()
|
||||
# Stop worker thread
|
||||
self.stop_worker()
|
||||
logging.info("Closed {}".format(self))
|
||||
|
||||
def clear_tmp(self):
|
||||
"""
|
||||
Removes all files in the temporary capture directories
|
||||
"""
|
||||
|
||||
if os.path.isdir(self.paths["temp"]):
|
||||
logging.info("Clearing {}...".format(self.paths["temp"]))
|
||||
shutil.rmtree(self.paths["temp"])
|
||||
logging.debug("Cleared {}.".format(self.paths["temp"]))
|
||||
|
||||
def rebuild_captures(self):
|
||||
self.images = build_captures_from_exif(self.paths["default"])
|
||||
|
||||
# RETURNING CAPTURES
|
||||
|
||||
@property
|
||||
def image(self):
|
||||
"""Return the latest captured image."""
|
||||
return last_entry(self.images.values())
|
||||
|
||||
@property
|
||||
def video(self):
|
||||
"""Return the latest recorded video."""
|
||||
return last_entry(self.videos.values())
|
||||
|
||||
def image_from_id(self, image_id):
|
||||
"""Return an image StreamObject with a matching ID."""
|
||||
logging.warning("image_from_id is deprecated. Access captures as a dictionary.")
|
||||
return entry_by_uuid(image_id, self.images.values())
|
||||
|
||||
def video_from_id(self, video_id):
|
||||
"""Return a video StreamObject with a matching ID."""
|
||||
logging.warning("video_from_id is deprecated. Access captures as a dictionary.")
|
||||
return entry_by_uuid(video_id, self.videos.values())
|
||||
|
||||
# CREATING NEW CAPTURES
|
||||
|
||||
def new_image(
|
||||
self,
|
||||
temporary: bool = True,
|
||||
filename: str = None,
|
||||
folder: str = "",
|
||||
fmt: str = "jpeg",
|
||||
):
|
||||
|
||||
"""
|
||||
Create a new image capture object.
|
||||
|
||||
Args:
|
||||
temporary (bool): Should the data be deleted after session ends.
|
||||
Creating the capture with a content manager sets this to true.
|
||||
filename (str): Name of the stored file. Defaults to timestamp.
|
||||
folder (str): Name of the folder in which to store the capture.
|
||||
fmt (str): Format of the capture.
|
||||
"""
|
||||
|
||||
# Generate file name
|
||||
if not filename:
|
||||
filename = generate_numbered_basename(self.images.values())
|
||||
logging.debug(filename)
|
||||
filename = "{}.{}".format(filename, fmt)
|
||||
|
||||
# Generate folder
|
||||
base_folder = self.paths["temp"] if temporary else self.paths["default"]
|
||||
folder = os.path.join(base_folder, folder)
|
||||
|
||||
# Generate file path
|
||||
filepath = os.path.join(folder, filename)
|
||||
|
||||
# Create capture object
|
||||
output = CaptureObject(filepath=filepath)
|
||||
# Insert a temporary tag if temporary
|
||||
if temporary:
|
||||
output.put_tags(["temporary"])
|
||||
|
||||
# Update capture list
|
||||
capture_key = str(output.id)
|
||||
logging.debug(f"Adding image {output} with key {capture_key}")
|
||||
self.images[capture_key] = output
|
||||
|
||||
return output
|
||||
|
||||
def new_video(
|
||||
self,
|
||||
temporary: bool = False,
|
||||
filename: str = None,
|
||||
folder: str = "",
|
||||
fmt: str = "h264",
|
||||
):
|
||||
|
||||
"""
|
||||
Create a new video capture object.
|
||||
|
||||
Args:
|
||||
temporary (bool): Should the data be deleted after session ends.
|
||||
Creating the capture with a content manager sets this to true.
|
||||
filename (str): Name of the stored file. Defaults to timestamp.
|
||||
folder (str): Name of the folder in which to store the capture.
|
||||
fmt (str): Format of the capture.
|
||||
"""
|
||||
# TODO: Remove the redundancy here
|
||||
|
||||
# Generate file name
|
||||
if not filename:
|
||||
filename = generate_numbered_basename(self.videos.values())
|
||||
logging.debug(filename)
|
||||
filename = "{}.{}".format(filename, fmt)
|
||||
|
||||
# Generate folder
|
||||
base_folder = self.paths["temp"] if temporary else self.paths["default"]
|
||||
folder = os.path.join(base_folder, folder)
|
||||
|
||||
# Generate file path
|
||||
filepath = os.path.join(folder, filename)
|
||||
|
||||
# Create capture object
|
||||
output = CaptureObject(filepath=filepath)
|
||||
# Insert a temporary tag if temporary
|
||||
if temporary:
|
||||
output.put_tags(["temporary"])
|
||||
|
||||
# Update capture list
|
||||
capture_key = str(output.id)
|
||||
logging.debug(f"Adding video {output} with key {capture_key}")
|
||||
self.videos[capture_key] = output
|
||||
|
||||
return output
|
||||
|
||||
# START AND STOP WORKER THREAD
|
||||
|
||||
def start_worker(self, timeout: int = 5) -> bool:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ import logging
|
|||
# Type hinting
|
||||
from typing import Tuple
|
||||
|
||||
from openflexure_microscope.camera.base import BaseCamera, CaptureObject
|
||||
from openflexure_microscope.camera.base import BaseCamera
|
||||
from openflexure_microscope.captures import CaptureObject
|
||||
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ import picamera.array
|
|||
# Type hinting
|
||||
from typing import Tuple
|
||||
|
||||
from .base import BaseCamera, CaptureObject
|
||||
from openflexure_microscope.camera.base import BaseCamera
|
||||
from openflexure_microscope.captures import CaptureObject
|
||||
|
||||
# Richard's fix gain
|
||||
from .set_picamera_gain import set_analog_gain, set_digital_gain
|
||||
|
|
|
|||
3
openflexure_microscope/captures/__init__.py
Normal file
3
openflexure_microscope/captures/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .capture_manager import CaptureManager
|
||||
from .capture import CaptureObject
|
||||
from . import capture_manager, capture
|
||||
210
openflexure_microscope/captures/capture_manager.py
Normal file
210
openflexure_microscope/captures/capture_manager.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import os
|
||||
import datetime
|
||||
import shutil
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
from labthings.core.lock import StrictLock
|
||||
|
||||
from openflexure_microscope.utilities import entry_by_uuid
|
||||
from openflexure_microscope.paths import data_file_path
|
||||
|
||||
from .capture import CaptureObject, build_captures_from_exif
|
||||
|
||||
BASE_CAPTURE_PATH = data_file_path("micrographs")
|
||||
TEMP_CAPTURE_PATH = os.path.join(BASE_CAPTURE_PATH, "tmp")
|
||||
|
||||
|
||||
def last_entry(object_list: list):
|
||||
"""Return the last entry of a list, if the list contains items."""
|
||||
if object_list: # If any images have been captured
|
||||
return object_list[-1] # Return the latest captured image
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def generate_basename():
|
||||
"""Return a default filename based on the capture datetime"""
|
||||
return datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
|
||||
|
||||
def generate_numbered_basename(obj_list: list) -> str:
|
||||
initial_basename = generate_basename()
|
||||
basename = initial_basename
|
||||
# Handle clashing
|
||||
iterator = 1
|
||||
while basename in [obj.basename for obj in obj_list]:
|
||||
basename = initial_basename + "_{}".format(iterator)
|
||||
iterator += 1
|
||||
|
||||
return basename
|
||||
|
||||
|
||||
class CaptureManager:
|
||||
def __init__(self):
|
||||
self.paths = {"default": BASE_CAPTURE_PATH, "temp": TEMP_CAPTURE_PATH}
|
||||
|
||||
self.lock = StrictLock(timeout=1, name="Captures")
|
||||
|
||||
# Capture data
|
||||
self.images = OrderedDict()
|
||||
self.videos = OrderedDict()
|
||||
|
||||
# FILE MANAGEMENT
|
||||
|
||||
def __enter__(self):
|
||||
"""Create camera on context enter."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
"""Close camera stream on context exit."""
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
logging.info("Closing {}".format(self))
|
||||
# Close all StreamObjects
|
||||
for capture_list in [self.images.values(), self.videos.values()]:
|
||||
for stream_object in capture_list:
|
||||
stream_object.close()
|
||||
# Empty temp directory
|
||||
self.clear_tmp()
|
||||
|
||||
def clear_tmp(self):
|
||||
"""
|
||||
Removes all files in the temporary capture directories
|
||||
"""
|
||||
|
||||
if os.path.isdir(self.paths["temp"]):
|
||||
logging.info("Clearing {}...".format(self.paths["temp"]))
|
||||
shutil.rmtree(self.paths["temp"])
|
||||
logging.debug("Cleared {}.".format(self.paths["temp"]))
|
||||
|
||||
def rebuild_captures(self):
|
||||
self.images = build_captures_from_exif(self.paths["default"])
|
||||
|
||||
def update_settings(self, config: dict):
|
||||
"""Update settings from a config dictionary"""
|
||||
with self.lock:
|
||||
# Apply valid config params to camera object
|
||||
for key, value in config.items(): # For each provided setting
|
||||
if hasattr(self, key): # If the instance has a matching property
|
||||
setattr(self, key, value) # Set to the target value
|
||||
|
||||
def read_settings(self) -> dict:
|
||||
"""Return the current settings as a dictionary"""
|
||||
return {"paths": self.paths}
|
||||
|
||||
# RETURNING CAPTURES
|
||||
|
||||
@property
|
||||
def image(self):
|
||||
"""Return the latest captured image."""
|
||||
return last_entry(self.images.values())
|
||||
|
||||
@property
|
||||
def video(self):
|
||||
"""Return the latest recorded video."""
|
||||
return last_entry(self.videos.values())
|
||||
|
||||
def image_from_id(self, image_id):
|
||||
"""Return an image StreamObject with a matching ID."""
|
||||
logging.warning("image_from_id is deprecated. Access captures as a dictionary.")
|
||||
return entry_by_uuid(image_id, self.images.values())
|
||||
|
||||
def video_from_id(self, video_id):
|
||||
"""Return a video StreamObject with a matching ID."""
|
||||
logging.warning("video_from_id is deprecated. Access captures as a dictionary.")
|
||||
return entry_by_uuid(video_id, self.videos.values())
|
||||
|
||||
# CREATING NEW CAPTURES
|
||||
|
||||
def new_image(
|
||||
self,
|
||||
temporary: bool = True,
|
||||
filename: str = None,
|
||||
folder: str = "",
|
||||
fmt: str = "jpeg",
|
||||
):
|
||||
|
||||
"""
|
||||
Create a new image capture object.
|
||||
|
||||
Args:
|
||||
temporary (bool): Should the data be deleted after session ends.
|
||||
Creating the capture with a content manager sets this to true.
|
||||
filename (str): Name of the stored file. Defaults to timestamp.
|
||||
folder (str): Name of the folder in which to store the capture.
|
||||
fmt (str): Format of the capture.
|
||||
"""
|
||||
|
||||
# Generate file name
|
||||
if not filename:
|
||||
filename = generate_numbered_basename(self.images.values())
|
||||
logging.debug(filename)
|
||||
filename = "{}.{}".format(filename, fmt)
|
||||
|
||||
# Generate folder
|
||||
base_folder = self.paths["temp"] if temporary else self.paths["default"]
|
||||
folder = os.path.join(base_folder, folder)
|
||||
|
||||
# Generate file path
|
||||
filepath = os.path.join(folder, filename)
|
||||
|
||||
# Create capture object
|
||||
output = CaptureObject(filepath=filepath)
|
||||
# Insert a temporary tag if temporary
|
||||
if temporary:
|
||||
output.put_tags(["temporary"])
|
||||
|
||||
# Update capture list
|
||||
capture_key = str(output.id)
|
||||
logging.debug(f"Adding image {output} with key {capture_key}")
|
||||
self.images[capture_key] = output
|
||||
|
||||
return output
|
||||
|
||||
def new_video(
|
||||
self,
|
||||
temporary: bool = False,
|
||||
filename: str = None,
|
||||
folder: str = "",
|
||||
fmt: str = "h264",
|
||||
):
|
||||
|
||||
"""
|
||||
Create a new video capture object.
|
||||
|
||||
Args:
|
||||
temporary (bool): Should the data be deleted after session ends.
|
||||
Creating the capture with a content manager sets this to true.
|
||||
filename (str): Name of the stored file. Defaults to timestamp.
|
||||
folder (str): Name of the folder in which to store the capture.
|
||||
fmt (str): Format of the capture.
|
||||
"""
|
||||
# TODO: Remove the redundancy here
|
||||
|
||||
# Generate file name
|
||||
if not filename:
|
||||
filename = generate_numbered_basename(self.videos.values())
|
||||
logging.debug(filename)
|
||||
filename = "{}.{}".format(filename, fmt)
|
||||
|
||||
# Generate folder
|
||||
base_folder = self.paths["temp"] if temporary else self.paths["default"]
|
||||
folder = os.path.join(base_folder, folder)
|
||||
|
||||
# Generate file path
|
||||
filepath = os.path.join(folder, filename)
|
||||
|
||||
# Create capture object
|
||||
output = CaptureObject(filepath=filepath)
|
||||
# Insert a temporary tag if temporary
|
||||
if temporary:
|
||||
output.put_tags(["temporary"])
|
||||
|
||||
# Update capture list
|
||||
capture_key = str(output.id)
|
||||
logging.debug(f"Adding video {output} with key {capture_key}")
|
||||
self.videos[capture_key] = output
|
||||
|
||||
return output
|
||||
|
|
@ -203,4 +203,3 @@ with open(DEFAULT_CONFIGURATION_FILE_PATH, "r") as default_configuration:
|
|||
user_configuration = OpenflexureSettingsFile(
|
||||
path=CONFIGURATION_FILE_PATH, defaults=DEFAULT_CONFIGURATION
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,3 +18,15 @@ from labthings.core.tasks import (
|
|||
|
||||
# Flask things
|
||||
from flask import abort, escape, Response, request
|
||||
|
||||
|
||||
__all__ = [
|
||||
"current_task",
|
||||
"update_task_progress",
|
||||
"update_task_data",
|
||||
"taskify",
|
||||
"abort",
|
||||
"escape",
|
||||
"Response",
|
||||
"request",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ Defines a microscope object, binding a camera and stage with basic functionality
|
|||
import logging
|
||||
import pkg_resources
|
||||
import uuid
|
||||
from typing import Tuple
|
||||
|
||||
from openflexure_microscope.captures import CaptureManager
|
||||
|
||||
from openflexure_microscope.stage.mock import MissingStage
|
||||
from openflexure_microscope.camera.mock import MissingCamera
|
||||
|
|
@ -33,6 +36,8 @@ class Microscope:
|
|||
self.id = uuid.uuid4()
|
||||
self.name = self.id
|
||||
|
||||
self.captures = CaptureManager()
|
||||
|
||||
self.fov = [0, 0] #: Microscope field-of-view in stage motor steps
|
||||
|
||||
# Store settings and configuration files
|
||||
|
|
@ -47,6 +52,7 @@ class Microscope:
|
|||
|
||||
self.camera = None #: Currently connected camera object
|
||||
self.stage = None #: Currently connected stage object
|
||||
|
||||
self.setup(self.configuration_file.load()) # Attach components
|
||||
|
||||
# Apply settings loaded from file
|
||||
|
|
@ -67,6 +73,7 @@ class Microscope:
|
|||
self.camera.close()
|
||||
if self.stage:
|
||||
self.stage.close()
|
||||
self.captures.close()
|
||||
logging.info("Closed {}".format(self))
|
||||
|
||||
def setup(self, configuration):
|
||||
|
|
@ -75,6 +82,7 @@ class Microscope:
|
|||
"""
|
||||
|
||||
### Detector
|
||||
print("Creating camera")
|
||||
if configuration.get("camera"):
|
||||
camera_type = configuration["camera"].get("type")
|
||||
if camera_type in ("PiCamera", "PiCameraStreamer"):
|
||||
|
|
@ -85,6 +93,7 @@ class Microscope:
|
|||
logging.warning("No compatible camera hardware found.")
|
||||
|
||||
### Stage
|
||||
print("Creating stage")
|
||||
if configuration.get("stage"):
|
||||
stage_type = configuration["stage"].get("type")
|
||||
stage_port = configuration["stage"].get("port")
|
||||
|
|
@ -95,6 +104,7 @@ class Microscope:
|
|||
logging.error(e)
|
||||
logging.warning("No compatible Sangaboard hardware found.")
|
||||
|
||||
print("Handling fallbacks")
|
||||
### Fallbacks
|
||||
if not self.camera:
|
||||
self.camera = MissingCamera()
|
||||
|
|
@ -102,6 +112,7 @@ class Microscope:
|
|||
self.stage = MissingStage()
|
||||
|
||||
### Locks
|
||||
print("Creating locks")
|
||||
if hasattr(self.camera, "lock"):
|
||||
self.lock.locks.append(self.camera.lock)
|
||||
if hasattr(self.stage, "lock"):
|
||||
|
|
@ -144,11 +155,14 @@ class Microscope:
|
|||
|
||||
# If attached to a camera
|
||||
if ("camera" in settings) and self.camera:
|
||||
self.camera.update_settings(settings["camera"])
|
||||
self.camera.update_settings(settings.get("camera", {}))
|
||||
|
||||
# If attached to a stage
|
||||
if ("stage" in settings) and self.stage:
|
||||
self.stage.update_settings(settings["stage"])
|
||||
self.stage.update_settings(settings.get("stage", {}))
|
||||
|
||||
# Capture manager
|
||||
self.captures.update_settings(settings.get("captures", {}))
|
||||
|
||||
# Microscope settings
|
||||
if "id" in settings:
|
||||
|
|
@ -175,7 +189,12 @@ class Microscope:
|
|||
don't get removed from the settings file.
|
||||
"""
|
||||
|
||||
settings_current = {"id": self.id, "name": self.name, "fov": self.fov, "extensions": self.extension_settings}
|
||||
settings_current = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"fov": self.fov,
|
||||
"extensions": self.extension_settings,
|
||||
}
|
||||
|
||||
# If attached to a camera
|
||||
if self.camera:
|
||||
|
|
@ -202,6 +221,10 @@ class Microscope:
|
|||
settings_current_stage = self.stage.read_settings()
|
||||
settings_current["stage"] = settings_current_stage
|
||||
|
||||
# Capture manager
|
||||
settings_current_captures = self.captures.read_settings()
|
||||
settings_current["captures"] = settings_current_captures
|
||||
|
||||
settings_full = self.settings_file.merge(settings_current)
|
||||
|
||||
if full:
|
||||
|
|
@ -255,3 +278,49 @@ class Microscope:
|
|||
}
|
||||
|
||||
return system_metadata
|
||||
|
||||
def capture(
|
||||
self,
|
||||
filename: str = None,
|
||||
folder: str = "",
|
||||
temporary: bool = False,
|
||||
use_video_port: bool = False,
|
||||
resize: Tuple[int, int] = None,
|
||||
bayer: bool = True,
|
||||
fmt: str = "jpeg",
|
||||
annotations: dict = None,
|
||||
tags: list = None,
|
||||
metadata: dict = None
|
||||
):
|
||||
if not annotations:
|
||||
annotations = {}
|
||||
if not metadata:
|
||||
metadata = {}
|
||||
if not tags:
|
||||
tags = []
|
||||
|
||||
with self.camera.lock:
|
||||
# Create output object
|
||||
output = self.captures.new_image(
|
||||
temporary=temporary, filename=filename, folder=folder, fmt=fmt
|
||||
)
|
||||
|
||||
# Capture to output object
|
||||
self.camera.capture(
|
||||
output.file,
|
||||
use_video_port=use_video_port,
|
||||
resize=resize,
|
||||
bayer=bayer,
|
||||
fmt=fmt
|
||||
)
|
||||
|
||||
# Inject system metadata
|
||||
output.put_metadata({"instrument": self.metadata})
|
||||
# Insert custom metadata
|
||||
output.put_metadata(metadata)
|
||||
# Insert custom metadata
|
||||
output.put_annotations(annotations)
|
||||
# Insert custom tags
|
||||
output.put_tags(tags)
|
||||
|
||||
return output
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from openflexure_microscope.rescue.monitor_timeout import launch_timeout_test_process
|
||||
from openflexure_microscope.camera.capture import build_captures_from_exif
|
||||
from openflexure_microscope.camera.base import BASE_CAPTURE_PATH
|
||||
from openflexure_microscope.captures.capture_manager import BASE_CAPTURE_PATH
|
||||
from openflexure_microscope.captures.capture import build_captures_from_exif
|
||||
from openflexure_microscope.config import user_settings
|
||||
|
||||
import logging
|
||||
|
|
@ -10,7 +10,7 @@ def check_capture_rebuild(timeout=10):
|
|||
logging.info("Loading user settings...")
|
||||
settings = user_settings.load()
|
||||
|
||||
cap_path = str(settings.get("camera", {}).get("paths", {}).get("default"))
|
||||
cap_path = str(settings.get("captures", {}).get("paths", {}).get("default"))
|
||||
logging.info(f"Capture path found: {cap_path}")
|
||||
if not cap_path:
|
||||
logging.error(
|
||||
|
|
|
|||
|
|
@ -28,12 +28,7 @@ def ndarray_to_json(arr: np.ndarray):
|
|||
# This comes in very handy for the lens shading table.
|
||||
arr = np.array(arr)
|
||||
b64_string, dtype, shape = serialise_array_b64(arr)
|
||||
return {
|
||||
"@type": "ndarray",
|
||||
"dtype": dtype,
|
||||
"shape": shape,
|
||||
"base64": b64_string
|
||||
}
|
||||
return {"@type": "ndarray", "dtype": dtype, "shape": shape, "base64": b64_string}
|
||||
|
||||
|
||||
def json_to_ndarray(json_dict: dict):
|
||||
|
|
@ -42,9 +37,10 @@ def json_to_ndarray(json_dict: dict):
|
|||
for required_param in ("dtype", "shape", "base64"):
|
||||
if not json_dict.get(required_param):
|
||||
raise KeyError(f"Missing required key {required_param}")
|
||||
|
||||
return deserialise_array_b64(json_dict.get("base64"), json_dict.get("dtype"), json_dict.get("shape"))
|
||||
|
||||
return deserialise_array_b64(
|
||||
json_dict.get("base64"), json_dict.get("dtype"), json_dict.get("shape")
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ python = "^3.6"
|
|||
Flask = "^1.0"
|
||||
numpy = "1.18.2"
|
||||
Pillow = "^5.4"
|
||||
scipy = "1.4.1" # Exact version until we can guarantee a wheel
|
||||
scipy = "1.4.1" # Exact version so we can guarantee a wheel
|
||||
|
||||
picamera = { git = "https://github.com/rwb27/picamera.git", branch = "master", optional = true } # Lens shading requires >1.14 or RWB fork, lens-shading branch.
|
||||
"RPi.GPIO" = { version = "^0.6.5", optional = true }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue