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