diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 6f7133f2..d2931aa5 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -22,6 +22,7 @@ from openflexure_microscope.config import USER_CONFIG_DIR from openflexure_microscope.api.v1 import blueprints +import time import atexit import logging import sys @@ -59,7 +60,7 @@ else: root.setLevel(logging.getLogger("gunicorn.error").level) # Create a dummy microscope object, with no hardware attachments -api_microscope = Microscope(None, None) +api_microscope = Microscope() # Rebuild the capture list # TODO: Offload to a thread? @@ -89,13 +90,15 @@ handler = JSONExceptionHandler(app) @app.before_first_request def attach_microscope(): # Create the microscope object globally (common to all spawned server threads) - global api_microscope, capture_list + global api_microscope, stored_image_list logging.debug("First request made. Populating microscope with hardware...") logging.debug("Creating camera object...") - api_camera = StreamingCamera(config=api_microscope.rc.read()) + # TODO: Try except finally, like with stage + api_camera = StreamingCamera() logging.debug("Creating stage object...") + # TODO: Tidy this up. api_stage may be referenced before assignment. Use some form of Maybe monad? try: api_stage = SangaStage() except (SerialException, OSError) as e: @@ -166,17 +169,27 @@ def err_log(): def cleanup(): global api_microscope logging.debug("App teardown started...") + logging.debug("Settling...") + time.sleep(0.5) # Save config - if api_microscope.rc: - api_microscope.rc.save(backup=True) + logging.debug("Saving config for teardown...") + api_microscope.save_config(backup=True) + + logging.debug("Settling...") + time.sleep(0.5) # Close down the microscope + logging.debug("Closing devices...") api_microscope.close() + + logging.debug("Settling...") + time.sleep(0.5) + logging.debug("App teardown complete.") atexit.register(cleanup) if __name__ == "__main__": - app.run(host='0.0.0.0', port="5000", threaded=True, debug=True, use_reloader=False) \ No newline at end of file + app.run(host='0.0.0.0', port="5000", threaded=True, debug=True, use_reloader=False) diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py index 3af1e7b5..8dbf339f 100644 --- a/openflexure_microscope/camera/base.py +++ b/openflexure_microscope/camera/base.py @@ -5,6 +5,8 @@ import threading import datetime import logging +from abc import ABCMeta, abstractmethod + try: from greenlet import getcurrent as get_ident except ImportError: @@ -43,6 +45,11 @@ def generate_numbered_basename(obj_list: list) -> str: return basename +def shunt_captures(target_list: list): + for obj in target_list: # For each older capture + obj.shunt() # Shunt capture from memory to storage + + class CameraEvent(object): """ A frame-signaller object used by any instances or subclasses of BaseCamera. @@ -87,7 +94,7 @@ class CameraEvent(object): self.events[get_ident()][0].clear() -class BaseCamera(object): +class BaseCamera(metaclass=ABCMeta): """ Base implementation of StreamingCamera. @@ -101,7 +108,6 @@ class BaseCamera(object): stream_timeout (int): Number of inactive seconds before timing out the stream stream_timeout_enabled (bool): Enable or disable timing out the stream state (dict): Dictionary for capture state - config (dict): Dictionary of base camera config paths (dict): Dictionary of capture paths images (list): List of image capture objects videos (list): List of video capture objects @@ -115,12 +121,12 @@ class BaseCamera(object): self.frame = None self.last_access = 0 self.event = CameraEvent() + self.stop = False # Used to indicate that the stream loop should break self.stream_timeout = 20 self.stream_timeout_enabled = False self.state = {} - self.config = {} self.paths = { 'image': BASE_CAPTURE_PATH, 'video': BASE_CAPTURE_PATH, @@ -132,13 +138,15 @@ class BaseCamera(object): self.images = [] self.videos = [] - def apply_config(self, config): + @abstractmethod + def apply_config(self, config: dict): """Update settings from a config dictionary""" - self.config.update(config) + pass - def read_config(self, config): + @abstractmethod + def read_config(self): """Return the current settings as a dictionary""" - return self.config + pass def __enter__(self): """Create camera on context enter.""" @@ -176,7 +184,7 @@ class BaseCamera(object): self.last_access = time.time() self.stop = False - + if not self.state['stream_active']: # start background frame thread self.thread = threading.Thread(target=self._thread) @@ -222,9 +230,10 @@ class BaseCamera(object): return self.frame + @abstractmethod def frames(self): """Create generator that returns frames from the camera.""" - raise RuntimeError('Must be implemented by subclasses.') + pass # RETURNING CAPTURES @@ -248,10 +257,6 @@ class BaseCamera(object): # CREATING NEW CAPTURES - def shunt_captures(self, target_list: list): - for obj in target_list: # For each older capture - obj.shunt() # Shunt capture from memory to storage - def new_image( self, write_to_file: bool = True, @@ -268,6 +273,7 @@ class BaseCamera(object): 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. """ @@ -291,7 +297,7 @@ class BaseCamera(object): filepath=filepath) # Update capture list - self.shunt_captures(self.images) + shunt_captures(self.images) self.images.append(output) return output @@ -312,6 +318,7 @@ class BaseCamera(object): 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. """ @@ -335,7 +342,7 @@ class BaseCamera(object): filepath=filepath) # Update capture list - self.shunt_captures(self.videos) + shunt_captures(self.videos) self.videos.append(output) return output diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index 9d7a491a..6a2bbd99 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -12,7 +12,7 @@ Video port: Splitter port 2: Video capture Splitter port 3: [Currently unused] -StreamingCamera streams at stream_resolution +StreamingCamera streams at video_resolution Camera capture resolution set to stream_resolution in frames() Video port uses that resolution for everything. If a different resolution is specified for video capture, this is handled by the resizer. @@ -39,36 +39,23 @@ from .base import BaseCamera, CaptureObject # Richard's fix gain from .set_picamera_gain import set_analog_gain, set_digital_gain -# Handle config and picamera settings -CONFIG_KEYS = { - 'stream_resolution': (832, 624), - 'image_resolution': (2592, 1944), # Default for PiCamera v1. Overridden in __init__ - 'numpy_resolution': (1312, 976), - 'jpeg_quality': 75, - 'picamera_settings': { - 'exposure_mode': None, - 'awb_mode': None, - 'awb_gains': None, - 'framerate': None, - 'shutter_speed': None, - 'saturation': None, - 'analog_gain': None, - 'digital_gain': None, - 'lens_shading_table': None, - }, -} - # MAIN CLASS class StreamingCamera(BaseCamera): - """Raspberry Pi camera implementation of StreamingCamera. - - Args: - config (dict): Dictionary of config parameters to apply on init. If None, will default to basic config. - """ - def __init__(self, config: dict = None): - global CONFIG_KEYS + """Raspberry Pi camera implementation of StreamingCamera.""" + picamera_settings_keys = [ + 'exposure_mode', + 'analog_gain', + 'digital_gain', + 'shutter_speed', + 'awb_gains', + 'awb_mode', + 'framerate', + 'saturation', + 'lens_shading_table' + ] + def __init__(self): # Run BaseCamera init BaseCamera.__init__(self) # Attach to Pi camera @@ -83,17 +70,11 @@ class StreamingCamera(BaseCamera): # Reset variable states self.set_zoom(1.0) - # Populate config and settings with all available keys - self.config.update(CONFIG_KEYS) - - # Update config based on PiCamera parameters - self.config.update({ - 'image_resolution': tuple(self.camera.MAX_RESOLUTION) - }) - - # Load config dictionary if passed - if config: - self.apply_config(config) + # Update config properties + self.image_resolution = tuple(self.camera.MAX_RESOLUTION) + self.stream_resolution = (832, 624) + self.numpy_resolution = (1312, 976) + self.jpeg_quality = 75 # Create an empty stream self.stream = io.BytesIO() @@ -114,40 +95,28 @@ class StreamingCamera(BaseCamera): self.camera.close() # HANDLE SETTINGS - @property - def supports_lens_shading(self): - """Determine whether the picamera module supports lens shading. - - As of March 2018, picamera did not wrap the necessary MMAL commands to - set the lens shading table, or to write the value of analog or digital - gain. I have a forked version of the library that does support these. - For ease of use by people who don't want those features, this library - does not have a hard dependency on lens shading. However, we need to - check in some places whether it's available. - """ - return hasattr(self.camera, "lens_shading_table") - def read_config(self): """ Return config dictionary of the StreamingCamera. """ + conf_dict = { + 'stream_resolution': self.stream_resolution, + 'image_resolution': self.image_resolution, + 'numpy_resolution': self.numpy_resolution, + 'jpeg_quality': self.jpeg_quality, 'picamera_settings': {}, } - # PiCamera parameters (obtained directly from PiCamera object) - for key, _ in CONFIG_KEYS['picamera_settings'].items(): + # PiCamera parameters + for key in StreamingCamera.picamera_settings_keys: try: value = getattr(self.camera, key) + logging.debug("Reading PiCamera().{}: {}".format(key, value)) conf_dict['picamera_settings'][key] = value except AttributeError: logging.debug("Unable to read PiCamera attribute {}".format(key)) - # StreamingCamera parameters (obtained from StreamingCamera _config) - for key in CONFIG_KEYS: - if (key != 'picamera_settings') and (key in self.config): - conf_dict[key] = self.config[key] - return conf_dict def apply_config(self, config: dict) -> None: @@ -160,49 +129,31 @@ class StreamingCamera(BaseCamera): Args: config (dict): Dictionary of config parameters. """ - + # TODO: Include timing and batching logic when applying PiCamera settings + paused_stream = False + logging.debug("StreamingCamera: Applying config:") + logging.debug(config) with self.lock: # Apply valid config params to Picamera object if not self.state['record_active']: # If not recording a video - logging.debug("Applying config:") - logging.debug(config) - # Pause stream while changing settings if self.state['stream_active']: # If stream is active logging.info("Pausing stream to update config.") self.stop_stream_recording() # Pause stream paused_stream = True # Remember to unpause stream when done - # PiCamera parameters (applied directly to PiCamera object) + # PiCamera parameters if 'picamera_settings' in config: # If new settings are given - for key, value in config['picamera_settings'].items(): # For each given setting - if hasattr(self.camera, key): - self.config['picamera_settings'][key] = None # Add the key to the list of returned settings - logging.debug("Setting parameter {}: {}".format(key, config['picamera_settings'][key])) + self.apply_picamera_settings(config['picamera_settings'], pause_for_effect=True) - # Handle special attributes: - if key == 'digital_gain': - set_digital_gain(self.camera, value) - elif key == 'analog_gain': - set_analog_gain(self.camera, value) - elif key == "shutter_speed": - # TODO: use types from CONFIG_KEYS? @jtc42 - self.camera.shutter_speed = int(value) - else: - setattr(self.camera, key, value) # Write setting to camera - - # StreamingCamera parameters (applied via StreamingCamera config) + # StreamingCamera parameters for key, value in config.items(): # For each provided setting - if key != 'picamera_settings': # We already handled this - if key not in CONFIG_KEYS.keys(): - logging.warn("{} is not in the streaming camera settings dictionary - adding it.") - #continue #TODO: filter settings somehow? - logging.debug("Setting parameter {}: {}".format(key, value)) - self.config[key] = value + if (key != 'picamera_settings') and hasattr(self, key): + setattr(self, key, value) # If stream was paused to update config, unpause if paused_stream: @@ -213,6 +164,53 @@ class StreamingCamera(BaseCamera): raise Exception( "Cannot update camera config while recording is active.") + def apply_picamera_settings(self, settings_dict: dict, pause_for_effect: bool=True): + # Set exposure mode + if 'exposure_mode' in settings_dict: + logging.debug("Applying exposure_mode: {}".format(settings_dict['exposure_mode'])) + self.camera.exposure_mode = settings_dict['exposure_mode'] + + # Apply gains and let them settle + if 'analog_gain' in settings_dict: + logging.debug("Applying analog_gain: {}".format(settings_dict['analog_gain'])) + set_analog_gain(self.camera, settings_dict['analog_gain']) + if 'digital_gain' in settings_dict: + logging.debug("Applying digital_gain: {}".format(settings_dict['digital_gain'])) + set_digital_gain(self.camera, settings_dict['digital_gain']) + + # Apply shutter speed + if 'shutter_speed' in settings_dict: + logging.debug("Applying shutter_speed: {}".format(settings_dict['shutter_speed'])) + self.camera.shutter_speed = settings_dict['shutter_speed'] + + time.sleep(0.2) # Let gains settle + + # Handle AWB in a half-smart way + if 'awb_gains' in settings_dict: + logging.debug("Applying awb_mode: off") + self.camera.awb_mode = 'off' + logging.debug("Applying awb_gains: {}".format(settings_dict['awb_gains'])) + self.camera.awb_gains = settings_dict['awb_gains'] + elif 'awb_mode' in settings_dict: + logging.debug("Applying awb_mode: {}".format(settings_dict['awb_mode'])) + self.camera.awb_mode = settings_dict['awb_mode'] + + # Handle some properties that can be quickly applied + batched_keys = ['framerate', 'saturation'] + for key in batched_keys: + if (key in settings_dict) and hasattr(self.camera, key): + logging.debug("Applying {}: {}".format(key, settings_dict[key])) + setattr(self.camera, key, settings_dict[key]) + + # Handle lens shading if camera supports it + if ('lens_shading_table' in settings_dict) and hasattr(self.camera, 'lens_shading_table'): + logging.debug("Applying lens_shading_table: {}".format(settings_dict['lens_shading_table'])) + self.camera.lens_shading_table = settings_dict['lens_shading_table'] + + # Final optional pause to settle + if pause_for_effect: + time.sleep(0.2) + def set_zoom(self, zoom_value: float = 1.) -> None: """ Change the camera zoom, handling re-centering and scaling. @@ -223,21 +221,21 @@ class StreamingCamera(BaseCamera): self.state['zoom_value'] = 1 # Richard's code for zooming ! fov = self.camera.zoom - centre = np.array([fov[0] + fov[2]/2.0, fov[1] + fov[3]/2.0]) - size = 1.0/self.state['zoom_value'] + centre = np.array([fov[0] + fov[2] / 2.0, fov[1] + fov[3] / 2.0]) + size = 1.0 / self.state['zoom_value'] # If the new zoom value would be invalid, move the centre to # keep it within the camera's sensor (this is only relevant # when zooming out, if the FoV is not centred on (0.5, 0.5) for i in range(2): - if np.abs(centre[i] - 0.5) + size/2 > 0.5: - centre[i] = 0.5 + (1.0 - size)/2 * np.sign(centre[i]-0.5) + if np.abs(centre[i] - 0.5) + size / 2 > 0.5: + centre[i] = 0.5 + (1.0 - size) / 2 * np.sign(centre[i] - 0.5) logging.info("setting zoom, centre {}, size {}".format(centre, size)) - new_fov = (centre[0] - size/2, centre[1] - size/2, size, size) + new_fov = (centre[0] - size / 2, centre[1] - size / 2, size, size) self.camera.zoom = new_fov # LAUNCH ACTIONS - def start_preview(self, fullscreen=True, window=None) -> bool: + def start_preview(self, fullscreen=True, window=None): """Start the on board GPU camera preview.""" logging.info("Starting the GPU preview") @@ -260,7 +258,7 @@ class StreamingCamera(BaseCamera): except picamera.exc.PiCameraValueError as e: logging.error("Suppressed a ValueError exception in start_preview. Exception: {}".format(e)) - def stop_preview(self) -> bool: + def stop_preview(self): """Stop the on board GPU camera preview.""" self.camera.stop_preview() self.state['preview_active'] = False @@ -289,8 +287,6 @@ class StreamingCamera(BaseCamera): # If output is a StreamObject if isinstance(output, CaptureObject): - # Lock the StreamObject while recording - output.lock() # Set target to capture stream output_stream = output.stream else: @@ -303,7 +299,7 @@ class StreamingCamera(BaseCamera): output_stream, format=fmt, splitter_port=2, - resize=self.config['stream_resolution'], + resize=self.stream_resolution, quality=quality) # Update state dictionary @@ -342,7 +338,7 @@ class StreamingCamera(BaseCamera): with self.lock: # If no resolution is specified, default to image_resolution if not resolution: - resolution = self.config['image_resolution'] + resolution = self.image_resolution # Stop the camera video recording on port 1 try: @@ -366,7 +362,7 @@ class StreamingCamera(BaseCamera): Args: splitter_port (int): Splitter port to start recording on resolution ((int, int)): Resolution to set the camera to, before starting recording. - Defaults to `self.config['stream_resolution']`. + Defaults to `self.stream_resolution`. """ with self.lock: # If stream object was destroyed @@ -375,7 +371,7 @@ class StreamingCamera(BaseCamera): # If no explicit resolution is passed if not resolution: - resolution = self.config['stream_resolution'] # Default to video recording resolution + resolution = self.stream_resolution # Default to video recording resolution # Reduce the resolution for video streaming try: @@ -392,7 +388,7 @@ class StreamingCamera(BaseCamera): self.camera.start_recording( self.stream, format='mjpeg', - quality=self.config['jpeg_quality'], + quality=self.jpeg_quality, bitrate=-1, # RWB: disable bitrate control # (bitrate control makes JPEG size less good as a focus # metric) @@ -463,9 +459,9 @@ class StreamingCamera(BaseCamera): """ with self.lock: if use_video_port: - resolution = self.config['stream_resolution'] + resolution = self.stream_resolution else: - resolution = self.config['numpy_resolution'] + resolution = self.numpy_resolution if resize: size = resize @@ -503,9 +499,9 @@ class StreamingCamera(BaseCamera): """ with self.lock: if use_video_port: - resolution = self.config['stream_resolution'] + resolution = self.stream_resolution else: - resolution = self.config['numpy_resolution'] + resolution = self.numpy_resolution if resize: size = resize @@ -557,7 +553,7 @@ class StreamingCamera(BaseCamera): self.stream.seek(0) self.stream.truncate() # to stream, read the new frame - time.sleep(1/self.camera.framerate*0.1) + time.sleep(1 / self.camera.framerate * 0.1) # yield the result to be read frame = self.stream.getvalue() diff --git a/openflexure_microscope/config.py b/openflexure_microscope/config.py index 1d0645ff..84fff0ff 100644 --- a/openflexure_microscope/config.py +++ b/openflexure_microscope/config.py @@ -58,7 +58,7 @@ yaml.Loader.add_constructor(u'tag:yaml.org,2002:bool', construct_yaml_bool) # HANDLE CONVERTING ARBITRARY CONFIG TO JSON-SAFE JSON -def json_convert(v): +def convert_type_to_json_safe(v): """Make an individual attribute JSON-safe""" if isinstance(v, Fraction): return float(v) @@ -66,7 +66,7 @@ def json_convert(v): return v -def to_map(data, func): +def recursively_apply(data, func): """ Recursively apply a function to a dictionary, list, array, or tuple @@ -76,18 +76,18 @@ def to_map(data, func): """ # If the object is a dictionary if isinstance(data, abc.Mapping): - return {key: to_map(val, func) for key, val in data.items()} + return {key: recursively_apply(val, func) for key, val in data.items()} # If the object is iterable but NOT a dictionary or a string elif (isinstance(data, abc.Iterable) and not isinstance(data, abc.Mapping) and not isinstance(data, str)): - return [to_map(x, func) for x in data] + return [recursively_apply(x, func) for x in data] # if the object is neither a map nor iterable else: return func(data) -def json_map(data, clean_keys=True): +def settings_to_json(data: dict, clean_keys=True): """ Make a copy of an input dictionary that's safe for JSON return @@ -102,12 +102,12 @@ def json_map(data, clean_keys=True): # If we're cleaning up unsuitable keys if clean_keys: # Convert lens_shading_table to a bool - if 'picamera_settings' in d and 'lens_shading_table' in d['picamera_settings']: + if 'picamera_settings' in d['camera_settings'] and 'lens_shading_table' in d['camera_settings']['picamera_settings']: logging.debug("Bool-ifying lens_shading_table") - if d['picamera_settings']['lens_shading_table'] is not None: - d['picamera_settings']['lens_shading_table'] = True + if d['camera_settings']['picamera_settings']['lens_shading_table'] is not None: + d['camera_settings']['picamera_settings']['lens_shading_table'] = True - return to_map(d, json_convert) + return recursively_apply(d, convert_type_to_json_safe) # HANDLE BASIC LOADING AND SAVING OF YAML FILES @@ -142,6 +142,7 @@ def save_yaml_file(config_path: str, config_dict: dict, safe: bool = False): config_path = os.path.expanduser(config_path) logging.info("Saving {}...".format(config_path)) + logging.debug(config_dict) with open(config_path, 'w') as outfile: if not safe: @@ -183,7 +184,7 @@ def initialise_file(config_path, populate: str = ""): # MAIN CONFIG CLASS -class OpenflexureConfig: +class OpenflexureSettingsFile: """ An object to handle expansion, conversion, and saving of the microscope configuration. @@ -195,83 +196,57 @@ class OpenflexureConfig: global DEFAULT_CONFIG, USER_CONFIG_FILE self.expandable_keys = { - 'picamera_settings': None, - 'openflexure_stage_settings': None + 'camera_settings': None, + 'stage_settings': None } #: Dictionary of keys that can be passed as a file path string and expanded automatically # Set arguments self.config_path = config_path or USER_CONFIG_FILE self.expand = expand - # Create empty config dictionaries - self._config = {} - # Initialise basic config file with defaults if it doesn't exist initialise_file(self.config_path, populate=DEFAULT_CONFIG) - # Load the config in, setting self._config and self.config - self.load() - - @property - def config(self): - """ - Return a dictionary representation of the current config as a property. - """ - return self.read() - - def read(self, json_safe=False): - """ - Read the current full config. - - Args: - json_safe (bool): Converts invalid data types to JSON types, and removes any - excessively large values (e.g. lens-shading tables). - """ - if json_safe: - logging.info("Reading config as JSON-safe dictionary") - return json_map(self._config) - else: - logging.info("Reading config directly") - return self._config - - def write(self, update_dict: dict): - """ - Write properties to the config. Merges dictionaries, rather than fully replacing. - - Args: - update_dict (dict): Dictionary of config data to merge. - """ - self._config.update(update_dict) - def load(self): """ Loads config from a file on-disk, and expands auxillary config files if available. """ # Unexpanded config dictionary (used at load/save time) - self._config = load_yaml_file(self.config_path) + loaded_config = load_yaml_file(self.config_path) # If the loaded config is in contracted format if self.expand: # Expand self.raw_config into self._config - self._config = self.expand_config(self._config) + loaded_config = self.expand_config(loaded_config) - def save(self, backup: bool = True): + logging.debug("Reading settings from disk") + return loaded_config + + def save(self, config: dict, backup: bool = True): """ Save config to a file on-disk, and splits into auxillary config files if available. """ # If the loaded config was in contracted format if self.expand: # Contract self._config into self.raw_config - save_config = self.contract_config(self._config) + save_config = self.contract_config(config) else: - save_config = self._config + save_config = config if backup: if os.path.isfile(self.config_path): shutil.copyfile(self.config_path, self.config_path+".bk") + logging.debug("Saving settings dictionary to disk") save_yaml_file(self.config_path, save_config) + def merge(self, config: dict, backup: bool = True): + logging.debug("Merging settings with file on disk") + settings = self.load() + settings.update(config) + + return settings + def expand_config(self, config_dict): """ Search a config dictionary for paths to valid auxillary config files, diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index 03503800..81a447ea 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -3,34 +3,28 @@ Defines a microscope object, binding a camera and stage with basic functionality. """ import logging -import numpy as np +import pkg_resources import uuid from openflexure_stage import OpenFlexureStage + from .camera.pi import StreamingCamera from .plugins import PluginMount from .utilities import axes_to_array from .task import TaskOrchestrator from .lock import CompositeLock -from .config import OpenflexureConfig +from .config import OpenflexureSettingsFile, settings_to_json -class Microscope(object): +class Microscope: """ A basic microscope object. The camera and stage should already be initialised, and passed as arguments. - Args: - camera (:py:class:`openflexure_microscope.camera.pi.StreamingCamera`): camera object - stage (:py:class:`openflexure_stage.stage.OpenFlexureStage`): stage object - config (:py:class:`openflexure_microscope.config.OpenflexureConfig`): Runtime-config object, - automatically created if None. - attach_plugins (bool): Should the microscope attach plugins listen in 'config' immediately. - Attributes: - rc (:py:class:`openflexure_microscope.config.OpenflexureConfig`): Runtime-config object, + settings_file (:py:class:`openflexure_microscope.config.OpenflexureSettingsFile`): Runtime-config object, automatically created if None. lock (:py:class:`openflexure_microscope.lock.CompositeLock`): Composite lock controlling thread access to multiple pieces of hardware. @@ -40,43 +34,30 @@ class Microscope(object): background tasks using microscope hardware plugin (:py:class:`openflexure_microscope.plugins.PluginMount`): Mounting point for all microscope plugins """ - def __init__( - self, - camera: StreamingCamera, - stage: OpenFlexureStage, - config=None, - attach_plugins: bool = True): - # Create RC object - if config: - self.rc = config - else: - self.rc = OpenflexureConfig(expand=True) + def __init__(self): + # Initial attributes + self.id = uuid.uuid4().hex + self.name = self.id + self.fov = [0, 0] + self.plugin_maps = [] + self.camera = None + self.stage = None # Initialise with an empty composite lock self.lock = CompositeLock([]) - # Attach initial hardware (may be NoneTypes) - self.camera = None - self.stage = None - self.attach(camera, stage) - # Create a task orchestrator self.task = TaskOrchestrator() - # Create plugin mount-point + # Attach to a settings file + self.settings_file = OpenflexureSettingsFile(expand=True) + # Apply settings loaded from file + self.apply_config(self.settings_file.load()) + + # Create plugin mount-point and attach plugins from maps self.plugin = PluginMount(self) - - # Attach plugins - if attach_plugins: - self.attach_plugins() - - # Set default parameters - if 'name' not in self.rc.config: - self.rc.write({'name': uuid.uuid4().hex}) - - if 'fov' not in self.rc.config: - self.rc.write({'fov': [0, 0]}) + self.attach_plugins(self.plugin_maps) def __enter__(self): """Create microscope on context enter.""" @@ -107,54 +88,47 @@ class Microscope(object): stage (:py:class:`openflexure_stage.stage.OpenFlexureStage`): stage object """ + settings_full = self.read_config() + + # TODO: Actually attach dummy hardware! + # Maybe even attach dummy hardware at __init__, and replace with real hardware if it exists + logging.debug("Attaching camera...") self.camera = camera #: :py:class:`openflexure_microscope.camera.pi.StreamingCamera`: Picamera object - if isinstance(self.camera, StreamingCamera): + if not self.camera: + logging.info("No camera attached.") + else: logging.info("Attached camera {}".format(camera)) - logging.info("Updating camera settings") - self.write_config(self.camera.read_config()) - elif not self.camera: - logging.info("Attached dummy camera.") - # If camera has a lock - if hasattr(self.camera, 'lock'): - logging.info("Attaching {} to composite lock.".format(self.camera.lock)) - # Add the lock to the microscope composite lock - self.lock.locks.append(self.camera.lock) + + if hasattr(self.camera, 'lock'): # If camera has a lock + logging.info("Attaching {} to composite lock.".format(self.camera.lock)) + # Add the lock to the microscope composite lock + self.lock.locks.append(self.camera.lock) logging.debug("Attaching stage...") self.stage = stage #: :py:class:`openflexure_stage.stage.OpenFlexureStage`: OpenFlexure stage object - if isinstance(self.stage, OpenFlexureStage): # If a stage object has been attached + if not self.stage: + logging.info("No stage attached.") + else: logging.info("Attached stage {}".format(stage)) - self.stage.backlash = np.zeros(3, dtype=np.int) - elif not self.stage: - logging.info("Attached dummy stage.") - # If stage object has a lock - if hasattr(self.stage, 'lock'): - logging.info("Attaching lock {} to composite lock.".format(self.stage.lock)) - # Add the lock to the microscope composite lock - self.lock.locks.append(self.stage.lock) - def attach_plugin_maps(self, plugin_maps): - """ - Attach plugins from a list of plugin map strings + if hasattr(self.stage, 'lock'): # If stage object has a lock + logging.info("Attaching lock {} to composite lock.".format(self.stage.lock)) + # Add the lock to the microscope composite lock + self.lock.locks.append(self.stage.lock) - Args: - plugin_maps (list): List of strings of plugin maps. - """ - logging.debug("Attaching plugins...") + logging.info("Reapplying settings to newly attached devices") + self.apply_config(settings_full) - for plugin_map in plugin_maps: - self.plugin.attach(plugin_map) - logging.debug("Plugins attached.") - - def attach_plugins(self): + def attach_plugins(self, plugin_maps: list): """ Automatically search for plugin maps in self.config, and attach. """ - if 'plugins' in self.rc.config: - self.attach_plugin_maps(self.rc.config['plugins']) + if plugin_maps: + for plugin_map in plugin_maps: + self.plugin.attach(plugin_map) else: - logging.warning("No plugins specified in microscope_settings.yaml. Skipping.") + logging.warning("No plugins specified. Skipping.") def reload_plugins(self): """ @@ -163,7 +137,7 @@ class Microscope(object): logging.info("Tearing down existing PluginMount...") self.plugin = PluginMount(self) logging.info("Repopulating PluginMount...") - self.attach_plugins() + self.attach_plugins(self.plugin_maps) # Create unified state @property @@ -177,68 +151,92 @@ class Microscope(object): state = { 'camera': self.camera.state, 'stage': self.stage.state, - 'plugin': self.plugin.state + 'plugin': self.plugin.state, + 'version': pkg_resources.get_distribution('openflexure_microscope').version } return state - def write_config(self, config: dict): + def apply_config(self, config: dict): """ - Writes and applies a config dictionary. Missing parameters will be left untouched. + Applies a config dictionary. Missing parameters will be left untouched. """ + logging.debug("Microscope: Applying config: {}".format(config)) + # If attached to a camera - if self.camera: + if ('camera_settings' in config) and self.camera: # Update camera config - self.camera.apply_config(config) + self.camera.apply_config(config['camera_settings']) # If attached to a stage - # TODO: Convert stage settings into a config expansion - if self.stage: + if ('stage_settings' in config) and self.stage: if 'backlash' in config: # Construct backlash array backlash = axes_to_array(config['backlash'], ['x', 'y', 'z'], [0, 0, 0]) self.stage.backlash = backlash - # Cache config - self.rc.write(config) + # Todo: tidy up with some loopy goodness + if 'id' in config: + self.id = config['id'] + if 'name' in config: + self.name = config['name'] + if 'fov' in config: + self.fov = config['fov'] + if 'plugins' in config: + self.plugin_maps = config['plugins'] def read_config(self, json_safe=False): """ - Read an updated config including camera settings. + Get an updated settings dictionary. + + Reads current attributes and properties from connected hardware, + then merges those with the currently saved settings. + + This is to ensure that settings for currently disconnected hardware + don't get removed from the settings file. """ + + settings_current = { + 'id': self.id, + 'name': self.name, + 'fov': self.fov, + 'plugins': self.plugin_maps + } + # If attached to a camera if self.camera: - # Update camera config params from StreamingCamera object - self.rc.write(self.camera.read_config()) + settings_current_camera = self.camera.read_config() + settings_current['camera_settings'] = settings_current_camera # If attached to a stage + # TODO: Offload to stage object if self.stage: if hasattr(self.stage.backlash, 'tolist'): backlash = self.stage.backlash.tolist() else: backlash = self.stage.backlash - else: - backlash = [0, 0, 0] - backlash = { - 'backlash': { - 'x': backlash[0], - 'y': backlash[1], - 'z': backlash[2], + settings_current_stage = { + 'backlash': { + 'x': backlash[0], + 'y': backlash[1], + 'z': backlash[2], + } } - } - self.rc.write(backlash) + settings_current['stage_settings'] = settings_current_stage - return self.rc.read(json_safe=json_safe) + settings_full = self.settings_file.merge(settings_current) + + if json_safe: + settings_full = settings_to_json(settings_full, clean_keys=True) + + return settings_full def save_config(self, backup: bool = True): """ - Saves the current runtime-config back to disk + Merges the current settings back to disk """ - # Get any changed device settings - self.read_config(json_safe=False) - # Save to disk - self.rc.save(backup=backup) + self.settings_file.save(self.read_config(), backup=True) @property def config(self) -> dict: diff --git a/openflexure_microscope/microscope_settings.default.yaml b/openflexure_microscope/microscope_settings.default.yaml index 0692a884..4d564043 100644 --- a/openflexure_microscope/microscope_settings.default.yaml +++ b/openflexure_microscope/microscope_settings.default.yaml @@ -4,8 +4,11 @@ fov: [4100, 3146] # Capture quality jpeg_quality: 75 -# Parameters specific to PiCamera objects -picamera_settings: ~/.openflexure/picamera_settings.yaml +# Parameters specific to camera objects +camera_settings: ~/.openflexure/camera_settings.yaml + +# Parameters specific to stage objects +stage_settings: ~/.openflexure/stage_settings.yaml # Default plugins plugins: diff --git a/openflexure_microscope/plugins/default/autofocus/plugin.py b/openflexure_microscope/plugins/default/autofocus/plugin.py index e11b08f6..dfc3e612 100644 --- a/openflexure_microscope/plugins/default/autofocus/plugin.py +++ b/openflexure_microscope/plugins/default/autofocus/plugin.py @@ -112,7 +112,10 @@ class AutofocusPlugin(MicroscopePlugin): might slightly hurt accuracy, but is unlikely to be a big issue. Too big may cause you to overshoot, which is a problem. """ - with self.monitor_sharpness() as m: + with self.monitor_sharpness() as m, self.microscope.camera.lock: + # Ensure the MJPEG stream has started + self.microscope.camera.start_stream_recording() + df = dz #TODO: refactor so I actually use dz in the code below! if initial_move_up: m.focus_rel(df/2) diff --git a/pyproject.toml b/pyproject.toml index 92e6ea39..44c01cfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.masonry.api" [tool.poetry] name = "openflexure_microscope" -version = "1.0.2" +version = "1.1.0-beta.1" description = "Python module, and Flask-based web API, to run the OpenFlexure Microscope." authors = [