From c2404537620f026da993d6bb8634f51d6d4f3f31 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 12 Mar 2020 16:02:38 +0000 Subject: [PATCH 01/10] Fix a working version of opencv --- poetry.lock | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 07cee8f4..c9cce15e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -284,6 +284,7 @@ webargs = "^5.5.3" reference = "028e21cb5b53cc8feeea3af7a0579fde67a54daf" type = "git" url = "https://github.com/labthings/python-labthings.git" + [[package]] category = "dev" description = "A fast and thorough lazy object proxy." @@ -330,6 +331,17 @@ optional = false python-versions = ">=3.5" version = "1.18.1" +[[package]] +category = "main" +description = "Wrapper package for OpenCV python bindings." +name = "opencv-python-headless" +optional = false +python-versions = "*" +version = "3.4.7.28" + +[package.dependencies] +numpy = ">=1.11.1" + [[package]] category = "dev" description = "Core utilities for Python packages" @@ -359,6 +371,7 @@ test = ["coverage", "pytest", "mock", "pillow", "numpy"] reference = "79177fa7f28d6d5c6eab5ba814b420b1785b6b24" type = "git" url = "https://github.com/rwb27/picamera.git" + [[package]] category = "main" description = "Python Imaging Library (Fork)" @@ -700,7 +713,7 @@ version = "1.11.2" rpi = ["picamera", "RPi.GPIO"] [metadata] -content-hash = "ad29f5c7d3a9ac55697f3d74f7b1c992cb2617039cbcab23caa0221f8ed4718a" +content-hash = "c32b040f9990c0acaf6d1c984d503a6fa86a6d2f6eee51301dbdde7a5b176d82" python-versions = "^3.6" [metadata.files] @@ -915,6 +928,11 @@ markupsafe = [ {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] marshmallow = [ @@ -948,6 +966,35 @@ numpy = [ {file = "numpy-1.18.1-cp38-cp38-win_amd64.whl", hash = "sha256:39d2c685af15d3ce682c99ce5925cc66efc824652e10990d2462dfe9b8918c6a"}, {file = "numpy-1.18.1.zip", hash = "sha256:b6ff59cee96b454516e47e7721098e6ceebef435e3e21ac2d6c3b8b02628eb77"}, ] +opencv-python-headless = [ + {file = "opencv_python_headless-3.4.7.28-cp27-cp27m-macosx_10_8_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:4685d447d3d7c361768e6b91224a757981b6513212b6693b9d63ca5705d42555"}, + {file = "opencv_python_headless-3.4.7.28-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:6d272a4c5c62f6e4fcb3d3971404deee21b899a7409660b99f0d571c5f0a979c"}, + {file = "opencv_python_headless-3.4.7.28-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:b24eacedc393d59779d68ba79132e6960201cb21721c82e39e5bfbc4fa19614a"}, + {file = "opencv_python_headless-3.4.7.28-cp27-cp27m-win32.whl", hash = "sha256:421206fbfadd96bc28ededceffd986d31d9c3e71e65828544fb3ee823a620609"}, + {file = "opencv_python_headless-3.4.7.28-cp27-cp27m-win_amd64.whl", hash = "sha256:52e0975f0cc85ba367399b93111f6592275fced9a1c0a432bac123112c248ec3"}, + {file = "opencv_python_headless-3.4.7.28-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:9ed902a2c835bf38c217688ac7912f405e1ac3b08cf85a77a4ac639a40957043"}, + {file = "opencv_python_headless-3.4.7.28-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:520410e6cf0440841bb044297d2a066cc759bf7f2792f21ff263340ced8eb4d2"}, + {file = "opencv_python_headless-3.4.7.28-cp34-cp34m-macosx_10_8_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:d82589bc4fe39cfe324db62543faf924f8fa433454e7ae50543099f74103eac7"}, + {file = "opencv_python_headless-3.4.7.28-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:7bba62609677685c3e042e4b053d15a12fe43de961897830787e01f4df8e05fe"}, + {file = "opencv_python_headless-3.4.7.28-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:44ce92ab0b8a1f137363f501c212c7b9aabec16c1d98d425908111984027b37b"}, + {file = "opencv_python_headless-3.4.7.28-cp34-cp34m-win32.whl", hash = "sha256:92a9d97102747864f42ec9c95d5e18f9e97c7b255edad8b635cca3dc6bac09b2"}, + {file = "opencv_python_headless-3.4.7.28-cp34-cp34m-win_amd64.whl", hash = "sha256:e0700402a59b2ece63486a845bf5add2217d5862a102c621b9c8c69129c3c4ee"}, + {file = "opencv_python_headless-3.4.7.28-cp35-cp35m-macosx_10_8_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:dd280e7efa4791b42a4f98876a0627e2178db78e39313c477d294789c5169c88"}, + {file = "opencv_python_headless-3.4.7.28-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:932b5b301ce737f491b77c5fd22501f50481629dd2a658942fcbd3198a1303b7"}, + {file = "opencv_python_headless-3.4.7.28-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:e37fb0c29e6d06e158d2db2541ccf4cf11617e855b50c03fb1d0854055553229"}, + {file = "opencv_python_headless-3.4.7.28-cp35-cp35m-win32.whl", hash = "sha256:d0f30f651fbf9a18c8d31ee7bf36ce4abe7265061ff22f4079c41d979068b3ff"}, + {file = "opencv_python_headless-3.4.7.28-cp35-cp35m-win_amd64.whl", hash = "sha256:764de5500b41142e6a7849c6301e133bff645dda17c642ebf66d5ae76c4a7632"}, + {file = "opencv_python_headless-3.4.7.28-cp36-cp36m-macosx_10_8_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:33c4b045449ce63f10151b8e0dfd17d7ecc2f67c7a9025d0494e142d13eaf814"}, + {file = "opencv_python_headless-3.4.7.28-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:ec2f5660766109c395df8e6b3da626d47a7b0a1e6932be803866e1ae83e98494"}, + {file = "opencv_python_headless-3.4.7.28-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:b40f25be422e3067400545c94efbfccb9181b97ac5c78bd5a22bd5891f8eeb2c"}, + {file = "opencv_python_headless-3.4.7.28-cp36-cp36m-win32.whl", hash = "sha256:0947d34faf0399162764f17cd9c2e100cc63d14680c1de2a302ab69fee54e7a1"}, + {file = "opencv_python_headless-3.4.7.28-cp36-cp36m-win_amd64.whl", hash = "sha256:c8b5a24d00545ecc615976a52ed8158b6cc405ea07e3772c23c423177a5ff59c"}, + {file = "opencv_python_headless-3.4.7.28-cp37-cp37m-macosx_10_8_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:e135f4e747ee59160c1bfb5e870bcb41aa49278a8034e9654589ec2e06fb6dc3"}, + {file = "opencv_python_headless-3.4.7.28-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:55659824e8c40ca2675f4a3c0f38798e0feca151a8261c62670b5c2f99d2e29c"}, + {file = "opencv_python_headless-3.4.7.28-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2a8acc0037a1869390f3ade328cd302b53ba9c46f02446e23563e3b9ceb1efee"}, + {file = "opencv_python_headless-3.4.7.28-cp37-cp37m-win32.whl", hash = "sha256:23c13e3d9b28c90f0e5990070a4764d0d2324e3ebf709c3fe4036575f17c5c01"}, + {file = "opencv_python_headless-3.4.7.28-cp37-cp37m-win_amd64.whl", hash = "sha256:dd4d98fb7d3dcb6f4a2e4e0a402ebfa1c4a73bada764af19ff3c4e473da975f4"}, +] packaging = [ {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, diff --git a/pyproject.toml b/pyproject.toml index ee526317..aa1c28a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ pyserial = "^3.4" # Used for sangaboard (basic_serial_instrument) until we move labthings = { git = "https://github.com/labthings/python-labthings.git", branch = "master"} # TODO: Pin to a fixed version before 2.0.0 stable python-dateutil = "^2.8" psutil = "^5.6.7" # Autostorage extension +opencv-python-headless = "3.4.7.28" [tool.poetry.extras] rpi = ["picamera", "RPi.GPIO"] From e452d623cae11afeb3f18cf84e640905f2e05603 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 12 Mar 2020 16:03:11 +0000 Subject: [PATCH 02/10] First working camera stage mapping calibration --- .../api/default_extensions/__init__.py | 1 + .../camera_stage_mapping/__init__.py | 1 + .../camera_stage_mapping/array_with_attrs.py | 95 ++++++ .../camera_stage_calibration_1d.py | 279 +++++++++++++++++ .../camera_stage_calibration_2d.py | 81 +++++ .../camera_stage_tracker.py | 290 ++++++++++++++++++ .../camera_stage_mapping/extension.py | 94 ++++++ .../image_with_location.py | 237 ++++++++++++++ 8 files changed, 1078 insertions(+) create mode 100644 openflexure_microscope/api/default_extensions/camera_stage_mapping/__init__.py create mode 100644 openflexure_microscope/api/default_extensions/camera_stage_mapping/array_with_attrs.py create mode 100644 openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_calibration_1d.py create mode 100644 openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_calibration_2d.py create mode 100644 openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_tracker.py create mode 100644 openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py create mode 100644 openflexure_microscope/api/default_extensions/camera_stage_mapping/image_with_location.py diff --git a/openflexure_microscope/api/default_extensions/__init__.py b/openflexure_microscope/api/default_extensions/__init__.py index 577f10ba..26535c47 100644 --- a/openflexure_microscope/api/default_extensions/__init__.py +++ b/openflexure_microscope/api/default_extensions/__init__.py @@ -5,6 +5,7 @@ from .autofocus import autofocus_extension_v2 from .scan import scan_extension_v2 from .zip_builder import zip_extension_v2 from .autostorage import autostorage_extension_v2 +from .camera_stage_mapping import csm_extension # "Gracefully" handle cases where picamera cannot be imported (eg test server) try: diff --git a/openflexure_microscope/api/default_extensions/camera_stage_mapping/__init__.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping/__init__.py new file mode 100644 index 00000000..4e829bec --- /dev/null +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/__init__.py @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..61fe164a --- /dev/null +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/array_with_attrs.py @@ -0,0 +1,95 @@ +# -*- 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 \ No newline at end of file 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 new file mode 100644 index 00000000..12554440 --- /dev/null +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_calibration_1d.py @@ -0,0 +1,279 @@ +""" +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), + } \ No newline at end of file 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 new file mode 100644 index 00000000..6e189b0f --- /dev/null +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_calibration_2d.py @@ -0,0 +1,81 @@ +""" +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] step + print(f"Ratio of residuals to displacement is {fractional_error})") + if fractional_error > 0.05: # Check it was a reasonably good fit + print("Warning: the error fitting measured displacements was %.1f%%" % (fractional_error*100)) + print(f"Calibrated the pixel-location matrix.\nResiduals were {fractional_error*100:.1f}% of the shift.") + + return { + "image_to_stage_displacement": A, + "moves": (stage_positions, image_positions), + "fractional_error": fractional_error + } + 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 new file mode 100644 index 00000000..7e14bf77 --- /dev/null +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/camera_stage_tracker.py @@ -0,0 +1,290 @@ +""" +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 +from matplotlib import pyplot as plt +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. + +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.]) + 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) + \ No newline at end of file diff --git a/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py new file mode 100644 index 00000000..25e7121e --- /dev/null +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py @@ -0,0 +1,94 @@ +""" +API extension for stage calibration + +This file contains the HTTP API for camera/stage calibration. +""" +from labthings.server.view import View +from labthings.server.find import find_component +from labthings.server.extensions import BaseExtension +from labthings.server.decorators import marshal_task, ThingAction, use_args +from labthings.server import fields + +from labthings.core.tasks import taskify +from labthings.core.utilities import get_by_path, set_by_path, create_from_path + + +from flask import abort + +import logging +import time +import numpy as np +import PIL +import io + +from .camera_stage_calibration_1d import calibrate_backlash_1d, image_to_stage_displacement_from_1d +from .camera_stage_tracker import Tracker + +def update_extension_settings(microscope, settings): + """Update the stored extension settings dictionary""" + keys = ["extensions","org.openflexure.camera_stage_mapping"] + dictionary = create_from_path(keys) + set_by_path(dictionary, keys, settings) + + microscope.update_settings(dictionary) + microscope.save_settings() + +def get_extension_settings(microscope): + """Retrieve the settings for this extension""" + keys = ["extensions","org.openflexure.camera_stage_mapping"] + return get_by_path(microscope.read_settings, keys) + +def camera_stage_functions(microscope): + """Return functions that allow us to interface with the microscope""" + microscope.camera.start_worker() # ensure the worker thread is running, so there is an MJPEG stream + + def grab_image(): + jpeg = microscope.camera.get_frame() + return np.array(PIL.Image.open(io.BytesIO(jpeg))) + + def get_position(): + return microscope.stage.position + + move = microscope.stage.move_abs + + return grab_image, get_position, move + +def calibrate_1d(microscope, direction): + """Move a microscope's stage in 1D, and figure out the relationship with the camera""" + grab_image, get_position, move = camera_stage_functions(microscope) + + def wait(): + time.sleep(0.2) + + tracker = Tracker(grab_image, get_position, settle=wait) + + return calibrate_backlash_1d(tracker, move, direction) + + + +@ThingAction +class Calibrate1DView(View): + @use_args({ + "direction": fields.List(fields.Float(), required=True, example=[1,0,0]) + }) + @marshal_task + def post(self, args): + """Calibrate one axis of the microscope stage against the camera.""" + microscope = find_component("org.openflexure.microscope") + + direction = np.array(args.get("direction")) + + task = taskify(calibrate_1d)(microscope, direction) + + return task + + + + + +csm_extension = BaseExtension("org.openflexure.camera_stage_mapping", version="0.0.1") + +csm_extension.add_method(calibrate_1d, "calibrate_1d") +#csm_extension.add_method(calibrate_xy, "calibrate_xy") + +csm_extension.add_view(Calibrate1DView, "/calibrate_1d") \ No newline at end of file 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 new file mode 100644 index 00000000..baef69d6 --- /dev/null +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/image_with_location.py @@ -0,0 +1,237 @@ +""" +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. + + +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!") + From d915625113aab6423c4f294391c815e6ee4a3f9b Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 12 Mar 2020 16:43:18 +0000 Subject: [PATCH 03/10] Added a kludgy extensions settings dictionary. --- openflexure_microscope/microscope.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index 3ed73568..f3e45650 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -40,6 +40,8 @@ class Microscope: self.settings_file = settings self.configuration_file = configuration + self.extension_settings = {} + # Initialise with an empty composite lock #: :py:class:`labthings.lock.CompositeLock`: Composite lock for locking both camera and stage self.lock = CompositeLock([]) @@ -157,6 +159,12 @@ class Microscope: if "fov" in settings: self.fov = settings["fov"] + # Extension settings + if "extensions" in settings: + self.extension_settings.update(settings["extensions"]) + + # TODO: warn if there are settings that we silently ignore + def read_settings(self, full: bool = True): """ Get an updated settings dictionary. @@ -168,7 +176,7 @@ class Microscope: don't get removed from the settings file. """ - settings_current = {"id": self.id, "name": self.name, "fov": self.fov} + settings_current = {"id": self.id, "name": self.name, "fov": self.fov, "extensions": self.extension_settings} # If attached to a camera if self.camera: From 5d5cc7a72aa63f02ac4f5c175c703dcd8db30e1f Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 12 Mar 2020 17:51:24 +0000 Subject: [PATCH 04/10] Added persistent settings and moves --- .../camera_stage_mapping/extension.py | 155 +++++++++++++----- 1 file changed, 116 insertions(+), 39 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py index 25e7121e..9d0929c2 100644 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py @@ -13,7 +13,7 @@ from labthings.core.tasks import taskify from labthings.core.utilities import get_by_path, set_by_path, create_from_path -from flask import abort +from flask import abort, jsonify import logging import time @@ -24,46 +24,105 @@ import io from .camera_stage_calibration_1d import calibrate_backlash_1d, image_to_stage_displacement_from_1d from .camera_stage_tracker import Tracker -def update_extension_settings(microscope, settings): - """Update the stored extension settings dictionary""" - keys = ["extensions","org.openflexure.camera_stage_mapping"] - dictionary = create_from_path(keys) - set_by_path(dictionary, keys, settings) +from openflexure_microscope.utilities import axes_to_array - microscope.update_settings(dictionary) - microscope.save_settings() -def get_extension_settings(microscope): - """Retrieve the settings for this extension""" - keys = ["extensions","org.openflexure.camera_stage_mapping"] - return get_by_path(microscope.read_settings, keys) +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", + ) -def camera_stage_functions(microscope): - """Return functions that allow us to interface with the microscope""" - microscope.camera.start_worker() # ensure the worker thread is running, so there is an MJPEG stream + _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 grab_image(): - jpeg = microscope.camera.get_frame() - return np.array(PIL.Image.open(io.BytesIO(jpeg))) - - def get_position(): - return microscope.stage.position - - move = microscope.stage.move_abs + 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() - return grab_image, get_position, move + def get_settings(self): + """Retrieve the settings for this extension""" + keys = ["extensions",self.name] + return get_by_path(self.microscope.read_settings(), keys) -def calibrate_1d(microscope, direction): - """Move a microscope's stage in 1D, and figure out the relationship with the camera""" - grab_image, get_position, move = camera_stage_functions(microscope) + 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 - def wait(): - time.sleep(0.2) + return grab_image, get_position, move - tracker = Tracker(grab_image, get_position, settle=wait) + 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() - return calibrate_backlash_1d(tracker, move, direction) + 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) + return { + "camera_stage_mapping_calibration": cal_xy, + "linear_calibration_x": cal_x, + "linear_calibration_y": cal_y, + } + + @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() + +#csm_extension.add_method(calibrate_1d, "calibrate_1d") # Not needed any more?? +#csm_extension.add_method(calibrate_xy, "calibrate_xy") @ThingAction @@ -74,21 +133,39 @@ class Calibrate1DView(View): @marshal_task def post(self, args): """Calibrate one axis of the microscope stage against the camera.""" - microscope = find_component("org.openflexure.microscope") direction = np.array(args.get("direction")) - task = taskify(calibrate_1d)(microscope, direction) + task = taskify(csm_extension.calibrate_1d)(direction) return task +csm_extension.add_view(Calibrate1DView, "/calibrate_1d") + +@ThingAction +class CalibrateXYView(View): + @marshal_task + def post(self): + """Calibrate both axes of the microscope stage against the camera.""" + task = taskify(csm_extension.calibrate_xy)() + + return task + +csm_extension.add_view(CalibrateXYView, "/calibrate_xy") +@ThingAction +class MoveInImageCoordinatesView(View): + @use_args({ + "x": fields.Float(),#description="The number of pixels to move in X", required=True, example=100), + "y": fields.Float(),#description="The number of pixels to move in Y", required=True, example=100), + }) + 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 jsonify(csm_extension.microscope.state["stage"]["position"]) +csm_extension.add_view(MoveInImageCoordinatesView, "/move_in_image_coordinates") -csm_extension = BaseExtension("org.openflexure.camera_stage_mapping", version="0.0.1") - -csm_extension.add_method(calibrate_1d, "calibrate_1d") -#csm_extension.add_method(calibrate_xy, "calibrate_xy") - -csm_extension.add_view(Calibrate1DView, "/calibrate_1d") \ No newline at end of file From 83bb06d58f2eb8d32aec89a02ea765c00bfb8759 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Fri, 27 Mar 2020 16:10:14 +0000 Subject: [PATCH 05/10] moved to a working version of opencv --- poetry.lock | 65 ++++++++++++++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/poetry.lock b/poetry.lock index da543ef1..2131dc03 100644 --- a/poetry.lock +++ b/poetry.lock @@ -341,7 +341,7 @@ description = "Wrapper package for OpenCV python bindings." name = "opencv-python-headless" optional = false python-versions = "*" -version = "3.4.7.28" +version = "4.1.0.25" [package.dependencies] numpy = ">=1.11.1" @@ -369,7 +369,7 @@ version = "1.13.2b0" [package.extras] array = ["numpy"] doc = ["sphinx"] -test = ["coverage", "pytest", "mock", "pillow", "numpy"] +test = ["pillow", "coverage", "mock", "numpy", "pytest"] [package.source] reference = "79177fa7f28d6d5c6eab5ba814b420b1785b6b24" @@ -727,7 +727,7 @@ ifaddr = "*" rpi = ["picamera", "RPi.GPIO"] [metadata] -content-hash = "71b15705721aa7447357d24e79c6ff77f6844b4191eceb5e90a14942c5375b91" +content-hash = "244c3bc80be3abde924d83e6287e16078d3e3f96c3aba0f09942b2fdc354c2e2" python-versions = "^3.6" [metadata.files] @@ -948,6 +948,11 @@ markupsafe = [ {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] marshmallow = [ @@ -982,33 +987,33 @@ numpy = [ {file = "numpy-1.18.2.zip", hash = "sha256:e7894793e6e8540dbeac77c87b489e331947813511108ae097f1715c018b8f3d"}, ] opencv-python-headless = [ - {file = "opencv_python_headless-3.4.7.28-cp27-cp27m-macosx_10_8_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:4685d447d3d7c361768e6b91224a757981b6513212b6693b9d63ca5705d42555"}, - {file = "opencv_python_headless-3.4.7.28-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:6d272a4c5c62f6e4fcb3d3971404deee21b899a7409660b99f0d571c5f0a979c"}, - {file = "opencv_python_headless-3.4.7.28-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:b24eacedc393d59779d68ba79132e6960201cb21721c82e39e5bfbc4fa19614a"}, - {file = "opencv_python_headless-3.4.7.28-cp27-cp27m-win32.whl", hash = "sha256:421206fbfadd96bc28ededceffd986d31d9c3e71e65828544fb3ee823a620609"}, - {file = "opencv_python_headless-3.4.7.28-cp27-cp27m-win_amd64.whl", hash = "sha256:52e0975f0cc85ba367399b93111f6592275fced9a1c0a432bac123112c248ec3"}, - {file = "opencv_python_headless-3.4.7.28-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:9ed902a2c835bf38c217688ac7912f405e1ac3b08cf85a77a4ac639a40957043"}, - {file = "opencv_python_headless-3.4.7.28-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:520410e6cf0440841bb044297d2a066cc759bf7f2792f21ff263340ced8eb4d2"}, - {file = "opencv_python_headless-3.4.7.28-cp34-cp34m-macosx_10_8_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:d82589bc4fe39cfe324db62543faf924f8fa433454e7ae50543099f74103eac7"}, - {file = "opencv_python_headless-3.4.7.28-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:7bba62609677685c3e042e4b053d15a12fe43de961897830787e01f4df8e05fe"}, - {file = "opencv_python_headless-3.4.7.28-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:44ce92ab0b8a1f137363f501c212c7b9aabec16c1d98d425908111984027b37b"}, - {file = "opencv_python_headless-3.4.7.28-cp34-cp34m-win32.whl", hash = "sha256:92a9d97102747864f42ec9c95d5e18f9e97c7b255edad8b635cca3dc6bac09b2"}, - {file = "opencv_python_headless-3.4.7.28-cp34-cp34m-win_amd64.whl", hash = "sha256:e0700402a59b2ece63486a845bf5add2217d5862a102c621b9c8c69129c3c4ee"}, - {file = "opencv_python_headless-3.4.7.28-cp35-cp35m-macosx_10_8_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:dd280e7efa4791b42a4f98876a0627e2178db78e39313c477d294789c5169c88"}, - {file = "opencv_python_headless-3.4.7.28-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:932b5b301ce737f491b77c5fd22501f50481629dd2a658942fcbd3198a1303b7"}, - {file = "opencv_python_headless-3.4.7.28-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:e37fb0c29e6d06e158d2db2541ccf4cf11617e855b50c03fb1d0854055553229"}, - {file = "opencv_python_headless-3.4.7.28-cp35-cp35m-win32.whl", hash = "sha256:d0f30f651fbf9a18c8d31ee7bf36ce4abe7265061ff22f4079c41d979068b3ff"}, - {file = "opencv_python_headless-3.4.7.28-cp35-cp35m-win_amd64.whl", hash = "sha256:764de5500b41142e6a7849c6301e133bff645dda17c642ebf66d5ae76c4a7632"}, - {file = "opencv_python_headless-3.4.7.28-cp36-cp36m-macosx_10_8_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:33c4b045449ce63f10151b8e0dfd17d7ecc2f67c7a9025d0494e142d13eaf814"}, - {file = "opencv_python_headless-3.4.7.28-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:ec2f5660766109c395df8e6b3da626d47a7b0a1e6932be803866e1ae83e98494"}, - {file = "opencv_python_headless-3.4.7.28-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:b40f25be422e3067400545c94efbfccb9181b97ac5c78bd5a22bd5891f8eeb2c"}, - {file = "opencv_python_headless-3.4.7.28-cp36-cp36m-win32.whl", hash = "sha256:0947d34faf0399162764f17cd9c2e100cc63d14680c1de2a302ab69fee54e7a1"}, - {file = "opencv_python_headless-3.4.7.28-cp36-cp36m-win_amd64.whl", hash = "sha256:c8b5a24d00545ecc615976a52ed8158b6cc405ea07e3772c23c423177a5ff59c"}, - {file = "opencv_python_headless-3.4.7.28-cp37-cp37m-macosx_10_8_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:e135f4e747ee59160c1bfb5e870bcb41aa49278a8034e9654589ec2e06fb6dc3"}, - {file = "opencv_python_headless-3.4.7.28-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:55659824e8c40ca2675f4a3c0f38798e0feca151a8261c62670b5c2f99d2e29c"}, - {file = "opencv_python_headless-3.4.7.28-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2a8acc0037a1869390f3ade328cd302b53ba9c46f02446e23563e3b9ceb1efee"}, - {file = "opencv_python_headless-3.4.7.28-cp37-cp37m-win32.whl", hash = "sha256:23c13e3d9b28c90f0e5990070a4764d0d2324e3ebf709c3fe4036575f17c5c01"}, - {file = "opencv_python_headless-3.4.7.28-cp37-cp37m-win_amd64.whl", hash = "sha256:dd4d98fb7d3dcb6f4a2e4e0a402ebfa1c4a73bada764af19ff3c4e473da975f4"}, + {file = "opencv_python_headless-4.1.0.25-cp27-cp27m-macosx_10_7_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:a71b5bdb39c99d706493b5dc4fd7cb803ece6ac54b9115ac7aa3c4dfb1338348"}, + {file = "opencv_python_headless-4.1.0.25-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:84ed23093b5da546ae0adf36182d3893940ea1f8ba38ea84f6d13ee9220bcff8"}, + {file = "opencv_python_headless-4.1.0.25-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:e3aa46a96c5b65eab349266aecfcc3297f3ac59b86314c1507558dcb978baa02"}, + {file = "opencv_python_headless-4.1.0.25-cp27-cp27m-win32.whl", hash = "sha256:13a9746459029c0bd64713ec81b5a57aad6e1a5dc672ee1e44708a46f5f099f6"}, + {file = "opencv_python_headless-4.1.0.25-cp27-cp27m-win_amd64.whl", hash = "sha256:ac059ce7c4a8162b208d403c4418ea951e354416339ad6859b47ddc80ba9d0a8"}, + {file = "opencv_python_headless-4.1.0.25-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:40fcb7de9d92b1b69e03f7de9d5d976f2cbd34eabe7df3f12c41d525e51b49d7"}, + {file = "opencv_python_headless-4.1.0.25-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:af4ccd244398d7cee5f416591b8672ae38c4da278bd89b449c0ee4d1d8391e03"}, + {file = "opencv_python_headless-4.1.0.25-cp34-cp34m-macosx_10_7_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:2444b3f351182f0707cafab76b935134a2ee94579e669b7dae14648acd243e93"}, + {file = "opencv_python_headless-4.1.0.25-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:fd119b75ceec1e64a3ff9bad0096445851bc8e3ba89cdccc11cf80db2244c2df"}, + {file = "opencv_python_headless-4.1.0.25-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:b31f50e6dfbeaa40164630dd3b4f3274d0378da39c6ad85e172cecc66b4074b4"}, + {file = "opencv_python_headless-4.1.0.25-cp34-cp34m-win32.whl", hash = "sha256:2504f27f53d419d2dbffb0390d512d666bf9aa0eb66114b4b81e1ed266282e8e"}, + {file = "opencv_python_headless-4.1.0.25-cp34-cp34m-win_amd64.whl", hash = "sha256:6e1f82e8a8d171bcb3dec08404e824aaedca9f12d9b957893c3c0f76d7aa5243"}, + {file = "opencv_python_headless-4.1.0.25-cp35-cp35m-macosx_10_7_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:8691c1b8059ad760f776eb7b87b1ec1202832ef0938497f5e8b8c857ea3ed8ce"}, + {file = "opencv_python_headless-4.1.0.25-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:536b3f25cbe832a89dd84e526a6f7a262cb98d9b4fb566bf63438b7165979abc"}, + {file = "opencv_python_headless-4.1.0.25-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:94e84603a0f3dbf869fb166d0ec086adfcbaa8f97957d8b68b9788168d1462d4"}, + {file = "opencv_python_headless-4.1.0.25-cp35-cp35m-win32.whl", hash = "sha256:408428eb7c7d8075b915ba86012177cedc4018bc3ff69b018f33042a86c8f208"}, + {file = "opencv_python_headless-4.1.0.25-cp35-cp35m-win_amd64.whl", hash = "sha256:5193fd3608ac9e6382df74c04016bdd55ff913328660b4e2419508fc0e88386e"}, + {file = "opencv_python_headless-4.1.0.25-cp36-cp36m-macosx_10_7_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:968587e6f251128830fd449b87829e8be1d7d35d5263c7a6d2698a58dd8e84ff"}, + {file = "opencv_python_headless-4.1.0.25-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:57a9815214a420180471521265687413e057e8ee19fa3f30ca5d5f4ac1f41ffa"}, + {file = "opencv_python_headless-4.1.0.25-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3dacdf4c5f9b17bd0e763c0e4399bc516bc9027ef6fac2dd1b2547a4f32ceefc"}, + {file = "opencv_python_headless-4.1.0.25-cp36-cp36m-win32.whl", hash = "sha256:3a30ad348e8cfcab5ded9e927220bc07b18d4a26ebde20c323a35054693d91ae"}, + {file = "opencv_python_headless-4.1.0.25-cp36-cp36m-win_amd64.whl", hash = "sha256:905c08a483137f2edd362d46420bc1c88f026b4889840b1af8b43ad29e7c2a9c"}, + {file = "opencv_python_headless-4.1.0.25-cp37-cp37m-macosx_10_7_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:55a8361257c21272b08d782c7847754c664861472c72f07862c4880a6dcc1ae8"}, + {file = "opencv_python_headless-4.1.0.25-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c8b64eaca2bd59c15db31e30cbe02151162525e855ebd79efa66d789ac0f9191"}, + {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"}, ] packaging = [ {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, From 2c9e443d5d3267071c6d9f4a4add4fb6c0d02eb1 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Fri, 27 Mar 2020 16:10:35 +0000 Subject: [PATCH 06/10] moved to a working version of opencv --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fd8166e9..9fa83c2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ 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 = "3.4.7.28" +opencv-python-headless = "4.1.0.25" labthings = "0.3.0" [tool.poetry.extras] From f338b3b869fc24c3ec4b45364a95f944d20647b7 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Fri, 27 Mar 2020 16:10:49 +0000 Subject: [PATCH 07/10] Turned debug mode back off --- openflexure_microscope/api/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 2b14596f..ad12c6b5 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -179,4 +179,4 @@ if __name__ == "__main__": from labthings.server.wsgi import Server server = Server(app) - server.run(host="0.0.0.0", port=5000, debug=True, zeroconf=True) + server.run(host="0.0.0.0", port=5000, debug=False, zeroconf=True) From 14268482831c4a08cd578128306dfa73639a71d9 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Fri, 27 Mar 2020 16:11:10 +0000 Subject: [PATCH 08/10] Nicely handle errors loading default extensions --- .../api/default_extensions/__init__.py | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/__init__.py b/openflexure_microscope/api/default_extensions/__init__.py index 113ffa0c..d79fbf37 100644 --- a/openflexure_microscope/api/default_extensions/__init__.py +++ b/openflexure_microscope/api/default_extensions/__init__.py @@ -1,18 +1,30 @@ import logging import traceback +from contextlib import contextmanager -from .autofocus import autofocus_extension_v2 -from .scan import scan_extension_v2 -from .zip_builder import zip_extension_v2 -from .autostorage import autostorage_extension_v2 -from .camera_stage_mapping import csm_extension +@contextmanager +def handle_extension_error(extension_name): + """'gracefully' log an error if an extension fails to load.""" + try: + yield + except Exception as e: + logging.error( + f"Exception loading builtin extension picamera_autocalibrate: \n{traceback.format_exc()}" + ) -# "Gracefully" handle cases where picamera cannot be imported (eg test server) -try: +with handle_extension_error("autofocus"): + from .autofocus import autofocus_extension_v2 +with handle_extension_error("scan"): + from .scan import scan_extension_v2 +with handle_extension_error("zip builder"): + from .zip_builder import zip_extension_v2 +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 +with handle_extension_error("lens shading calibration"): from .picamera_autocalibrate import lst_extension_v2 -except Exception as e: - logging.error( - f"Exception loading builtin extension picamera_autocalibrate: \n{traceback.format_exc()}" - ) -from ..example_extensions.custom_element import customelement_extension_v2 +#FIXME: this oughtn't stay in the production version... +with handle_extension_error("custom element example"): + from ..example_extensions.custom_element import customelement_extension_v2 From 6e94722ff4cde40e9423a7a9a9251eafa4affd7c Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Fri, 27 Mar 2020 16:11:28 +0000 Subject: [PATCH 09/10] removed spurious matplotlib dependency --- .../camera_stage_mapping/camera_stage_tracker.py | 1 - 1 file changed, 1 deletion(-) 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 7e14bf77..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 @@ -9,7 +9,6 @@ stage, and tracking the corresponding motion with the camera. import numpy as np import time from numpy.linalg import norm -from matplotlib import pyplot as plt import cv2 from scipy import ndimage From f2768c9cadb7746f5f4393f54f520695072ceccf Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Fri, 27 Mar 2020 16:11:47 +0000 Subject: [PATCH 10/10] sorted docs and comments --- .../default_extensions/camera_stage_mapping/extension.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py b/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py index 9d0929c2..e9f8c905 100644 --- a/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py +++ b/openflexure_microscope/api/default_extensions/camera_stage_mapping/extension.py @@ -121,10 +121,6 @@ class CSMExtension(BaseExtension): csm_extension = CSMExtension() -#csm_extension.add_method(calibrate_1d, "calibrate_1d") # Not needed any more?? -#csm_extension.add_method(calibrate_xy, "calibrate_xy") - - @ThingAction class Calibrate1DView(View): @use_args({ @@ -157,8 +153,8 @@ csm_extension.add_view(CalibrateXYView, "/calibrate_xy") @ThingAction class MoveInImageCoordinatesView(View): @use_args({ - "x": fields.Float(),#description="The number of pixels to move in X", required=True, example=100), - "y": fields.Float(),#description="The number of pixels to move in Y", required=True, example=100), + "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")