From e46e06d8401dbe5f5ae880ca1d9bd5073fa9c9ac Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 29 Apr 2020 12:00:13 +0100 Subject: [PATCH 1/3] add FFT tracking and "leapfrog" support --- .../camera_stage_tracker.py | 130 ++++++++++++-- .../fft_image_tracking.py | 168 ++++++++++++++++++ 2 files changed, 281 insertions(+), 17 deletions(-) create mode 100644 openflexure_microscope/api/default_extensions/camera_stage_mapping/fft_image_tracking.py 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..b643b69f 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 @@ -11,6 +11,11 @@ import time from numpy.linalg import norm import cv2 from scipy import ndimage +from collections import namedtuple +import logging +from fft_image_tracking import high_pass_fft_template, displacement_from_fft_template, TrackingError + +TrackerHistory = namedtuple("TrackerHistory", ["stage_positions", "image_positions"]) def central_half(image): """Return the central 50% (in X and Y) of an image""" @@ -25,7 +30,8 @@ def datum_pixel(image): except: return (np.array(image.shape[:2]) - 1) / 2. -def locate_feature_in_image(image, feature, margin=0, restrict=False): +########## Cross-correlation based tracking ############ +def locate_feature_in_image(image, feature, margin=0, restrict=False, relative_to="top left"): """Find the given feature (small image) and return the position of its datum (or centre) in the image's pixels. image : numpy.array @@ -39,6 +45,10 @@ def locate_feature_in_image(image, feature, margin=0, restrict=False): 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. + relative_to : string (optional, default "top left") + We return the position of the centre (or datum pixel, if it's got that metadata) of the feature, relative to + either the top left (i.e. 0,0) pixel in the image, or the central pixel - to do the latter, set ``relative_to`` + to "centre" (or "center" if you must). 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 @@ -69,21 +79,55 @@ def locate_feature_in_image(image, feature, margin=0, restrict=False): 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 + if relative_to in ["top left", None]: + return pos + if relative_to in ["centre", "center"]: + return pos - (np.array(image.shape[:2]) - 1)/2. + raise ValueError("An invalid value was specified for datum.") + +######## FFT based tracking functions ########### +# moved to fft_image_tracking.py class Tracker(): - def __init__(self, grab_image, get_position, settle=None): + def __init__(self, grab_image, get_position, settle=None, method="direct", **kwargs): """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 + method: string, currently "direct" (default) or "fft" + Additional keyword arguments are passed to the tracking method. + + Tracking methods: + "direct": uses cross-correlation with the central half of the image. + There are currently no options for this method. + "fft" uses FFT-based cross-correlation, after a high pass filter. + Keyword arguments are accepted: + pad: boolean, default=True + Whether to zero-pad the FFT to remove ambiguity. If + false, the answer is only unique modulo one field of + view due to the periodic nature of FFTs. Setting this + option to False speeds up tracking by about 4x. + sigma: floating point, default=10 + The standard deviation, in pixels, of a Gaussian filter + used to smooth the image, before subtracting the smooth + image from the original, in a low pass filter. NB the + standard deviation is given in pixels, but is applied + in the Fourier domain (with appropriate transformation). + The value of sigma does not affect computation speed. 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. + # Subclassing notes + If you change the tracking method, you should override: + * track_image + * generate_template + * max_displacement + * min_displacement (optional - defaults to -max_displacment) + 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 @@ -97,7 +141,11 @@ class Tracker(): self._template = None self.margin = np.array([0, 0]) self._template_position = np.array([0.0, 0.0]) + self._last_point = None self.image_shape = None + self.method = method + #self.kwargs = {"error_threshold": 0.2}.update(kwargs) + self.kwargs = kwargs def get_position(self): """Get the position of the stage""" @@ -138,42 +186,86 @@ class Tracker(): 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.template = self.generate_template(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.]) - if relative_positions: - self._template_position = self.track_image(image) # Position should be zero initially self.append_point(settle=False) + + def leapfrog(self): + """Replace the template but don't change position. + + By default, this will replace the template with the image from the last + point we measured, but update _template_position so that the coordinates + returned don't change. + """ + if self._last_point is None: + raise ValueError("Can't leapfrog until you have measured at least one point.") + image, image_pos = self._last_point + self.template = self.generate_template(image) + if np.any(self.image_shape != image.shape): + raise ValueError("Error: the image size seems to have changed!") + # Ensure that the position doesn't change. NB this is nice and reliable because + # it actually runs self.track_image, but we could be much more efficient if we + # assumed that self.track_image(image) == 0, and we can certainly do the maths + # to make that work... + # TODO: eliminate the unnecessary correlation + self._template_position = np.array(image_pos) + + def generate_template(self, image): + """Generate a template based on a supplied image. + + This function is designed to be overridden in order to + change the tracking method. + """ + if self.method == "direct": + return central_half(image) + if self.method == "fft": + kwargs = {k: v for k, v in self.kwargs.items() if k in ["pad", "sigma"]} + return high_pass_fft_template(image, calculate_peak=True, **kwargs) + @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 + if self.method == "direct": + # TODO: if template_position is not central, should we alter this?? + disp = (np.array(self.image_shape[:2]) - np.array(self.template.shape)[:2]) // 2 + if self.method == "fft": + # FFT tracking does a real FFT to track the position, which is half as long in + # the last dimension. If we didn't zero pad, the transform will have the same shape as + # the image in x, and half in y - so we return half the image size. If we are zero + # padding, then both these dimensions double, and we return the image size. + disp = np.array(self.template.shape) // np.array([2,1]) + return disp + self._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._template_position - self.max_displacement + # TODO: be cleverer about tracking assymetry? Currently there is none... @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])) + return np.min(np.concatenate([self.max_displacement - self._template_position, + -self.min_displacement - self._template_position])) + + def point_in_safe_range(self, point): + """Return True if a given point is within the safe range of the tracker.""" + return np.all(point > self.min_displacement) and np.all(point < self.max_displacement) def track_image(self, image): """Find the position of the image relative to the template + This uses the method specified at initialisation time to + track motion of the sample. + 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 @@ -182,7 +274,11 @@ 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 + if self.method=="direct": + return -locate_feature_in_image(image, self.template, relative_to="centre") + self._template_position + if self.method=="fft": + kwargs = {k: v for k, v in self.kwargs.items() if k in ["pad", "fractional_threshold", "error_threshold"]} + return -displacement_from_fft_template(self.template, image, **kwargs) + self._template_position def append_point(self, settle=True, image=None): """Find the current position using both stage and image, and append it""" @@ -194,6 +290,7 @@ class Tracker(): stage_pos = self.get_position() self._image_positions.append(image_pos) self._stage_positions.append(stage_pos) + self._last_point = (image, image_pos) return stage_pos, image_pos @property @@ -209,7 +306,7 @@ class Tracker(): @property def history(self): """Return arrays of stage, image positions""" - return self.stage_positions, self.image_positions + return TrackerHistory(self.stage_positions, self.image_positions) def reset_history(self, leave_first_point=False): """Reset the positions and displacements recorded""" @@ -286,4 +383,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/fft_image_tracking.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping/fft_image_tracking.py new file mode 100644 index 00000000..81b7efd0 --- /dev/null +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/fft_image_tracking.py @@ -0,0 +1,168 @@ +""" +Utility functions to track motion of a microscope using FFT-based correlation. + +Cross-correlation is a reasonable way to determine where an object is in an +image. It can also be used to track 2D motion. The Fourier Shift Theorem +relies on the fact that a correlation (or convolution) becomes a multiplication +in the Fourier domain. This means that Fast Fourier Transforms are an +efficient way to implement cross-correlation of whole images. This module +contains a number of functions to simplify tracking the motion of a microscope +stage using FFTs. + +(c) Richard Bowman 2020, released under GNU GPL v3 +No warranty, express or implied, is given with respect to this code. + +""" +import numpy as np +import logging +from array_with_attrs import ArrayWithAttrs + + +def grayscale_and_padding(image, pad=True): + """Convert to grayscale and prepare for zero padding if needed. + + The FFT-based tracking methods need grayscale images. Also, if + we are going to zero-pad, we should convert to floating point and + ensure the mean of the image is zero, otherwise the dominant feature + will be the edge of the image. + + Returns: + image, fft_shape + """ + if len(image.shape) == 3: + image = np.mean(image, axis=2) + fft_shape = np.array(image.shape) + if pad: + image = image.astype(np.float) - np.mean(image) + fft_shape *= 2 + return image, fft_shape + +def high_pass_fourier_mask(shape, s, rfft=True): + """Generate a mask performing a high pass filter + + The return value is a 2D array, which can be multiplied + with the Fourier Transform of an image to perform a high + pass filter. + + Arguments: + shape: tuple of 2 integers + The shape of the output array + s: float + The standard deviation of the Gaussian in real + space, in pixels + """ + high_pass_filter = np.ones(shape) + x, y = (np.arange(n, dtype=np.float) for n in shape) + # Beyond the halfway point of the array, frequencies are negative + x[x.shape[0]//2:x.shape[0]] -= x.shape[0] + if not rfft: # If it's a real fft, the last axis is halved so we can skip this. + y[y.shape[0]//2:y.shape[0]] -= y.shape[0] + x /= np.max(np.abs(x)) * 2 # Normalise so highest frequency is 1/2 + y /= np.max(np.abs(y)) * 2 # Normalise so highest frequency is 1/2 + r2 = x[:, np.newaxis]**2 + y[np.newaxis, :]**2 + # now we multiply by 1-FT(Gaussian kernel with sd of s pixels) + high_pass_filter -= np.exp(-2*np.pi**2*s**2*r2) + return high_pass_filter + +def high_pass_fft_template(image, sigma=10, pad=True, calculate_peak=True): + """Calculate a high-pass-filtered FT template for tracking + + This performs a real FFT, and then attenuates low frequencies. + The resulting array can be used as a template for tracking. + + sigma is the standard deviation in pixels of the Gaussian used + in the high pass filter. + + pad enables (default) zero padding - this removes the ambiguity + around position, at the cost of making the function slower. We + subtract the mean and zero-pad the input array (equivalent to + padding with the mean value, to reduce the impact of the edge) + + calculate computes the value of the brightest pixel we'd expect + in a correlation image (i.e. the peak if we correlate the image + passed in with the template we're generating). This is stored + in ``template.attrs["peak_correlation_value"]`` + """ + image, fft_shape = grayscale_and_padding(image, pad) + initial_fft = np.fft.rfft2(image, s=fft_shape) # NB rfft2 is faster, but a different shape! + high_pass_filter = high_pass_fourier_mask(initial_fft.shape, sigma) + if calculate_peak: + expected_peak = np.mean(np.conj(initial_fft) * high_pass_filter * initial_fft) + template = ArrayWithAttrs(np.conj(initial_fft) * high_pass_filter) + template.attrs["maximum_correlation_value"] = expected_peak + return template + return np.conj(initial_fft) * high_pass_filter + +def background_subtracted_centre_of_mass(corr, fractional_threshold=0.05, quadrant_swap=False): + """Carry out a background subtracted centre of mass measurement + + Arguments: + corr: a 2D numpy array, to be thresholded + fractional_threshold: the fraction of the range (from + min(corr) to max(corr)) that should remain above + the background level. 1 means no thresholding, + 0.05 means use only the top 5% of the range. + quadrant_swap: boolean, default False + Set this to true if we are working on the output of + a Fourier transform. This will adjust the coordinates + such that we effectively perform quadrant swapping, to + place the DC component in the centre of the image, and + make the coordinate (0,0) correspond to that point, with + positive and negative coordinates either side. + """ + assert corr.dtype == np.float, "The image must be floating point" + background = np.max(corr) - fractional_threshold * (np.max(corr) - np.min(corr)) + background_subtracted = corr - background + background_subtracted[background_subtracted < 0] = 0 + xs, ys = (np.arange(n) for n in corr.shape) # This is equivalent to meshgrid, more or less... + if quadrant_swap: + xs[len(xs)//2:] -= len(xs) + ys[len(ys)//2:] -= len(ys) + x = np.sum(background_subtracted * xs[:, np.newaxis]) + y = np.sum(background_subtracted * ys[np.newaxis, :]) + I = np.sum(background_subtracted) + return np.array([x/I, y/I]) + +class TrackingError(Exception): + pass + +def displacement_from_fft_template(template, image, fractional_threshold=0.1, pad=True, return_peak=False, error_threshold=0): + """Find the displacement, in pixels, of an image from a template + + The template should be generated by ``high_pass_fft_template`` + Fractional_threshold is the fraction of the range (from max to min) + of the cross-correlation image that should remain above the threshold + before finding the peak by centre-of-mass. + + NB because of the periodic boundary conditions of the FFT, this gives + a result that is ambiguous - it's only accurate modulo one image. + The result that is returned represents the smalles displacement, + positive or negative. You may add or subtract one whole image-width + (or height) if that makes sense - use other cues to resolve the + ambiguity. + + return_peak returns the brightes pixel in the correlation image, as + well as the displacement in a tuple. + + error_threshold is an optional floating-point number between 0 and 1. + Setting it to a value greater than 0 will compare the correlation value + with the maximum possible. If the ratio of the current signal to the + maximum drops below ``error_threshold``, we raise a ``TrackingError`` + exception. + """ + image, fft_shape = grayscale_and_padding(image, pad) + # The template is already Fourier transformed and high pass filtered. + # so multiplying the two in Fourier space performs the convolution. + corr = np.fft.irfft2(template * np.fft.rfft2(image, s=fft_shape)) + if error_threshold > 0: + if np.max(corr)/template.attrs["maximum_correlation_value"] < error_threshold: + raise TrackingError("The correlation signal dropped below the threshold set.") + displacement = background_subtracted_centre_of_mass(corr, fractional_threshold, quadrant_swap=True) + if return_peak: + return displacement, np.max(corr) + return displacement + +def displacement_between_images(image_0, image_1, sigma=10, fractional_threshold=0.1, pad=True): + """Calculate the displacement, in pixels, between two images.""" + return displacement_from_fft_template(high_pass_fft_template(image_0, sigma, pad=pad), + image_1, fractional_threshold, pad=pad) From 484734df95918b61ff9318d648702db53ab9ca2c Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 29 Apr 2020 12:07:43 +0100 Subject: [PATCH 2/3] Revert "add FFT tracking and "leapfrog" support" This reverts commit e46e06d8401dbe5f5ae880ca1d9bd5073fa9c9ac. --- .../camera_stage_tracker.py | 130 ++------------ .../fft_image_tracking.py | 168 ------------------ 2 files changed, 17 insertions(+), 281 deletions(-) delete mode 100644 openflexure_microscope/api/default_extensions/camera_stage_mapping/fft_image_tracking.py 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 b643b69f..9c7a7235 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 @@ -11,11 +11,6 @@ import time from numpy.linalg import norm import cv2 from scipy import ndimage -from collections import namedtuple -import logging -from fft_image_tracking import high_pass_fft_template, displacement_from_fft_template, TrackingError - -TrackerHistory = namedtuple("TrackerHistory", ["stage_positions", "image_positions"]) def central_half(image): """Return the central 50% (in X and Y) of an image""" @@ -30,8 +25,7 @@ def datum_pixel(image): except: return (np.array(image.shape[:2]) - 1) / 2. -########## Cross-correlation based tracking ############ -def locate_feature_in_image(image, feature, margin=0, restrict=False, relative_to="top left"): +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 @@ -45,10 +39,6 @@ def locate_feature_in_image(image, feature, margin=0, restrict=False, relative_t 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. - relative_to : string (optional, default "top left") - We return the position of the centre (or datum pixel, if it's got that metadata) of the feature, relative to - either the top left (i.e. 0,0) pixel in the image, or the central pixel - to do the latter, set ``relative_to`` - to "centre" (or "center" if you must). 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 @@ -79,55 +69,21 @@ def locate_feature_in_image(image, feature, margin=0, restrict=False, relative_t 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. - if relative_to in ["top left", None]: - return pos - if relative_to in ["centre", "center"]: - return pos - (np.array(image.shape[:2]) - 1)/2. - raise ValueError("An invalid value was specified for datum.") - -######## FFT based tracking functions ########### -# moved to fft_image_tracking.py + return pos class Tracker(): - def __init__(self, grab_image, get_position, settle=None, method="direct", **kwargs): + 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 - method: string, currently "direct" (default) or "fft" - Additional keyword arguments are passed to the tracking method. - - Tracking methods: - "direct": uses cross-correlation with the central half of the image. - There are currently no options for this method. - "fft" uses FFT-based cross-correlation, after a high pass filter. - Keyword arguments are accepted: - pad: boolean, default=True - Whether to zero-pad the FFT to remove ambiguity. If - false, the answer is only unique modulo one field of - view due to the periodic nature of FFTs. Setting this - option to False speeds up tracking by about 4x. - sigma: floating point, default=10 - The standard deviation, in pixels, of a Gaussian filter - used to smooth the image, before subtracting the smooth - image from the original, in a low pass filter. NB the - standard deviation is given in pixels, but is applied - in the Fourier domain (with appropriate transformation). - The value of sigma does not affect computation speed. 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. - # Subclassing notes - If you change the tracking method, you should override: - * track_image - * generate_template - * max_displacement - * min_displacement (optional - defaults to -max_displacment) - 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 @@ -141,11 +97,7 @@ class Tracker(): self._template = None self.margin = np.array([0, 0]) self._template_position = np.array([0.0, 0.0]) - self._last_point = None self.image_shape = None - self.method = method - #self.kwargs = {"error_threshold": 0.2}.update(kwargs) - self.kwargs = kwargs def get_position(self): """Get the position of the stage""" @@ -186,86 +138,42 @@ class Tracker(): 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 = self.generate_template(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.]) + if relative_positions: + self._template_position = self.track_image(image) # Position should be zero initially self.append_point(settle=False) - - def leapfrog(self): - """Replace the template but don't change position. - - By default, this will replace the template with the image from the last - point we measured, but update _template_position so that the coordinates - returned don't change. - """ - if self._last_point is None: - raise ValueError("Can't leapfrog until you have measured at least one point.") - image, image_pos = self._last_point - self.template = self.generate_template(image) - if np.any(self.image_shape != image.shape): - raise ValueError("Error: the image size seems to have changed!") - # Ensure that the position doesn't change. NB this is nice and reliable because - # it actually runs self.track_image, but we could be much more efficient if we - # assumed that self.track_image(image) == 0, and we can certainly do the maths - # to make that work... - # TODO: eliminate the unnecessary correlation - self._template_position = np.array(image_pos) - - def generate_template(self, image): - """Generate a template based on a supplied image. - - This function is designed to be overridden in order to - change the tracking method. - """ - if self.method == "direct": - return central_half(image) - if self.method == "fft": - kwargs = {k: v for k, v in self.kwargs.items() if k in ["pad", "sigma"]} - return high_pass_fft_template(image, calculate_peak=True, **kwargs) - @property def max_displacement(self): """The highest position values that can be tracked""" - if self.method == "direct": - # TODO: if template_position is not central, should we alter this?? - disp = (np.array(self.image_shape[:2]) - np.array(self.template.shape)[:2]) // 2 - if self.method == "fft": - # FFT tracking does a real FFT to track the position, which is half as long in - # the last dimension. If we didn't zero pad, the transform will have the same shape as - # the image in x, and half in y - so we return half the image size. If we are zero - # padding, then both these dimensions double, and we return the image size. - disp = np.array(self.template.shape) // np.array([2,1]) - return disp + self._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._template_position - self.max_displacement - # TODO: be cleverer about tracking assymetry? Currently there is none... + 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._template_position, - -self.min_displacement - self._template_position])) - - def point_in_safe_range(self, point): - """Return True if a given point is within the safe range of the tracker.""" - return np.all(point > self.min_displacement) and np.all(point < self.max_displacement) + 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 - This uses the method specified at initialisation time to - track motion of the sample. - 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 @@ -274,11 +182,7 @@ class Tracker(): a minus sign in front of `locate_feature_in_image` in the source code. """ - if self.method=="direct": - return -locate_feature_in_image(image, self.template, relative_to="centre") + self._template_position - if self.method=="fft": - kwargs = {k: v for k, v in self.kwargs.items() if k in ["pad", "fractional_threshold", "error_threshold"]} - return -displacement_from_fft_template(self.template, image, **kwargs) + 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""" @@ -290,7 +194,6 @@ class Tracker(): stage_pos = self.get_position() self._image_positions.append(image_pos) self._stage_positions.append(stage_pos) - self._last_point = (image, image_pos) return stage_pos, image_pos @property @@ -306,7 +209,7 @@ class Tracker(): @property def history(self): """Return arrays of stage, image positions""" - return TrackerHistory(self.stage_positions, self.image_positions) + return self.stage_positions, self.image_positions def reset_history(self, leave_first_point=False): """Reset the positions and displacements recorded""" @@ -383,3 +286,4 @@ 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/fft_image_tracking.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping/fft_image_tracking.py deleted file mode 100644 index 81b7efd0..00000000 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/fft_image_tracking.py +++ /dev/null @@ -1,168 +0,0 @@ -""" -Utility functions to track motion of a microscope using FFT-based correlation. - -Cross-correlation is a reasonable way to determine where an object is in an -image. It can also be used to track 2D motion. The Fourier Shift Theorem -relies on the fact that a correlation (or convolution) becomes a multiplication -in the Fourier domain. This means that Fast Fourier Transforms are an -efficient way to implement cross-correlation of whole images. This module -contains a number of functions to simplify tracking the motion of a microscope -stage using FFTs. - -(c) Richard Bowman 2020, released under GNU GPL v3 -No warranty, express or implied, is given with respect to this code. - -""" -import numpy as np -import logging -from array_with_attrs import ArrayWithAttrs - - -def grayscale_and_padding(image, pad=True): - """Convert to grayscale and prepare for zero padding if needed. - - The FFT-based tracking methods need grayscale images. Also, if - we are going to zero-pad, we should convert to floating point and - ensure the mean of the image is zero, otherwise the dominant feature - will be the edge of the image. - - Returns: - image, fft_shape - """ - if len(image.shape) == 3: - image = np.mean(image, axis=2) - fft_shape = np.array(image.shape) - if pad: - image = image.astype(np.float) - np.mean(image) - fft_shape *= 2 - return image, fft_shape - -def high_pass_fourier_mask(shape, s, rfft=True): - """Generate a mask performing a high pass filter - - The return value is a 2D array, which can be multiplied - with the Fourier Transform of an image to perform a high - pass filter. - - Arguments: - shape: tuple of 2 integers - The shape of the output array - s: float - The standard deviation of the Gaussian in real - space, in pixels - """ - high_pass_filter = np.ones(shape) - x, y = (np.arange(n, dtype=np.float) for n in shape) - # Beyond the halfway point of the array, frequencies are negative - x[x.shape[0]//2:x.shape[0]] -= x.shape[0] - if not rfft: # If it's a real fft, the last axis is halved so we can skip this. - y[y.shape[0]//2:y.shape[0]] -= y.shape[0] - x /= np.max(np.abs(x)) * 2 # Normalise so highest frequency is 1/2 - y /= np.max(np.abs(y)) * 2 # Normalise so highest frequency is 1/2 - r2 = x[:, np.newaxis]**2 + y[np.newaxis, :]**2 - # now we multiply by 1-FT(Gaussian kernel with sd of s pixels) - high_pass_filter -= np.exp(-2*np.pi**2*s**2*r2) - return high_pass_filter - -def high_pass_fft_template(image, sigma=10, pad=True, calculate_peak=True): - """Calculate a high-pass-filtered FT template for tracking - - This performs a real FFT, and then attenuates low frequencies. - The resulting array can be used as a template for tracking. - - sigma is the standard deviation in pixels of the Gaussian used - in the high pass filter. - - pad enables (default) zero padding - this removes the ambiguity - around position, at the cost of making the function slower. We - subtract the mean and zero-pad the input array (equivalent to - padding with the mean value, to reduce the impact of the edge) - - calculate computes the value of the brightest pixel we'd expect - in a correlation image (i.e. the peak if we correlate the image - passed in with the template we're generating). This is stored - in ``template.attrs["peak_correlation_value"]`` - """ - image, fft_shape = grayscale_and_padding(image, pad) - initial_fft = np.fft.rfft2(image, s=fft_shape) # NB rfft2 is faster, but a different shape! - high_pass_filter = high_pass_fourier_mask(initial_fft.shape, sigma) - if calculate_peak: - expected_peak = np.mean(np.conj(initial_fft) * high_pass_filter * initial_fft) - template = ArrayWithAttrs(np.conj(initial_fft) * high_pass_filter) - template.attrs["maximum_correlation_value"] = expected_peak - return template - return np.conj(initial_fft) * high_pass_filter - -def background_subtracted_centre_of_mass(corr, fractional_threshold=0.05, quadrant_swap=False): - """Carry out a background subtracted centre of mass measurement - - Arguments: - corr: a 2D numpy array, to be thresholded - fractional_threshold: the fraction of the range (from - min(corr) to max(corr)) that should remain above - the background level. 1 means no thresholding, - 0.05 means use only the top 5% of the range. - quadrant_swap: boolean, default False - Set this to true if we are working on the output of - a Fourier transform. This will adjust the coordinates - such that we effectively perform quadrant swapping, to - place the DC component in the centre of the image, and - make the coordinate (0,0) correspond to that point, with - positive and negative coordinates either side. - """ - assert corr.dtype == np.float, "The image must be floating point" - background = np.max(corr) - fractional_threshold * (np.max(corr) - np.min(corr)) - background_subtracted = corr - background - background_subtracted[background_subtracted < 0] = 0 - xs, ys = (np.arange(n) for n in corr.shape) # This is equivalent to meshgrid, more or less... - if quadrant_swap: - xs[len(xs)//2:] -= len(xs) - ys[len(ys)//2:] -= len(ys) - x = np.sum(background_subtracted * xs[:, np.newaxis]) - y = np.sum(background_subtracted * ys[np.newaxis, :]) - I = np.sum(background_subtracted) - return np.array([x/I, y/I]) - -class TrackingError(Exception): - pass - -def displacement_from_fft_template(template, image, fractional_threshold=0.1, pad=True, return_peak=False, error_threshold=0): - """Find the displacement, in pixels, of an image from a template - - The template should be generated by ``high_pass_fft_template`` - Fractional_threshold is the fraction of the range (from max to min) - of the cross-correlation image that should remain above the threshold - before finding the peak by centre-of-mass. - - NB because of the periodic boundary conditions of the FFT, this gives - a result that is ambiguous - it's only accurate modulo one image. - The result that is returned represents the smalles displacement, - positive or negative. You may add or subtract one whole image-width - (or height) if that makes sense - use other cues to resolve the - ambiguity. - - return_peak returns the brightes pixel in the correlation image, as - well as the displacement in a tuple. - - error_threshold is an optional floating-point number between 0 and 1. - Setting it to a value greater than 0 will compare the correlation value - with the maximum possible. If the ratio of the current signal to the - maximum drops below ``error_threshold``, we raise a ``TrackingError`` - exception. - """ - image, fft_shape = grayscale_and_padding(image, pad) - # The template is already Fourier transformed and high pass filtered. - # so multiplying the two in Fourier space performs the convolution. - corr = np.fft.irfft2(template * np.fft.rfft2(image, s=fft_shape)) - if error_threshold > 0: - if np.max(corr)/template.attrs["maximum_correlation_value"] < error_threshold: - raise TrackingError("The correlation signal dropped below the threshold set.") - displacement = background_subtracted_centre_of_mass(corr, fractional_threshold, quadrant_swap=True) - if return_peak: - return displacement, np.max(corr) - return displacement - -def displacement_between_images(image_0, image_1, sigma=10, fractional_threshold=0.1, pad=True): - """Calculate the displacement, in pixels, between two images.""" - return displacement_from_fft_template(high_pass_fft_template(image_0, sigma, pad=pad), - image_1, fractional_threshold, pad=pad) From ef6f428a84df4ed2bd573d2dfe4051700ad9126d Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Thu, 30 Apr 2020 17:46:59 +0100 Subject: [PATCH 3/3] Updated dependencies --- poetry.lock | 106 +++++++++++++++++++++++++++++++++---------------- pyproject.toml | 9 +++-- 2 files changed, 78 insertions(+), 37 deletions(-) diff --git a/poetry.lock b/poetry.lock index f6ca48d9..19c1ef24 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" @@ -279,7 +279,7 @@ description = "Python implementation of LabThings, based on the Flask microframe name = "labthings" optional = false python-versions = ">=3.6,<4.0" -version = "0.5.2" +version = "0.5.3" [package.dependencies] Flask = ">=1.1.1,<2.0.0" @@ -313,12 +313,12 @@ description = "A lightweight library for converting complex datatypes to and fro name = "marshmallow" optional = false python-versions = ">=3.5" -version = "3.5.1" +version = "3.5.2" [package.extras] -dev = ["pytest", "pytz", "simplejson", "mypy (0.761)", "flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)", "tox"] -docs = ["sphinx (2.4.3)", "sphinx-issues (1.2.0)", "alabaster (0.7.12)", "sphinx-version-warning (1.1.2)"] -lint = ["mypy (0.761)", "flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)"] +dev = ["pytest", "pytz", "simplejson", "mypy (0.770)", "flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)", "tox"] +docs = ["sphinx (3.0.3)", "sphinx-issues (1.2.0)", "alabaster (0.7.12)", "sphinx-version-warning (1.1.2)"] +lint = ["mypy (0.770)", "flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)"] tests = ["pytest", "pytz", "simplejson"] [[package]] @@ -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 = "78984c9fc264ae453f51e49d12d3d70e663b7d2a0d3a1829529339904d0c5c00" 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"}, @@ -890,8 +903,8 @@ jinja2 = [ {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, ] labthings = [ - {file = "labthings-0.5.2-py3-none-any.whl", hash = "sha256:d7bc6a7566e8b53bbe44f9979cdaa82240b67e76d6414beabc2ccce5b8680c96"}, - {file = "labthings-0.5.2.tar.gz", hash = "sha256:5816c9e924142e0a8b7ca1ed752a9efcd2724afe6da616e06d220ffc05c949e8"}, + {file = "labthings-0.5.3-py3-none-any.whl", hash = "sha256:86ab087945fd1f824b1c1d6f8e05a7d149df5da5be650746a8432837ccc644b9"}, + {file = "labthings-0.5.3.tar.gz", hash = "sha256:dc521e49d69031ef2754fa31c4f246b9b5c6aaed00f5d622c0a877970a8b9721"}, ] lazy-object-proxy = [ {file = "lazy-object-proxy-1.4.3.tar.gz", hash = "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"}, @@ -952,8 +965,8 @@ markupsafe = [ {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] marshmallow = [ - {file = "marshmallow-3.5.1-py2.py3-none-any.whl", hash = "sha256:ac2e13b30165501b7d41fc0371b8df35944f5849769d136f20e2c5f6cdc6e665"}, - {file = "marshmallow-3.5.1.tar.gz", hash = "sha256:90854221bbb1498d003a0c3cc9d8390259137551917961c8b5258c64026b2f85"}, + {file = "marshmallow-3.5.2-py2.py3-none-any.whl", hash = "sha256:f12203bf8d94c410ab4b8d66edfde4f8a364892bde1f6747179765559f93d62a"}, + {file = "marshmallow-3.5.2.tar.gz", hash = "sha256:56663fa1d5385c14c6a1236badd166d6dee987a5f64d2b6cc099dadf96eb4f09"}, ] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, @@ -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..867c28e4 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 until 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,8 +35,11 @@ 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" -labthings = "0.5.2" +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.3" [tool.poetry.extras] rpi = ["picamera", "RPi.GPIO"]