From 9646058c373995bf3744451613ce752188152b70 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 28 Apr 2020 13:20:38 +0100 Subject: [PATCH 1/7] Code format --- openflexure_microscope/api/app.py | 1 + .../api/default_extensions/__init__.py | 2 + .../api/default_extensions/autofocus.py | 33 +++-- .../camera_stage_mapping/array_with_attrs.py | 35 +++-- .../camera_stage_calibration_1d.py | 126 +++++++++------- .../camera_stage_tracker.py | 135 ++++++++++++------ .../camera_stage_mapping/extension.py | 85 +++++++---- .../image_with_location.py | 99 ++++++++----- .../picamera_autocalibrate/extension.py | 42 ++++-- .../api/default_extensions/scan.py | 2 +- .../api/v2/views/actions/__init__.py | 2 +- openflexure_microscope/config.py | 1 - openflexure_microscope/microscope.py | 7 +- openflexure_microscope/utilities.py | 12 +- 14 files changed, 381 insertions(+), 201 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 3e8a8209..e3cce530 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -1,5 +1,6 @@ #!/usr/bin/env python from gevent import monkey + monkey.patch_all() import time diff --git a/openflexure_microscope/api/default_extensions/__init__.py b/openflexure_microscope/api/default_extensions/__init__.py index e90646d7..87ad694d 100644 --- a/openflexure_microscope/api/default_extensions/__init__.py +++ b/openflexure_microscope/api/default_extensions/__init__.py @@ -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"): diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py index fc9f4a0a..97ea2e45 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -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") diff --git a/openflexure_microscope/api/default_extensions/camera_stage_mapping/array_with_attrs.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping/array_with_attrs.py index 61fe164a..db99d630 100644 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/array_with_attrs.py +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/array_with_attrs.py @@ -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 \ No newline at end of file + parent = None diff --git a/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_calibration_1d.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_calibration_1d.py index 12554440..84636afc 100644 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_calibration_1d.py +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_calibration_1d.py @@ -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), - } \ No newline at end of file + } diff --git a/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_tracker.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_tracker.py index 9c7a7235..a065216b 100644 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_tracker.py +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_tracker.py @@ -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) - \ No newline at end of file diff --git a/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py index 50648237..cbb02235 100644 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py @@ -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") \ No newline at end of file + +csm_extension.add_view(GetCalibrationFile, "/get_calibration") diff --git a/openflexure_microscope/api/default_extensions/camera_stage_mapping/image_with_location.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping/image_with_location.py index baef69d6..a0063169 100644 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/image_with_location.py +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/image_with_location.py @@ -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!" + ) diff --git a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py index 8a22e7f8..ff48ed0b 100644 --- a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py +++ b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py @@ -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( diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index ef788a05..30a0baa1 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -199,7 +199,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) diff --git a/openflexure_microscope/api/v2/views/actions/__init__.py b/openflexure_microscope/api/v2/views/actions/__init__.py index 19bf25eb..e06454c5 100644 --- a/openflexure_microscope/api/v2/views/actions/__init__.py +++ b/openflexure_microscope/api/v2/views/actions/__init__.py @@ -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): """ diff --git a/openflexure_microscope/config.py b/openflexure_microscope/config.py index 6657de24..74ffb3f0 100644 --- a/openflexure_microscope/config.py +++ b/openflexure_microscope/config.py @@ -203,4 +203,3 @@ with open(DEFAULT_CONFIGURATION_FILE_PATH, "r") as default_configuration: user_configuration = OpenflexureSettingsFile( path=CONFIGURATION_FILE_PATH, defaults=DEFAULT_CONFIGURATION ) - diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index c36d4690..d8db1def 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -175,7 +175,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: diff --git a/openflexure_microscope/utilities.py b/openflexure_microscope/utilities.py index 63db1651..185478f6 100644 --- a/openflexure_microscope/utilities.py +++ b/openflexure_microscope/utilities.py @@ -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 From d4dbafcb52add280178e9c2a7b354ef74e42aa7a Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 28 Apr 2020 13:20:48 +0100 Subject: [PATCH 2/7] Explicit __all__ in devel submodule --- openflexure_microscope/devel/__init__.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/openflexure_microscope/devel/__init__.py b/openflexure_microscope/devel/__init__.py index d777ea1a..519e2ce1 100644 --- a/openflexure_microscope/devel/__init__.py +++ b/openflexure_microscope/devel/__init__.py @@ -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", +] From 9ff24e8e374a9089ab65ddee9423b67f529c5f42 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 28 Apr 2020 13:36:53 +0100 Subject: [PATCH 3/7] Added updated opencv-python-headless for Py38 installations --- poetry.lock | 88 ++++++++++++++++++++++++++++++++++++-------------- pyproject.toml | 7 ++-- 2 files changed, 68 insertions(+), 27 deletions(-) diff --git a/poetry.lock b/poetry.lock index f6ca48d9..5be0bcfc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -123,7 +123,7 @@ description = "Composable command line interface toolkit" name = "click" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "7.1.1" +version = "7.1.2" [[package]] category = "dev" @@ -340,6 +340,7 @@ version = "1.18.2" [[package]] category = "main" description = "Wrapper package for OpenCV python bindings." +marker = "python_version >= \"3.7\" and python_version < \"3.8\"" name = "opencv-python-headless" optional = false python-versions = "*" @@ -348,6 +349,18 @@ version = "4.1.0.25" [package.dependencies] numpy = ">=1.11.1" +[[package]] +category = "main" +description = "Wrapper package for OpenCV python bindings." +marker = "python_version >= \"3.8\" and python_version < \"4.0\"" +name = "opencv-python-headless" +optional = false +python-versions = "*" +version = "4.2.0.34" + +[package.dependencies] +numpy = ">=1.11.1" + [[package]] category = "dev" description = "Core utilities for Python packages" @@ -374,7 +387,7 @@ doc = ["sphinx"] test = ["pillow", "coverage", "mock", "numpy", "pytest"] [package.source] -reference = "79177fa7f28d6d5c6eab5ba814b420b1785b6b24" +reference = "e50a34826fc8691e9f8554c529a1700662724ade" type = "git" url = "https://github.com/rwb27/picamera.git" [[package]] @@ -461,7 +474,7 @@ description = "World timezone definitions, modern and historical" name = "pytz" optional = false python-versions = "*" -version = "2019.3" +version = "2020.1" [[package]] category = "dev" @@ -503,7 +516,7 @@ description = "SciPy: Scientific Library for Python" name = "scipy" optional = false python-versions = ">=3.5" -version = "1.3.0" +version = "1.4.1" [package.dependencies] numpy = ">=1.13.3" @@ -722,7 +735,7 @@ ifaddr = "*" rpi = ["picamera", "RPi.GPIO"] [metadata] -content-hash = "0d199a2b60763d46e7864814f8cb78037f358fdbf64d32c9f233ea6384449510" +content-hash = "f880f4ec9bc5aacc0b508e234779c32fe418f96db56eb4bc805ab22cba695d7e" python-versions = "^3.6" [metadata.files] @@ -793,8 +806,8 @@ chardet = [ {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, ] click = [ - {file = "click-7.1.1-py2.py3-none-any.whl", hash = "sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a"}, - {file = "click-7.1.1.tar.gz", hash = "sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc"}, + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, ] colorama = [ {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, @@ -1010,6 +1023,26 @@ opencv-python-headless = [ {file = "opencv_python_headless-4.1.0.25-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:7709abc1c1341b831bf30d2b2afaa4877bdcab7dd775c94864608c08067e65b9"}, {file = "opencv_python_headless-4.1.0.25-cp37-cp37m-win32.whl", hash = "sha256:3020c6a996cea3a8d36ef5c49c8641a8f32a2bb77944128749af756de2946d6e"}, {file = "opencv_python_headless-4.1.0.25-cp37-cp37m-win_amd64.whl", hash = "sha256:5f1f5b0325cfd50cd58fc64bf66feed56238305e41450f04ab450092e18ee95b"}, + {file = "opencv_python_headless-4.2.0.34-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:45f90a872bc2e3d3257e06508ea18b3e2d429cf1b20de1a8f5c57251cc39fae4"}, + {file = "opencv_python_headless-4.2.0.34-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1641d82b94ce496e6f3df8f2a337eae94cd608e0e61dbb4b697577438d88c258"}, + {file = "opencv_python_headless-4.2.0.34-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:3aadcd93f1ad9a92dbfbfdeacb7ffca115f137aa2b07d36495fd141f2a553479"}, + {file = "opencv_python_headless-4.2.0.34-cp35-cp35m-win32.whl", hash = "sha256:cef7e844f5fd73cb602b8ba3f048a68dc2c59b7abb55ca99cceee9f688e909a4"}, + {file = "opencv_python_headless-4.2.0.34-cp35-cp35m-win_amd64.whl", hash = "sha256:70117cd51b158f7628cf2dab24805df40173c3794ca3dfeec22defb326ec0173"}, + {file = "opencv_python_headless-4.2.0.34-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4c3004573ecfbdf46785682a294923661903eb18e4c188106998ef5be255df1f"}, + {file = "opencv_python_headless-4.2.0.34-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:64292b593367e5b37556900b132861d94c05f616d8569c5625b02be6d51e13f7"}, + {file = "opencv_python_headless-4.2.0.34-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ffe74fc421fc77b649f4b762e115fd2b9cea9ba86b4590f7939bd15f92f11f80"}, + {file = "opencv_python_headless-4.2.0.34-cp36-cp36m-win32.whl", hash = "sha256:ed5c7d2fd8dc08ebcb9f8ed09883e3a083a5e0bc35f863ac280f61cec9560b09"}, + {file = "opencv_python_headless-4.2.0.34-cp36-cp36m-win_amd64.whl", hash = "sha256:28b28e3462500dccf75ac5efb2b885494d050fb6444fee37b53f9f950bccca57"}, + {file = "opencv_python_headless-4.2.0.34-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4e9de88b1376f904158698c3335f184cbcdc68eda018eb8f751e27e7407dddd1"}, + {file = "opencv_python_headless-4.2.0.34-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:edfaeea04982166a02c87b85ed5451b77485c86db7a4b216a42e6b686e1b5f1a"}, + {file = "opencv_python_headless-4.2.0.34-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:aca7419cae1390a8f2d6776571d1d19db66dadbff2ded79e4ff4bad5d9df6633"}, + {file = "opencv_python_headless-4.2.0.34-cp37-cp37m-win32.whl", hash = "sha256:1df0adfc1b893615ab8487cd207aae356936b0ba1823520e2292c928853c2930"}, + {file = "opencv_python_headless-4.2.0.34-cp37-cp37m-win_amd64.whl", hash = "sha256:67c6ff682e413db759f49a206bafa5ebb8b01dfdefa5199df32741b58772c3d7"}, + {file = "opencv_python_headless-4.2.0.34-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:904f0b4a358b3260d7572956026bf6a0fa0cda31d5a6afeed3e55341a3f36c8d"}, + {file = "opencv_python_headless-4.2.0.34-cp38-cp38-manylinux1_i686.whl", hash = "sha256:f93bec89d943ca1877c38920e934ed1d6193cdc6ff09cb910c19c15643ee07df"}, + {file = "opencv_python_headless-4.2.0.34-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ca9a5f4b04f2b423892ef5934fdd685edd0bc458c77fc7e164156a50e6be0377"}, + {file = "opencv_python_headless-4.2.0.34-cp38-cp38-win32.whl", hash = "sha256:06a96ec4866aa40c68c255042ea0ab8a96c07f2eb54f5b0dc6b8ac08b00c1b7e"}, + {file = "opencv_python_headless-4.2.0.34-cp38-cp38-win_amd64.whl", hash = "sha256:20c450943acebd9aa04566fdef4a658b94eec80a0c39a8f480c9aa1696034fcb"}, ] packaging = [ {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, @@ -1107,8 +1140,8 @@ python-dateutil = [ {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, ] pytz = [ - {file = "pytz-2019.3-py2.py3-none-any.whl", hash = "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d"}, - {file = "pytz-2019.3.tar.gz", hash = "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"}, + {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, + {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, ] requests = [ {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, @@ -1123,22 +1156,27 @@ rope = [ {file = "RPi.GPIO-0.6.5.tar.gz", hash = "sha256:a4210ad63bfe844e43995286de0d3950dfacfa0f3799bb9392770ac54a7d2e47"}, ] scipy = [ - {file = "scipy-1.3.0-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:4907040f62b91c2e170359c3d36c000af783f0fa1516a83d6c1517cde0af5340"}, - {file = "scipy-1.3.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1db9f964ed9c52dc5bd6127f0dd90ac89791daa690a5665cc01eae185912e1ba"}, - {file = "scipy-1.3.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:adadeeae5500de0da2b9e8dd478520d0a9945b577b2198f2462555e68f58e7ef"}, - {file = "scipy-1.3.0-cp35-cp35m-win32.whl", hash = "sha256:03b1e0775edbe6a4c64effb05fff2ce1429b76d29d754aa5ee2d848b60033351"}, - {file = "scipy-1.3.0-cp35-cp35m-win_amd64.whl", hash = "sha256:a7695a378c2ce402405ea37b12c7a338a8755e081869bd6b95858893ceb617ae"}, - {file = "scipy-1.3.0-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:826b9f5fbb7f908a13aa1efd4b7321e36992f5868d5d8311c7b40cf9b11ca0e7"}, - {file = "scipy-1.3.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b283a76a83fe463c9587a2c88003f800e08c3929dfbeba833b78260f9c209785"}, - {file = "scipy-1.3.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:db61a640ca20f237317d27bc658c1fc54c7581ff7f6502d112922dc285bdabee"}, - {file = "scipy-1.3.0-cp36-cp36m-win32.whl", hash = "sha256:409846be9d6bdcbd78b9e5afe2f64b2da5a923dd7c1cd0615ce589489533fdbb"}, - {file = "scipy-1.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:c19a7389ab3cd712058a8c3c9ffd8d27a57f3d84b9c91a931f542682bb3d269d"}, - {file = "scipy-1.3.0-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:09d008237baabf52a5d4f5a6fcf9b3c03408f3f61a69c404472a16861a73917e"}, - {file = "scipy-1.3.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:a84c31e8409b420c3ca57fd30c7589378d6fdc8d155d866a7f8e6e80dec6fd06"}, - {file = "scipy-1.3.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c5ea60ece0c0c1c849025bfc541b60a6751b491b6f11dd9ef37ab5b8c9041921"}, - {file = "scipy-1.3.0-cp37-cp37m-win32.whl", hash = "sha256:6c0543f2fdd38dee631fb023c0f31c284a532d205590b393d72009c14847f5b1"}, - {file = "scipy-1.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:10325f0ffac2400b1ec09537b7e403419dcd25d9fee602a44e8a32119af9079e"}, - {file = "scipy-1.3.0.tar.gz", hash = "sha256:c3bb4bd2aca82fb498247deeac12265921fe231502a6bc6edea3ee7fe6c40a7a"}, + {file = "scipy-1.4.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:c5cac0c0387272ee0e789e94a570ac51deb01c796b37fb2aad1fb13f85e2f97d"}, + {file = "scipy-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a144811318853a23d32a07bc7fd5561ff0cac5da643d96ed94a4ffe967d89672"}, + {file = "scipy-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:71eb180f22c49066f25d6df16f8709f215723317cc951d99e54dc88020ea57be"}, + {file = "scipy-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:770254a280d741dd3436919d47e35712fb081a6ff8bafc0f319382b954b77802"}, + {file = "scipy-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:a1aae70d52d0b074d8121333bc807a485f9f1e6a69742010b33780df2e60cfe0"}, + {file = "scipy-1.4.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:bb517872058a1f087c4528e7429b4a44533a902644987e7b2fe35ecc223bc408"}, + {file = "scipy-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:dba8306f6da99e37ea08c08fef6e274b5bf8567bb094d1dbe86a20e532aca088"}, + {file = "scipy-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:386086e2972ed2db17cebf88610aab7d7f6e2c0ca30042dc9a89cf18dcc363fa"}, + {file = "scipy-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:8d3bc3993b8e4be7eade6dcc6fd59a412d96d3a33fa42b0fa45dc9e24495ede9"}, + {file = "scipy-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:dc60bb302f48acf6da8ca4444cfa17d52c63c5415302a9ee77b3b21618090521"}, + {file = "scipy-1.4.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:787cc50cab3020a865640aba3485e9fbd161d4d3b0d03a967df1a2881320512d"}, + {file = "scipy-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0902a620a381f101e184a958459b36d3ee50f5effd186db76e131cbefcbb96f7"}, + {file = "scipy-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:00af72998a46c25bdb5824d2b729e7dabec0c765f9deb0b504f928591f5ff9d4"}, + {file = "scipy-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:9508a7c628a165c2c835f2497837bf6ac80eb25291055f56c129df3c943cbaf8"}, + {file = "scipy-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a2d6df9eb074af7f08866598e4ef068a2b310d98f87dc23bd1b90ec7bdcec802"}, + {file = "scipy-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3092857f36b690a321a662fe5496cb816a7f4eecd875e1d36793d92d3f884073"}, + {file = "scipy-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:8a07760d5c7f3a92e440ad3aedcc98891e915ce857664282ae3c0220f3301eb6"}, + {file = "scipy-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:1e3190466d669d658233e8a583b854f6386dd62d655539b77b3fa25bfb2abb70"}, + {file = "scipy-1.4.1-cp38-cp38-win32.whl", hash = "sha256:cc971a82ea1170e677443108703a2ec9ff0f70752258d0e9f5433d00dda01f59"}, + {file = "scipy-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:2cce3f9847a1a51019e8c5b47620da93950e58ebc611f13e0d11f4980ca5fecb"}, + {file = "scipy-1.4.1.tar.gz", hash = "sha256:dee1bbf3a6c8f73b6b218cb28eed8dd13347ea2f87d572ce19b289d6fd3fbc59"}, ] six = [ {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, diff --git a/pyproject.toml b/pyproject.toml index b5444a8a..52e45351 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ python = "^3.6" Flask = "^1.0" numpy = "1.18.2" Pillow = "^5.4" -scipy = "1.3.0" # 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 } @@ -35,7 +35,10 @@ pyserial = "^3.4" # Used for sangaboard (basic_serial_instrument) until we move python-dateutil = "^2.8" psutil = "^5.6.7" # Autostorage extension -opencv-python-headless = "4.1.0.25" +opencv-python-headless = [ + {version = "4.1.0.25", python = "~3.7"}, # PiWheels build for RPi running Py37 + {version = "4.2.0.34", python = "^3.8"} # Latest for Py38 systems +] labthings = "0.5.2" [tool.poetry.extras] From 1a6088a816c21d6d05453a91ef14b7c178a38dd4 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 28 Apr 2020 13:42:26 +0100 Subject: [PATCH 4/7] Code formatting --- .../camera_stage_calibration_2d.py | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_calibration_2d.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_calibration_2d.py index 6e189b0f..9f090994 100644 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_calibration_2d.py +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_calibration_2d.py @@ -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, + } From caf553db7a5de1327f5cc44979e314b93cdd4839 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 28 Apr 2020 14:32:56 +0100 Subject: [PATCH 5/7] Moved capture management into separate object --- openflexure_microscope/api/app.py | 2 + .../api/default_extensions/autostorage.py | 30 +-- .../api/default_extensions/scan.py | 28 +-- .../custom_element/__init__.py | 2 - openflexure_microscope/api/microscope.py | 2 +- .../api/v2/views/actions/camera.py | 20 +- openflexure_microscope/camera/base.py | 170 +-------------- openflexure_microscope/camera/mock.py | 3 +- openflexure_microscope/camera/pi.py | 3 +- openflexure_microscope/captures/__init__.py | 3 + .../{camera => captures}/capture.py | 0 .../captures/capture_manager.py | 193 ++++++++++++++++++ openflexure_microscope/microscope.py | 67 +++++- .../rescue/check_capture_reload.py | 6 +- 14 files changed, 302 insertions(+), 227 deletions(-) create mode 100644 openflexure_microscope/captures/__init__.py rename openflexure_microscope/{camera => captures}/capture.py (100%) create mode 100644 openflexure_microscope/captures/capture_manager.py diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index e3cce530..795357cc 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -57,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__, @@ -175,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) diff --git a/openflexure_microscope/api/default_extensions/autostorage.py b/openflexure_microscope/api/default_extensions/autostorage.py index 345ca73d..a237b41c 100644 --- a/openflexure_microscope/api/default_extensions/autostorage.py +++ b/openflexure_microscope/api/default_extensions/autostorage.py @@ -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.") @@ -93,7 +93,7 @@ class AutostorageExtension(BaseExtension): ) # We'll store a reference to a camera object, who's capture paths will be modified - self.camera = None + self.capture_manager = None self.initial_location = get_default_location() @@ -107,9 +107,9 @@ class AutostorageExtension(BaseExtension): logging.debug(f"Autostorage extension bound to camera {self.camera}") # Store a reference to the camera - self.camera = microscope_obj.camera + 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,20 +118,20 @@ 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: 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 @@ -140,7 +140,7 @@ class AutostorageExtension(BaseExtension): 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 +157,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(): diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index 30a0baa1..5ba0edcf 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -85,27 +85,17 @@ 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 diff --git a/openflexure_microscope/api/example_extensions/custom_element/__init__.py b/openflexure_microscope/api/example_extensions/custom_element/__init__.py index d8fa14cb..5669464c 100644 --- a/openflexure_microscope/api/example_extensions/custom_element/__init__.py +++ b/openflexure_microscope/api/example_extensions/custom_element/__init__.py @@ -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 diff --git a/openflexure_microscope/api/microscope.py b/openflexure_microscope/api/microscope.py index d9496684..87773bd4 100644 --- a/openflexure_microscope/api/microscope.py +++ b/openflexure_microscope/api/microscope.py @@ -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!") diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index c9d42e49..9e8c6d8a 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -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 diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py index 821b133d..8ba6ea2b 100644 --- a/openflexure_microscope/camera/base.py +++ b/openflexure_microscope/camera/base.py @@ -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.""" @@ -127,134 +89,6 @@ class BaseCamera(metaclass=ABCMeta): 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: diff --git a/openflexure_microscope/camera/mock.py b/openflexure_microscope/camera/mock.py index 65e65a32..9dc727c4 100644 --- a/openflexure_microscope/camera/mock.py +++ b/openflexure_microscope/camera/mock.py @@ -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 """ diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index 29ab938b..a73ea5d1 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -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 diff --git a/openflexure_microscope/captures/__init__.py b/openflexure_microscope/captures/__init__.py new file mode 100644 index 00000000..16cab515 --- /dev/null +++ b/openflexure_microscope/captures/__init__.py @@ -0,0 +1,3 @@ +from .capture_manager import CaptureManager +from .capture import CaptureObject +from . import capture_manager, capture \ No newline at end of file diff --git a/openflexure_microscope/camera/capture.py b/openflexure_microscope/captures/capture.py similarity index 100% rename from openflexure_microscope/camera/capture.py rename to openflexure_microscope/captures/capture.py diff --git a/openflexure_microscope/captures/capture_manager.py b/openflexure_microscope/captures/capture_manager.py new file mode 100644 index 00000000..0ccefc89 --- /dev/null +++ b/openflexure_microscope/captures/capture_manager.py @@ -0,0 +1,193 @@ +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 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 diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index d8db1def..f7e06d14 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -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 @@ -75,6 +81,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 +92,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 +103,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 +111,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 +154,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: @@ -207,6 +220,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: @@ -260,3 +277,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 \ No newline at end of file diff --git a/openflexure_microscope/rescue/check_capture_reload.py b/openflexure_microscope/rescue/check_capture_reload.py index 3a61c692..c79d76d6 100644 --- a/openflexure_microscope/rescue/check_capture_reload.py +++ b/openflexure_microscope/rescue/check_capture_reload.py @@ -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( From 45ee112e6f6894666c92f12438a002792596f031 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 28 Apr 2020 14:40:09 +0100 Subject: [PATCH 6/7] Fixed broken references --- .../api/default_extensions/autostorage.py | 14 ++++++++------ .../api/default_extensions/scan.py | 8 +++++--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/autostorage.py b/openflexure_microscope/api/default_extensions/autostorage.py index a237b41c..a00109bd 100644 --- a/openflexure_microscope/api/default_extensions/autostorage.py +++ b/openflexure_microscope/api/default_extensions/autostorage.py @@ -92,7 +92,7 @@ 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 + # 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,10 +103,12 @@ 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 + # Store a reference to the CaptureManager self.capture_manager = microscope_obj.captures # Store the initial storage location self.initial_location = get_current_location(self.capture_manager) @@ -128,13 +130,13 @@ class AutostorageExtension(BaseExtension): 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.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 {} diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index 5ba0edcf..88fcf771 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -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 @@ -90,10 +90,12 @@ def capture( filename=filename, folder=folder, temporary=temporary, - use_video_port=use_video_port, resize=resize, bayer=bayer, + use_video_port=use_video_port, + resize=resize, + bayer=bayer, annotations=annotations, tags=tags, - metadata=metadata + metadata=metadata, ) From a34214b9fb86ab9115339a29f567622a7d9c19b9 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 28 Apr 2020 14:47:22 +0100 Subject: [PATCH 7/7] Fixed broken references to capture list --- .../api/default_extensions/zip_builder.py | 2 +- .../api/v2/views/captures.py | 20 +++++++++---------- openflexure_microscope/camera/base.py | 6 ------ .../captures/capture_manager.py | 17 ++++++++++++++++ openflexure_microscope/microscope.py | 1 + 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index 1b1b53d1..0ac6de82 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -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] diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index abe7e895..70bb03d2 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -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 diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py index 8ba6ea2b..d8531a1f 100644 --- a/openflexure_microscope/camera/base.py +++ b/openflexure_microscope/camera/base.py @@ -79,12 +79,6 @@ 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)) diff --git a/openflexure_microscope/captures/capture_manager.py b/openflexure_microscope/captures/capture_manager.py index 0ccefc89..96599460 100644 --- a/openflexure_microscope/captures/capture_manager.py +++ b/openflexure_microscope/captures/capture_manager.py @@ -52,6 +52,23 @@ class CaptureManager: # 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 diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index f7e06d14..8a401b86 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -73,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):