diff --git a/openflexure_microscope/api/default_extensions/__init__.py b/openflexure_microscope/api/default_extensions/__init__.py index c7f3b487..8a28495f 100644 --- a/openflexure_microscope/api/default_extensions/__init__.py +++ b/openflexure_microscope/api/default_extensions/__init__.py @@ -23,6 +23,6 @@ with handle_extension_error("zip builder"): with handle_extension_error("autostorage"): from .autostorage import autostorage_extension_v2 with handle_extension_error("camera stage mapping"): - from .camera_stage_mapping import csm_extension + from camera_stage_mapping.ofm_extension import csm_extension with handle_extension_error("lens shading calibration"): from .picamera_autocalibrate import lst_extension_v2 diff --git a/openflexure_microscope/api/default_extensions/camera_stage_mapping/__init__.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping/__init__.py deleted file mode 100644 index 4e829bec..00000000 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .extension import csm_extension 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 deleted file mode 100644 index db99d630..00000000 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/array_with_attrs.py +++ /dev/null @@ -1,102 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Created on Tue May 26 08:08:14 2015 - -@author: rwb27 -""" - -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 - - def copy_arrays(self): - """Replace any numpy.ndarray in the dict with a copy, to break any unintentional links.""" - 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.""" - if isinstance(obj, AttributeDict) and not copy: - return obj - else: - out = AttributeDict(obj) - 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 - else: - return ArrayWithAttrs(obj) # otherwise, wrap it - - -class ArrayWithAttrs(np.ndarray): - """A numpy ndarray, with an AttributeDict accessible as array.attrs. - - This class is intended as a temporary version of an h5py dataset to allow - the easy passing of metadata/attributes around nplab functions. It owes - 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. - - This function adds an attributes dictionary to a numpy array, to make - it work like an h5py dataset. It doesn't copy data if it can be - avoided.""" - # the input array should be a numpy array, then we cast it to this type - obj = np.asarray(input_array).view(cls) - # next, add the dict - # ensure_attribute_dict always returns an AttributeDict - 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 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) - - -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"): - super(DummyHDF5Group, self).__init__() - self.attrs = attrs - for key in dictionary: - self[key] = dictionary[key] - self.name = name - self.basename = name - - file = None - 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 deleted file mode 100644 index 84636afc..00000000 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_calibration_1d.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -1D calibration of the relationship between a stage and a camera - -The `Tracker` class in this file is used to simplify code for tasks that involve moving the -stage, and tracking the corresponding motion with the camera. - -(c) Richard Bowman 2019, released under GNU GPL v3 -""" -import numpy as np -import time -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) - - -def direction_from_points(points): - """Given an Nx2 array of points, figure out the principal component. - - The return value is a normalised vector that points along the - direction with the most motion. - """ - 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)] - - -def apply_backlash(x, backlash=0, start_unwound=True): - """Apply a basic model of backlash to a set of coordinates. - - The output (y) will lag behind the input by up to `backlash` - - `start_unwound` (default: True) assumes we change direction - at the start of the time series, so you will get no motion - until `x[i]` has moved by at least `2*backlash`. - """ - y = np.zeros_like(x) - if start_unwound: - initial_direction = np.sign(x[1] - x[0]) - 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] - if np.abs(d) >= backlash: - y[i] = x[i] - np.sign(d) * backlash - else: - y[i] = y[i - 1] - return y - - -def fit_backlash(moves): - """Given a set of linear moves forwards and back, estimate backlash. - - The result is an estimate of the amount of backlash, and the ratio - of steps to pixels. The moves should be in the same - format as `Tracker.history`. - - We use a very basic fitting method: we do a brute-force search for - the backlash value, and for each value of backlash we fit a line to - the relationship between stage position (after modelling backlash) - and image position. We then pick the value of backlash that gets - the lowest residuals. Currently the backlash values tried will - start at 0 and increase by 1 or by a factor of 1.33 each time. - - The return value is a dictionary with the following keys: - backlash: float - the estimated backlash, in motor steps - pixels_per_step: float - the gradient of pixels to steps - fractional_error: float - an estimate of the goodness of fit - stage_direction: numpy.ndarray - unit vector in the direction of stage motion - image_direction: numpy.ndarray - unit vector in the direction of the motion measured - on the camera - pixels_per_step_vector: numpy.ndarray - The displacement in 2D on the camera resulting from - one step in `stage_direction`. This is equal to the - product of `pixels_per_step` and `image_direction`. - """ - all_stage_points, all_image_points = moves - - # Figure out the direction of motion, and reduce everything to 1D - image_direction = direction_from_points(all_image_points) - stage_direction = direction_from_points(all_stage_points) - xfit = np.sum(all_stage_points * stage_direction[np.newaxis, :], axis=1) - yfit = np.sum(all_image_points * image_direction[np.newaxis, :], axis=1) - - # We should probably use a fancy optimiser to fit the backlash, but - # brute-forcing it is reliable and doesn't take long. - def fit_motion(xfit, yfit, backlash=0): - """Using the model of backlash, fit the observed camera motion""" - xfit_blsh = apply_backlash(xfit, backlash) - xfit_blsh -= np.mean(xfit_blsh) - m, c = np.polyfit(xfit_blsh, yfit, 1) - residuals = yfit - (xfit_blsh * m + c) - return m, c, np.std(residuals, ddof=3) - - max_backlash = (np.max(xfit) - np.min(xfit)) / 3 - backlash_values = [] - residual_values = [] - backlash = 0 - while backlash < max_backlash: - m, c, residual = fit_motion(xfit, yfit, backlash) - residual_values.append(residual) - backlash_values.append(backlash) - 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)) - if fractional_error > 0.1: - raise ValueError("The fit didn't look successful") - - return { - "backlash": backlash, - "pixels_per_step": m, - "fractional_error": fractional_error, - "stage_direction": stage_direction, - "image_direction": image_direction, - "pixels_per_step_vector": m * image_direction, - } - - -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 - _ = tracker.template - except: - tracker.acquire_template() - assert tracker.stage_positions.shape[0] == 1 - original_stage_pos = tracker.stage_positions[-1, :] - - 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 - ) - - logging.info("Moving the stage to the edge of the field of view...") - i, m = move_until_motion_detected( - tracker, - move, - direction, - threshold=tracker.max_safe_displacement * 0.7, - 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) - - # 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() - - logging.info("Moving the stage backwards to measure backlash (1/2)") - # Now move backwards, in 10 steps that should roughly cross the field of view. - # If the stage has no backlash, this will move too far, hence the break statement to - # prevent it moving outside of the field of view. - starting_stage_pos, starting_camera_pos = tracker.append_point() - for i in range(15): - move(starting_stage_pos - sensible_step * (i + 1)) - # 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 - - 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="") - 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 - linear_moves = tracker.history - - try: - res = fit_backlash(linear_moves) - 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...") - tracker.reset_history() - stage_pos, camera_pos = tracker.append_point() - while np.dot(stage_pos - sensible_step - original_stage_pos, sensible_step) > 0: - move(stage_pos - sensible_step - backlash_correction) - move(stage_pos - sensible_step) - stage_pos, camera_pos = tracker.append_point() - backlash_corrected_moves = tracker.history - move(original_stage_pos - backlash_correction) - except ValueError: - 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" - ) - - 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) - - for k in ["exponential", "linear", "backlash_corrected"]: - moves = results[k + "_moves"] - if moves is not None: - ax[0].plot(moves[1][:, 0], moves[1][:, 1], "o-") - ax[0].set_aspect(1, adjustable="datalim") - - image_direction = results["image_direction"] - stage_direction = results["stage_direction"] - - def convert_moves(moves): - stage_pos, image_pos = moves - stage_1d = np.sum(stage_pos * stage_direction[np.newaxis, :], axis=1) - 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-") - - 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") - if results["backlash_corrected_moves"] is not None: - 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 - - This uses the output from `calibrate_backlash_1d`, run at least - twice with orthogonal (or at least different) `direction` parameters. - The resulting 2x2 transformation matrix should map from image - to stage coordinates. Currently, the backlash estimate given - by this function is only really trustworthy if you've supplied - two orthogonal calibrations - that will usually be the case. - """ - stage_vectors = [] - image_vectors = [] - backlash = np.zeros(3) - for cal in calibrations: - stage_vectors.append(cal["stage_direction"][:2]) - image_vectors.append(cal["pixels_per_step_vector"]) - # our backlash estimate will be the maximum backlash - # measured in each direction - 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 - return { - "image_to_stage_displacement": A, - "backlash_vector": backlash, - "backlash": np.max(backlash), - } 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 deleted file mode 100644 index 39ebd514..00000000 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_calibration_2d.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -Camera-stage calibration, 2D - -Uses 2D motion to try to calibrate the relationship between a camera and a stage. - - -(c) Richard Bowman 2019, released under GNU GPL v3 -""" -import numpy as np -import time -from numpy.linalg import norm -from matplotlib import pyplot as plt -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 - 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): - """Make a series of moves in X and Y to determine the XY components of the pixel-to-sample matrix. - - Arguments: - tracker: Tracker - An initialised Tracker object, centred on the starting point. This provides position readout from the stage and the camera. - move: function - A function that accepts a 1D array and performs an absolute move to - that position. If backlash correction is needed, include it here. - 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 - _ = tracker.template - except: - tracker.acquire_template() - 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: - 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 - # 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 - 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 - - transformed_image_positions = np.dot(image_positions, A) - residuals = transformed_image_positions - stage_positions - fractional_error = norm(residuals) / stage_positions.shape[0] - logging.debug(f"Ratio of residuals to displacement is {fractional_error})") - if fractional_error > 0.05: # Check it was a reasonably good fit - logging.warning( - "Warning: the error fitting measured displacements was %.1f%%" - % (fractional_error * 100) - ) - logging.info( - 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, - } 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 deleted file mode 100644 index a065216b..00000000 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_tracker.py +++ /dev/null @@ -1,332 +0,0 @@ -""" -Camera-stage tracker - -The `Tracker` class in this file is used to simplify code for tasks that involve moving the -stage, and tracking the corresponding motion with the camera. - -(c) Richard Bowman 2019, released under GNU GPL v3 -""" -import numpy as np -import time -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), ...] - - -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.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. - - image : numpy.array - The image in which to look. - feature : numpy.array - The feature to look for. Ideally should be an `ImageWithLocation`. - margin : int (optional) - Make sure the feature image is at least this much smaller than the big image. NB this will take account of the - image datum points - if the datum points are superimposed, there must be at least margin pixels on each side of - the feature image. - restrict : bool (optional, default False) - If set to true, restrict the search area to a square of (margin * 2 + 1) pixels centred on the pixel that most - closely overlaps the datum points of the two images. - - The `image` must be larger than `feature` by a margin big enough to produce a meaningful search area. We use the - OpenCV `matchTemplate` method to find the feature. The returned position is the position, relative to the corner of - the first image, of the "datum pixel" of the feature image. If no datum pixel is specified, we assume it's the - centre of the image. The output of this function can be passed into the pixel_to_location() method of the larger - 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!" - # 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)) - 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, - ..., - ] - - 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. - return pos - - -class Tracker: - def __init__(self, grab_image, get_position, settle=None): - """A class to manage moving the stage and following motion in the image - - Constructor Arguments: - grab_image: a function that returns the image as a numpy array - get_position: a function that returns position as a numpy array - settle: a function that waits and/or discards images - - We accept functions because that seems like the easiest way to be - compatible with many different cameras/stages. Subclass and override - ``__init__`` if you want to use a particular object instead. - - NB the ``image_position`` that this class returns may be the negative of - what you might expect. This is because normally we are looking for - where a certain object (usually matched to a template image) is within - an image. Instead, the ``Tracker`` is following motion of the image - relative to a template. Our model is that we have a static image on - the slide, so we're tracking the slide's motion. - """ - self._grab_image = grab_image - self._get_position = get_position - self._settle = settle - self._template = None - 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: - self._settle() - else: - time.sleep(0.3) - self._grab_image() - - @property - def template(self): - """The template image (should be a numpy array)""" - if self._template is None: - 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 - ): - """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. - Immediately afterwards, we record the first point, so we will acquire a second image - and also read the stage's position. - - The template image will be the central 50% of the starting image, which means the - maximum displacement will be 0.25 fields-of-view in all directions. - - Arguments: - settle: bool, default True - Whether to wait for the stage to settle before taking the template image - reset_history: bool, default True - Whether to erase all the previously-stored positions - relative_positions: bool, default True - If true, we will define the first point (as read from the camera) to be [0,0] - and make all future measurements relative to this one. NB this won't affect - the stage positions, which are always absolute. - """ - if settle: - self.settle() - image = self._grab_image() - self.template = central_half(image) - self.image_shape = image.shape - 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, 0.0]) - if relative_positions: - 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 - - @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 - - @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 - - NB this class is intended to track motion of the sample - most of - the time, we're interested in the motion of a (small) object that - is represented by the template, relative to the (larger) image. In - our case, we're doing the opposite - tracking motion of the image, - relative to a picture of part of the sample. That's why there is - 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 - - def append_point(self, settle=True, image=None): - """Find the current position using both stage and image, and append it""" - if settle: - self.settle() - if image is None: - image = self._grab_image() - image_pos = self.track_image(image) - stage_pos = self.get_position() - 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: - self._stage_positions = [self._stage_positions[0]] - self._image_positions = [self._image_positions[0]] - else: - self._stage_positions = [] - self._image_positions = [] - - @property - def moving_away_from_centre(self): - """Whether we are moving away from [0,0] on the camera. - - If we have recorded more than two steps, this property will be - `True` if the most recent point in the history is farther away from - `[0,0]` than the second most recent point. If we have recorded fewer - than 2 points, this property returns None. - """ - 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, -): - """Move the stage until we can detect motion in the camera. - - We move the stage in the direction given by ``displacement`` until the - image has shifted by at least ``threshold`` pixels. The steps will be - given by ``multipliers``, i.e. each time we move to - ``displacement * multipliers[i]`` relative to the starting position. - - NB we expect that the ``tracker`` object has already been initialised - with ``acquire_template``. - - ``detect_cumulative_motion`` will use the first point in the tracker as - the point to detect displacement relative to, rather than the last point. - The displacements are always made relative to the last point in the tracker - as it is passed in (i.e. the stage is always moved relative to where it - currently is, but motion detection may be done relative to where the tracker - was initialised). This only matters if the tracker has more than one point - in its history. - - The return value `i, m` is the number of moves made, and the largest - multiplier value that was used, i.e. we moved by a total of - `displacement * m`. - """ - displacement = np.array(displacement) - 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 - ) - ) - - -def concatenate_tracker_histories(histories): - """Combine a number of separate tracker history entries into one - - A "tracker history" refers to the output of `Tracker.history`, i.e. - it is a tuple of `(stage_positions, image_positions)` with the two - components being a Nx3 and Nx2 `numpy.ndarray` objects respectively. - - Given an array of such tuples, we will concatenate the components, - returning a single "tracker history" with the segments concatenated. - - Returns: backlash, pixels_per_step, fractional_error - - The return value is a tuple of 3 numbers; the estimated backlash (in - motor steps), the ratio of image_position changes to stage_position - (in units of pixels/steps), and an estimate of goodness of fit. - """ - components = zip(*histories) - return tuple(np.concatenate(c, axis=1) for c in components) diff --git a/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py deleted file mode 100644 index 4da67f9f..00000000 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -API extension for stage calibration - -This file contains the HTTP API for camera/stage calibration. -""" -from labthings.server.view import View, ActionView, PropertyView -from labthings.server.find import find_component -from labthings.server.extensions import BaseExtension -from labthings.server.decorators import ( - ThingAction, - use_args, - ThingProperty, -) -from labthings.server import fields - -from labthings.core.utilities import get_by_path, set_by_path, create_from_path - - -from flask import abort, send_file - -import logging -import time -import numpy as np -import PIL -import io -import os -import json - -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 -from openflexure_microscope.paths import data_file_path -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" - ) - - _microscope = None - - @property - def microscope(self): - # TODO: does caching the microscope actually help? - 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] - dictionary = create_from_path(keys) - set_by_path(dictionary, keys, settings) - logging.info(f"Updating settings with {dictionary}") - self.microscope.update_settings(dictionary) - self.microscope.save_settings() - - def get_settings(self): - """Retrieve the settings for this extension""" - 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 - - 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 - - def calibrate_1d(self, direction): - """Move a microscope's stage in 1D, and figure out the relationship with the camera""" - grab_image, get_position, move = self.camera_stage_functions() - - def wait(): - time.sleep(0.2) - - tracker = Tracker(grab_image, get_position, settle=wait) - - return calibrate_backlash_1d(tracker, move, direction) - - 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])) - logging.info("Calibrating Y axis:") - 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) - - data = { - "camera_stage_mapping_calibration": cal_xy, - "linear_calibration_x": cal_x, - "linear_calibration_y": cal_y, - } - - with open(CSM_DATAFILE_PATH, "w") as f: - json.dump(data, f, cls=JSONEncoder) - - return data - - @property - def image_to_stage_displacement_matrix(self): - try: - settings = self.get_settings() - return settings["image_to_stage_displacement"] - except KeyError: - raise ValueError("The microscope has not yet been calibrated.") - - def move_in_image_coordinates(self, displacement_in_pixels): - """Move by a given number of pixels on the camera""" - p = np.array(displacement_in_pixels) - relative_move = np.dot(p, self.image_to_stage_displacement_matrix) - self.microscope.stage.move_rel([relative_move[0], relative_move[1], 0]) - - -csm_extension = CSMExtension() - - -class Calibrate1DView(ActionView): - @use_args( - {"direction": fields.List(fields.Float(), required=True, example=[1, 0, 0])} - ) - def post(self, args): - """Calibrate one axis of the microscope stage against the camera.""" - - direction = np.array(args.get("direction")) - - return csm_extension.calibrate_1d(direction) - - -csm_extension.add_view(Calibrate1DView, "/calibrate_1d", endpoint="calibrate_1d") - - -class CalibrateXYView(ActionView): - def post(self): - """Calibrate both axes of the microscope stage against the camera.""" - return csm_extension.calibrate_xy() - - -csm_extension.add_view(CalibrateXYView, "/calibrate_xy", endpoint="calibrate_xy") - - -class MoveInImageCoordinatesView(ActionView): - @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")]) - ) - - return csm_extension.microscope.state["stage"]["position"] - - -csm_extension.add_view(MoveInImageCoordinatesView, "/move_in_image_coordinates", endpoint="move_in_image_coordinates") - - -class GetCalibrationFile(PropertyView): - def get(self): - """Get the calibration data in JSON format.""" - datafile_name = CSM_DATAFILE_NAME - datafile_path = CSM_DATAFILE_PATH - - if os.path.isfile(datafile_path): - with open(datafile_path, "rb") as f: - return json.load(f) - else: - return {} - - -csm_extension.add_view(GetCalibrationFile, "/get_calibration", endpoint="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 deleted file mode 100644 index a0063169..00000000 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/image_with_location.py +++ /dev/null @@ -1,270 +0,0 @@ -""" -Image With Location -=================== - -This datatype supports the various operations that rely on linking a camera to a microscope stage. It is an image -along with the metadata required to relate positions in the image to positions in real life. - -To create an `ImageWithLocation`, first put the image data into an `ArrayWithAttrs` and then specify the required -metadata in the `attrs` dictionary. The `pixel_to_sample_matrix` is the only required piece of metadata - the -`datum_pixel` is optional (and if missing, will be assumed to be the central pixel). - -A note on coordinate systems ----------------------------- -I've tried to stick to two coordinate systems: that used by the stage, generally called a "location", and pixels in an -image. - -Images have a "datum pixel", specified in metadata or assumed to be the centre (i.e. pixel (N-1)/2 for a width of N). -This need not be an integer pixel position, but is specified in pixels relative to the [0,0] pixel. When considering -something within an image, *the coordinate system is always relative to pixel [0,0]*, not relative to the datum pixel. -Similarly, the transformation matrix that moves between pixel and stage coordinates uses [0,0] as its origin, not the -datum pixel. However, when considering the displacement between two images, this is usually with respect to the datum -pixels of the images - though we should generally specify this. - -We transform between pixel and location coordinate systems with a matrix, the `pixel_to_sample_matrix`. Usually it -is called ``M`` in mathematical expressions. To convert a pixel coordinate to a location, we post-multiply the pixel -coordinate by the matrix, i.e. ``l = p.M`` and to convert the other way we use the inverse of ``M`` so ``p = l.M`` -where the dot denotes matrix multiplication using `numpy.dot`. - -Note that the calibration matrix is a 4x4 matrix, and the vectors should be ``(x, y, z, 1)`` so that we encode -the absolute position in that matrix, along with scaling and rotation. It's an entirely sensible thing to include -the stage coordinates in metadata as well as the matrix, but it is not needed. - -""" -from __future__ import division - -from builtins import range -from past.utils import old_div -import numpy as np -from array_with_attrs import ArrayWithAttrs, ensure_attrs -import cv2 - -# import cv2.cv -from scipy import ndimage - - -class ImageWithLocation(ArrayWithAttrs): - """An image, as a numpy array, with attributes to provide location information - - This is a functioning `numpy.ndarray` which can store the image in uncompressed format. - We require that the `attrs` dictionary (defined by `ArrayWithAttrs`) contains keys - 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 __getitem__(self, item): - """Update the metadata when we extract a slice""" - try: - # Handle specially the case where we are extracting a 2D region of the image, i.e. the first and second - # indices are slices. We test for that here - and do it in a try: except block so that if, for example, - # item is not indexable, - 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 - 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 - 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 - - 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 - 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) - # Scale the pixel-to-sample matrix if we've got a non-unity step in the slice - # I don't understand why I can't do this with slicing, but it all goes wrong... - for i in range(2): - out.pixel_to_sample_matrix[i, :3] *= step[i] - return out - - def pixel_to_location(self, pixel): - """Return the location in the sample of the given pixel. - - NB this returns a 3D location, including Z.""" - p = ensure_2d(pixel) - l = np.dot(np.array([p[0], p[1], 0, 1]), self.pixel_to_sample_matrix) - return l[:3] - - def location_to_pixel(self, location, check_bounds=False, z_tolerance=np.infty): - """Return the pixel coordinates of a given location in the sample. - - location : numpy.ndarray - A 2- or 3- element numpy array representing sample position, in units of distance. - check_bounds : bool, optional (default False) - If this is True, raise an exception if the pixel is not in the image. - z_tolerance : float, optional (defaults to infinity) - If we are checking the bounds, make sure the sample location is within this distance of the image's Z - position. The default is to allow any distance. - - Returns : numpy.ndarray - 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])) - 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" - 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): - """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 - ways of extracting a thumbnail: - pos = (240,320) - size = (100,100) - thumbnail = image[pos[0] - size[0]/2:pos[0] + size[0]/2, pos[1] - size[1]/2:pos[1] + size[1]/2, ...] - thumbnail2 = image.feature_at(pos, size) - thumbnail3 = image[190:290 270:370] - - ``centre_position`` and ``size`` should be two-element tuples, but the intention is that this code will cope - gracefully with floating-point values. - - NB the datum pixel of the returned image will be set to its centre, not the datum position of the original image - by default. Give the argument ``set_datum_to_centre=False`` to disable this behaviour. - """ - try: - float(centre_position[0]) - float(centre_position[1]) - float(size[0]) - float(size[1]) - except: - 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), - ..., - ] - 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. - return thumb - - def downsample(self, n): - """Return a view of the image, downsampled (sliced with a non-unity step). - - In the future, an optional argument to this function may take means of blocks of the images to improve signal - 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 - - @property - def datum_pixel(self): - """The pixel that nominally corresponds to where the image "is". - - 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) - ) - 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 - - @property - def datum_location(self): - """The location in the sample of the datum pixel""" - return self.pixel_to_location(self.datum_pixel) - - @property - def pixel_to_sample_matrix(self): - """The matrix that maps from pixel coordinates to sample coordinates. - - 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"] - 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 - 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 - - # 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 - if datum_pixel is not None: - 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.0 - - -def ensure_3d(vector): - """Make sure a vector has 3 elements, appending a zero if needed.""" - if len(vector) == 3: - return np.array(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!" - ) - - -def ensure_2d(vector): - """Make sure a vector has 3 elements, appending a zero if needed.""" - if len(vector) == 2: - return np.array(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!" - ) diff --git a/openflexure_microscope/api/static b/openflexure_microscope/api/static index c32bca43..5b451a8d 160000 --- a/openflexure_microscope/api/static +++ b/openflexure_microscope/api/static @@ -1 +1 @@ -Subproject commit c32bca436c2b17837e73097e9977d077a14305ab +Subproject commit 5b451a8d36138027726e1e1ce109614ca55c36f5 diff --git a/poetry.lock b/poetry.lock index 83bad1bc..3a50d8b5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -89,6 +89,27 @@ toml = ">=0.9.4" [package.extras] d = ["aiohttp (>=3.3.2)"] +[[package]] +category = "main" +description = "" +name = "camera-stage-mapping" +optional = false +python-versions = "^3.6" +version = "0.1.0" + +[package.dependencies] +numpy = "^1.17" + +[package.extras] +all = ["opencv-python-headless (^4.1)", "scipy (^1.4)"] +correlation = ["opencv-python-headless (^4.1)", "scipy (^1.4)"] +ipython = [] +ofmclient = [] + +[package.source] +reference = "297262362ae208f56847c823838c1d90675ef431" +type = "git" +url = "https://gitlab.com/openflexure/microscope-extensions/camera-stage-mapping.git" [[package]] category = "main" description = "Pure Python CBOR (de)serializer with extensive tag support" @@ -190,19 +211,21 @@ description = "Coroutine-based network library" name = "gevent" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" -version = "20.5.0" +version = "20.5.1" [package.dependencies] cffi = ">=1.12.2" greenlet = ">=0.4.14" +setuptools = "*" +"zope.event" = "*" +"zope.interface" = "*" [package.extras] dnspython = ["dnspython (>=1.16.0)", "idna"] docs = ["repoze.sphinx.autointerface", "sphinxcontrib-programoutput"] -events = ["zope.event", "zope.interface"] monitor = ["psutil (>=5.6.1)", "psutil (5.6.3)"] -recommended = ["dnspython (>=1.16.0)", "idna", "zope.event", "zope.interface", "cffi (>=1.12.2)", "psutil (>=5.6.1)", "psutil (5.6.3)"] -test = ["dnspython (>=1.16.0)", "idna", "zope.event", "zope.interface", "requests", "objgraph", "cffi (>=1.12.2)", "psutil (>=5.6.1)", "psutil (5.6.3)", "futures", "mock", "contextvars (2.4)", "coverage (<5.0)", "coveralls (>=1.7.0)"] +recommended = ["dnspython (>=1.16.0)", "idna", "cffi (>=1.12.2)", "psutil (>=5.6.1)", "psutil (5.6.3)"] +test = ["dnspython (>=1.16.0)", "idna", "requests", "objgraph", "cffi (>=1.12.2)", "psutil (>=5.6.1)", "psutil (5.6.3)", "futures", "mock", "contextvars (2.4)", "coverage (<5.0)", "coveralls (>=1.7.0)"] [[package]] category = "main" @@ -552,7 +575,7 @@ description = "Python 2 and 3 compatibility utilities" name = "six" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -version = "1.14.0" +version = "1.15.0" [[package]] category = "dev" @@ -751,16 +774,47 @@ description = "Pure Python Multicast DNS Service Discovery Library (Bonjour/Avah name = "zeroconf" optional = false python-versions = "*" -version = "0.26.1" +version = "0.26.2" [package.dependencies] ifaddr = "*" +[[package]] +category = "main" +description = "Very basic event publishing system" +name = "zope.event" +optional = false +python-versions = "*" +version = "4.4" + +[package.dependencies] +setuptools = "*" + +[package.extras] +docs = ["sphinx"] +test = ["zope.testrunner"] + +[[package]] +category = "main" +description = "Interfaces for Python" +name = "zope.interface" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "5.1.0" + +[package.dependencies] +setuptools = "*" + +[package.extras] +docs = ["sphinx", "repoze.sphinx.autointerface"] +test = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] +testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] + [extras] rpi = ["RPi.GPIO"] [metadata] -content-hash = "1c923fc71ff343293798e1bd187df98791716e15e4d970fef1a8d77bf25de1e2" +content-hash = "38a57e780d968651d6bac204d860866ded059b4169814bc4fe00810d84a79c7f" python-versions = "^3.6" [metadata.files] @@ -792,6 +846,7 @@ black = [ {file = "black-18.9b0-py36-none-any.whl", hash = "sha256:817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739"}, {file = "black-18.9b0.tar.gz", hash = "sha256:e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"}, ] +camera-stage-mapping = [] cbor2 = [ {file = "cbor2-5.1.0.tar.gz", hash = "sha256:43ce11e8c2fe4971d386d1a60cf83bfa0a4a667b97668ba76acbf5e6398821aa"}, ] @@ -854,28 +909,28 @@ flask-cors = [ {file = "Flask_Cors-3.0.8-py2.py3-none-any.whl", hash = "sha256:f4d97201660e6bbcff2d89d082b5b6d31abee04b1b3003ee073a6fd25ad1d69a"}, ] gevent = [ - {file = "gevent-20.5.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:efd9546468502a30ddd4699c3124ccb9d3099130f9b5ae1e2a54ad5b46e86120"}, - {file = "gevent-20.5.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:3ff477b6d275396123faf8ce2d5b82f96d85ba264e0b9d4b56a2bac49d1b9adc"}, - {file = "gevent-20.5.0-cp27-cp27m-win32.whl", hash = "sha256:92edc18a357473e01a4e4a82c073ed3c99ceca6e3ce93c23668dd4a2401f07dc"}, - {file = "gevent-20.5.0-cp27-cp27m-win_amd64.whl", hash = "sha256:1dd95433be45e1115053878366e3f5332ae99c39cb345be23851327c062b9f4a"}, - {file = "gevent-20.5.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:fcb64f3a28420d1b872b7ef41b12e8a1a4dcadfc8eff3c09993ab0cdf52584a1"}, - {file = "gevent-20.5.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:4d2729dd4bf9c4d0f29482f53cdf9fc90a498aebb5cd7ae8b45d35657437d2ac"}, - {file = "gevent-20.5.0-cp35-cp35m-win32.whl", hash = "sha256:00b03601b8dd1ee2aa07811cb60a4befe36173b15d91c6e207e37f8d77dd6fac"}, - {file = "gevent-20.5.0-cp35-cp35m-win_amd64.whl", hash = "sha256:937d36730f2b0dee3387712074b1f15b802e2e074a3d7c6dcaf70521236d607c"}, - {file = "gevent-20.5.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:929c33df8e9bcbe31906024fcd21580bd018196dbd3249eb5b2f19d63e11092d"}, - {file = "gevent-20.5.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:52e5cd607749ed3b8aa0272cacf2c11deec61fca4c3bec57a9fea8c49316627d"}, - {file = "gevent-20.5.0-cp36-cp36m-win32.whl", hash = "sha256:15eae3cd450dac7dae7f4ac59e01db1378965c9ef565c39c5ae78c5a888f9ac9"}, - {file = "gevent-20.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:9b4e940fc6071afebb86ba5f48dbb5f1fc3cb96ebeb8cf145eb5b499e9c6ee33"}, - {file = "gevent-20.5.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:e01d5373528e4ebdde66dc47a608d225fa3c4408ccd828d26c49b7ff75d82bd9"}, - {file = "gevent-20.5.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:31dc5d4ab8172cc00c4ff17cb18edee633babd961f64bf54214244d769bc3a74"}, - {file = "gevent-20.5.0-cp37-cp37m-win32.whl", hash = "sha256:0acc15ba2ac2a555529ad82d5a28fc85dbb6b2ff947657d67bebfd352e2b5c14"}, - {file = "gevent-20.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a7805934e8ce81610b61f806572c3d504cedd698cc8c9460d78d2893ba598c4a"}, - {file = "gevent-20.5.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:5c604179cebcc57f10505d8db177b92a715907815a464b066e7eba322d1c33ac"}, - {file = "gevent-20.5.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:88c76df4967c5229f853aa67ad1b394d9e4f985b0359c9bc9879416bba3e7c68"}, - {file = "gevent-20.5.0-cp38-cp38-win32.whl", hash = "sha256:d07a2afe4215731eb57d5b257a2e7e7e170d8a7ae1f02f6d0682cd3403debea9"}, - {file = "gevent-20.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:28b7d83b4327ceb79668eca2049bf4b9ce66d5ace18a88335e3035b573f889fd"}, - {file = "gevent-20.5.0-pp27-pypy_73-win32.whl", hash = "sha256:38db524ea88d81d596b2cbb6948fced26654a15fec40ea4529224e239a6f45e8"}, - {file = "gevent-20.5.0.tar.gz", hash = "sha256:1dc7f1f6bc1f67d625e4272b01e717eba0b4fa024d2ff7934c8d320674d6f7fa"}, + {file = "gevent-20.5.1-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:2504563f44bb188c1e48684e2ac7d2793f9f5b1e1cf119a8fdf8c36d2bf2eaf7"}, + {file = "gevent-20.5.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:1c2ad11663597d785e06daa8b65978a1536347a42bc840cf32823b54a0209d15"}, + {file = "gevent-20.5.1-cp27-cp27m-win32.whl", hash = "sha256:29eefad2557138fb654ba5cedfb94055f959e6c9705f9983518195cbcf250cc6"}, + {file = "gevent-20.5.1-cp27-cp27m-win_amd64.whl", hash = "sha256:fe3ede0282c023b6ac1d0441402866488017b8f90f47691794441d0a18342a65"}, + {file = "gevent-20.5.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:42ff095288b1f335f7ea96a7812f378d843a034f4f0e604edc24a3dddb001106"}, + {file = "gevent-20.5.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:1cf6ed4f66ecc432939e4be9434a20dffcf3207fb0ab6bc0343e7a9ea76d233b"}, + {file = "gevent-20.5.1-cp35-cp35m-win32.whl", hash = "sha256:f4a73e288fab042335b19f4b40407f8b44a40612626429943e37db23b40dd055"}, + {file = "gevent-20.5.1-cp35-cp35m-win_amd64.whl", hash = "sha256:765b39e502c76a1d77f743b821b7b1afe2a816848cf73a3606b1d5a91841cb9c"}, + {file = "gevent-20.5.1-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:52567bdc3769bc6df4693c1ea5ed1d82f825a6066835b405676ece437caf3fb9"}, + {file = "gevent-20.5.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:b53cf1a495c065df8b4b65d9f73a1cd7c5fa010955c0ed7bc5de196062099e41"}, + {file = "gevent-20.5.1-cp36-cp36m-win32.whl", hash = "sha256:7f1e339b6d51c354fa904ec8233b994b53c7c339b81c0743e07f2921b299d787"}, + {file = "gevent-20.5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8bea8dccb6ea671ecf00e1ba16d5275da8b78464082ac035e7391097513db777"}, + {file = "gevent-20.5.1-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:28a71ac05cf8a80897a8402f3193dab89bd225a3f0d27042d7352ec37156ba6a"}, + {file = "gevent-20.5.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:5c07973cd9f5a73480a386d1805b6a6b94e69aa906ee42f84a0cba02619a19e3"}, + {file = "gevent-20.5.1-cp37-cp37m-win32.whl", hash = "sha256:867c77a6da601b2f4600b71b7f8663cadb8f11c31f294b3a49025cdbaf406110"}, + {file = "gevent-20.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:71438390acb6aea432d5f853d5dcb16fa2a6d3c1d2299a0ebe32eed03ac81547"}, + {file = "gevent-20.5.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:1734f56ea545668780a4a283542a48d11298ab525c780a6001071f9d9d3c6880"}, + {file = "gevent-20.5.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:3a1ec10c73fb70bd474cd778e4ab487c1375b7d93053c24db15acbda367e3734"}, + {file = "gevent-20.5.1-cp38-cp38-win32.whl", hash = "sha256:653ad83784b872e78204c7e049b650c41c2e7ccb956142d8edc23a72e57ff80c"}, + {file = "gevent-20.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:ad01ef76f1d71cc7f2ce131cde6575ecc35d0a682a187a3229df3e977847f378"}, + {file = "gevent-20.5.1-pp27-pypy_73-win32.whl", hash = "sha256:7cb2fedafb0a692a3f1a14ddb13cbb3283863a1dfc3b536452f5ac6dfb88317a"}, + {file = "gevent-20.5.1.tar.gz", hash = "sha256:13ed2fa4a074c26fd60744a0757bf65004950554dfd9efd7c9deee1c241279af"}, ] gevent-websocket = [ {file = "gevent-websocket-0.10.1.tar.gz", hash = "sha256:7eaef32968290c9121f7c35b973e2cc302ffb076d018c9068d2f5ca8b2d85fb0"}, @@ -1209,8 +1264,8 @@ scipy = [ {file = "scipy-1.4.1.tar.gz", hash = "sha256:dee1bbf3a6c8f73b6b218cb28eed8dd13347ea2f87d572ce19b289d6fd3fbc59"}, ] six = [ - {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, - {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, + {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, + {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, ] snowballstemmer = [ {file = "snowballstemmer-2.0.0-py2.py3-none-any.whl", hash = "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0"}, @@ -1291,6 +1346,52 @@ wrapt = [ {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"}, ] zeroconf = [ - {file = "zeroconf-0.26.1-py3-none-any.whl", hash = "sha256:a0cdd43ee8f00e7082f784c4226d2609070ad0b2aeb34b0154466950d2134de6"}, - {file = "zeroconf-0.26.1.tar.gz", hash = "sha256:51f25787c27cf7b903e6795e8763bccdaa71199f61b75af97f1bde036fa43b27"}, + {file = "zeroconf-0.26.2-py3-none-any.whl", hash = "sha256:569c801e50891e0cc639c223e296e870dd9f6242a4f2b41d356666735b2a4264"}, + {file = "zeroconf-0.26.2.tar.gz", hash = "sha256:bca127caa7d16217cbca78290dbee532b41d71e798b939548dc5a2c3a8f98e5e"}, +] +"zope.event" = [ + {file = "zope.event-4.4-py2.py3-none-any.whl", hash = "sha256:d8e97d165fd5a0997b45f5303ae11ea3338becfe68c401dd88ffd2113fe5cae7"}, + {file = "zope.event-4.4.tar.gz", hash = "sha256:69c27debad9bdacd9ce9b735dad382142281ac770c4a432b533d6d65c4614bcf"}, +] +"zope.interface" = [ + {file = "zope.interface-5.1.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:645a7092b77fdbc3f68d3cc98f9d3e71510e419f54019d6e282328c0dd140dcd"}, + {file = "zope.interface-5.1.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:d1fe9d7d09bb07228650903d6a9dc48ea649e3b8c69b1d263419cc722b3938e8"}, + {file = "zope.interface-5.1.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:a744132d0abaa854d1aad50ba9bc64e79c6f835b3e92521db4235a1991176813"}, + {file = "zope.interface-5.1.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:461d4339b3b8f3335d7e2c90ce335eb275488c587b61aca4b305196dde2ff086"}, + {file = "zope.interface-5.1.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:269b27f60bcf45438e8683269f8ecd1235fa13e5411de93dae3b9ee4fe7f7bc7"}, + {file = "zope.interface-5.1.0-cp27-cp27m-win32.whl", hash = "sha256:6874367586c020705a44eecdad5d6b587c64b892e34305bb6ed87c9bbe22a5e9"}, + {file = "zope.interface-5.1.0-cp27-cp27m-win_amd64.whl", hash = "sha256:8149ded7f90154fdc1a40e0c8975df58041a6f693b8f7edcd9348484e9dc17fe"}, + {file = "zope.interface-5.1.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:0103cba5ed09f27d2e3de7e48bb320338592e2fabc5ce1432cf33808eb2dfd8b"}, + {file = "zope.interface-5.1.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:b0becb75418f8a130e9d465e718316cd17c7a8acce6fe8fe07adc72762bee425"}, + {file = "zope.interface-5.1.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:fb55c182a3f7b84c1a2d6de5fa7b1a05d4660d866b91dbf8d74549c57a1499e8"}, + {file = "zope.interface-5.1.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4f98f70328bc788c86a6a1a8a14b0ea979f81ae6015dd6c72978f1feff70ecda"}, + {file = "zope.interface-5.1.0-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:af2c14efc0bb0e91af63d00080ccc067866fb8cbbaca2b0438ab4105f5e0f08d"}, + {file = "zope.interface-5.1.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:f68bf937f113b88c866d090fea0bc52a098695173fc613b055a17ff0cf9683b6"}, + {file = "zope.interface-5.1.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d7804f6a71fc2dda888ef2de266727ec2f3915373d5a785ed4ddc603bbc91e08"}, + {file = "zope.interface-5.1.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:74bf0a4f9091131de09286f9a605db449840e313753949fe07c8d0fe7659ad1e"}, + {file = "zope.interface-5.1.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:ba4261c8ad00b49d48bbb3b5af388bb7576edfc0ca50a49c11dcb77caa1d897e"}, + {file = "zope.interface-5.1.0-cp35-cp35m-win32.whl", hash = "sha256:ebb4e637a1fb861c34e48a00d03cffa9234f42bef923aec44e5625ffb9a8e8f9"}, + {file = "zope.interface-5.1.0-cp35-cp35m-win_amd64.whl", hash = "sha256:911714b08b63d155f9c948da2b5534b223a1a4fc50bb67139ab68b277c938578"}, + {file = "zope.interface-5.1.0-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:e74671e43ed4569fbd7989e5eecc7d06dc134b571872ab1d5a88f4a123814e9f"}, + {file = "zope.interface-5.1.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b1d2ed1cbda2ae107283befd9284e650d840f8f7568cb9060b5466d25dc48975"}, + {file = "zope.interface-5.1.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ef739fe89e7f43fb6494a43b1878a36273e5924869ba1d866f752c5812ae8d58"}, + {file = "zope.interface-5.1.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:eb9b92f456ff3ec746cd4935b73c1117538d6124b8617bc0fe6fda0b3816e345"}, + {file = "zope.interface-5.1.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:dcefc97d1daf8d55199420e9162ab584ed0893a109f45e438b9794ced44c9fd0"}, + {file = "zope.interface-5.1.0-cp36-cp36m-win32.whl", hash = "sha256:f40db0e02a8157d2b90857c24d89b6310f9b6c3642369852cdc3b5ac49b92afc"}, + {file = "zope.interface-5.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:14415d6979356629f1c386c8c4249b4d0082f2ea7f75871ebad2e29584bd16c5"}, + {file = "zope.interface-5.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5e86c66a6dea8ab6152e83b0facc856dc4d435fe0f872f01d66ce0a2131b7f1d"}, + {file = "zope.interface-5.1.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:39106649c3082972106f930766ae23d1464a73b7d30b3698c986f74bf1256a34"}, + {file = "zope.interface-5.1.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8cccf7057c7d19064a9e27660f5aec4e5c4001ffcf653a47531bde19b5aa2a8a"}, + {file = "zope.interface-5.1.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:562dccd37acec149458c1791da459f130c6cf8902c94c93b8d47c6337b9fb826"}, + {file = "zope.interface-5.1.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:da2844fba024dd58eaa712561da47dcd1e7ad544a257482392472eae1c86d5e5"}, + {file = "zope.interface-5.1.0-cp37-cp37m-win32.whl", hash = "sha256:1ae4693ccee94c6e0c88a4568fb3b34af8871c60f5ba30cf9f94977ed0e53ddd"}, + {file = "zope.interface-5.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:dd98c436a1fc56f48c70882cc243df89ad036210d871c7427dc164b31500dc11"}, + {file = "zope.interface-5.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1b87ed2dc05cb835138f6a6e3595593fea3564d712cb2eb2de963a41fd35758c"}, + {file = "zope.interface-5.1.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:558a20a0845d1a5dc6ff87cd0f63d7dac982d7c3be05d2ffb6322a87c17fa286"}, + {file = "zope.interface-5.1.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b726194f938791a6691c7592c8b9e805fc6d1b9632a833b9c0640828cd49cbc"}, + {file = "zope.interface-5.1.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:60a207efcd8c11d6bbeb7862e33418fba4e4ad79846d88d160d7231fcb42a5ee"}, + {file = "zope.interface-5.1.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:b054eb0a8aa712c8e9030065a59b5e6a5cf0746ecdb5f087cca5ec7685690c19"}, + {file = "zope.interface-5.1.0-cp38-cp38-win32.whl", hash = "sha256:27d287e61639d692563d9dab76bafe071fbeb26818dd6a32a0022f3f7ca884b5"}, + {file = "zope.interface-5.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:a5f8f85986197d1dd6444763c4a15c991bfed86d835a1f6f7d476f7198d5f56a"}, + {file = "zope.interface-5.1.0.tar.gz", hash = "sha256:40e4c42bd27ed3c11b2c983fecfb03356fae1209de10686d03c02c8696a1d90e"}, ] diff --git a/pyproject.toml b/pyproject.toml index e288b50d..d95877a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.masonry.api" [tool.poetry] name = "openflexure-microscope-server" -version = "2.1.0-beta" +version = "2.1.0" description = "Python module, and Flask-based web API, to run the OpenFlexure Microscope." authors = [ @@ -48,6 +48,7 @@ opencv-python-headless = [ ] labthings = "0.6.3" pynpm = "^0.1.2" +camera-stage-mapping = {git = "https://gitlab.com/openflexure/microscope-extensions/camera-stage-mapping.git"} [tool.poetry.extras] rpi = ["RPi.GPIO"]