diff --git a/openflexure_microscope/__init__.py b/openflexure_microscope/__init__.py index ceaa4d3a..35033908 100644 --- a/openflexure_microscope/__init__.py +++ b/openflexure_microscope/__init__.py @@ -1,4 +1,5 @@ -__all__ = ['Microscope'] +__all__ = ['Microscope', 'config'] __version__ = "0.1.0" from .microscope import Microscope +from . import config \ No newline at end of file diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index b70bc169..f9018a64 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -261,7 +261,7 @@ class PositionAPI(MicroscopeView): return jsonify(self.microscope.state['position']) app.add_url_rule( - uri('/position/'), + uri('/position/'), view_func=PositionAPI.as_view('position', microscope=api_microscope)) diff --git a/openflexure_microscope/camera/config.py b/openflexure_microscope/camera/config.py deleted file mode 100644 index 4c4300cb..00000000 --- a/openflexure_microscope/camera/config.py +++ /dev/null @@ -1,39 +0,0 @@ -import yaml - -TYPES = { - 'stream_resolution': tuple, - 'video_resolution': tuple, - 'image_resolution': tuple, - 'numpy_resolution': tuple, - 'jpeg_quality': int, - 'framerate': int, - 'awb_mode': str, - 'red_gain': float, - 'blue_gain': float, - 'shutter_speed': int, - 'saturation': int, - 'analog_gain': float, - 'digital_gain': float, -} - - -def convert_config(config: dict) -> dict: - """Convert datatype of config based on type dictionary.""" - global TYPES - - for key in config: - if key in TYPES: - config[key] = TYPES[key](config[key]) - - return config - - -def load_config(yaml_path: str) -> dict: - """Load YAML file, pass through dictionary conversion, and return.""" - with open(yaml_path) as config_file: - return yaml.load(config_file) - - -def save_config(config: dict, yaml_path: str): - with open('yaml_path', 'w') as outfile: - yaml.dump(config, outfile) \ No newline at end of file diff --git a/openflexure_microscope/camera/config_picamera.yaml b/openflexure_microscope/camera/config_picamera.yaml deleted file mode 100644 index b71b2b03..00000000 --- a/openflexure_microscope/camera/config_picamera.yaml +++ /dev/null @@ -1,35 +0,0 @@ -# Exposure mode -exposure_mode: 'off' - -# The analog gain offers higher sensitivity and less noise than using digital gain only -analog_gain: 1. -digital_gain: 1. - -# When queried, the shutter_speed property returns the shutter speed of the camera in microseconds, -# or 0 which indicates that the speed will be automatically determined. -# If high value does not work, need to decrease the fps for streaming -shutter_speed: 30000 - -# Auto white balance: The red and blue values are returned Fraction instances. -# The values will be between 0.0 and 8.0. -awb_mode: 'off' - -# If not using auto, then you can change the awb_gains -red_gain: 1.3 -blue_gain: 1.2 - -# Color saturation of the camera as an integer between -100 and 100. -saturation: 0 - -# ISO Valid values are between 0 (auto) and 800 (1600 is not available?). -# ISO value is not used anyway as we are fixing the gains -iso: 500 - -# Resolutions for streaming and capture -video_resolution: [832, 624] -image_resolution: [2592, 1944] -numpy_resolution: [1312, 976] - -# Capture quality -jpeg_quality: 75 -framerate: 24 \ No newline at end of file diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index 2c155729..f68bde66 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -47,21 +47,27 @@ import threading from .base import BaseCamera, StreamObject # Richard's fix gain from .set_picamera_gain import set_analog_gain, set_digital_gain -# Manage config -from .config import load_config, save_config, convert_config - -HERE = os.path.abspath(os.path.dirname(__file__)) -DEFAULT_CONFIG_PATH = os.path.join(HERE, 'config_picamera.yaml') class StreamingCamera(BaseCamera): - """Raspberry Pi camera implementation of StreamingCamera.""" - def __init__(self): + """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): # Run BaseCamera init BaseCamera.__init__(self) # Attach to Pi camera self.camera = picamera.PiCamera() #: :py:class:`picamera.PiCamera`: Picamera object + # Store state of StreamingCamera + self.state.update({ + 'stream_active': False, + 'record_active': False, + 'preview_active': False, + }) + # Default camera config self.config.update({ 'video_resolution': (832, 624), @@ -70,13 +76,11 @@ class StreamingCamera(BaseCamera): 'jpeg_quality': 75, }) - # Store state of StreamingCamera - self.state.update({ - 'stream_active': False, - 'record_active': False, - 'preview_active': False, - }) + # Load config dictionary if passed + if config: + self.update_settings(config) + # Start streaming self.start_worker() def initialisation(self): @@ -91,74 +95,82 @@ class StreamingCamera(BaseCamera): if self.camera: self.camera.close() + # HANDLE SETTINGS - def load_config(self, config_path: str=None): + @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. """ - Open config_picamera.yaml file and write to camera. + return hasattr(self.camera, "lens_shading_table") - Args: - config_path (str): Path to the config YAML file. If `None`, defaults to `DEFAULT_CONFIG_PATH` - """ - global DEFAULT_CONFIG_PATH - if not config_path: - config_data = load_config(DEFAULT_CONFIG_PATH) - else: - config_data = load_config(config_path) - - self.update_settings(config_data) - - def save_config(self, config_path: str=None): - """ - Save current config dictionary to a YAML file. - - Args: - config_path (str): Path to the config YAML file. If `None`, defaults to `DEFAULT_CONFIG_PATH` - """ - global DEFAULT_CONFIG_PATH - if not config_path: - config_path = DEFAULT_CONFIG_PATH - - save_config(self.config, config_path) + def update_lens_shading(self, shading_array: np.ndarray): + if not self.supports_lens_shading: + logging.error("the currently-installed picamera library does not support all " + "the features of the openflexure microscope software. These features " + "include lens shading control and setting the analog/digital gain.\n" + "\n" + "See the installation instructions for how to fix this:\n" + "https://github.com/rwb27/openflexure_microscope_software") def update_settings(self, config: dict) -> None: """ Write a config dictionary to the StreamingCamera config. + The passed dictionary may contain other parameters not relevant to + camera config. Eg. Passing a general config file will work fine. + Args: config (dict): Dictionary of config parameters. """ - - self.config = convert_config(config) # Convert loaded dictionary to valid config dictionary - logging.debug(self.config) - paused_stream = False # 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.pause_stream_for_capture() # Pause stream paused_stream = True # Remember to unpause stream when done - # Camera AWB - if 'awb_mode' in config: - self.camera.awb_mode = config['awb_mode'] - if 'red_gain' in config and 'blue_gain' in config: - self.camera.awb_gains = ( - config['red_gain'], - config['blue_gain']) + # PiCamera parameters (applied directly to PiCamera object) + picamera_keys = [ + 'exposure_mode', + 'awb_mode', + 'awb_gains', + 'framerate', + 'shutter_speed', + 'saturation', + 'led' + ] + if 'picamera_params' in config: + for key in picamera_keys: + if key in config['picamera_params']: + logging.debug("Setting parameter {}: {}".format(key, config['picamera_params'][key])) + setattr(self.camera, key, config['picamera_params'][key]) + + # StreamingCamera parameters (applied via StreamingCamera config) + config_keys = [ + 'video_resolution', + 'image_resolution', + 'numpy_resolution', + 'jpeg_quality', ] + + for key in config_keys: + if key in config: + logging.debug("Setting parameter {}: {}".format(key, config[key])) + self.config[key] = config[key] - # Camera framerate - if 'framerate' in config: - self.camera.framerate = config['framerate'] - # Camera exposure - if 'shutter_speed' in config: - self.camera.shutter_speed = config['shutter_speed'] - if 'saturation' in config: - self.camera.saturation = config['saturation'] - # Camera misc. - self.camera.led = False # Richard's library to set analog and digital gains if 'analog_gain' in config: set_analog_gain(self.camera, config['analog_gain']) @@ -464,8 +476,6 @@ class StreamingCamera(BaseCamera): self.wait_for_camera() - # Load config file - self.load_config() # Set stream resolution self.camera.resolution = self.config['video_resolution'] @@ -490,7 +500,7 @@ class StreamingCamera(BaseCamera): self.stream.seek(0) self.stream.truncate() # to stream, read the new frame - time.sleep(1/self.config['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 new file mode 100644 index 00000000..809d261b --- /dev/null +++ b/openflexure_microscope/config.py @@ -0,0 +1,71 @@ +import yaml +import os +import logging +import shutil + +HERE = os.path.abspath(os.path.dirname(__file__)) +DEFAULT_CONFIG_PATH = os.path.join(HERE, 'openflexurerc.default.yaml') +USER_CONFIG_PATH = os.path.join(os.path.expanduser("~"), "openflexurerc.yaml") + +TYPES = { + 'stream_resolution': tuple, + 'video_resolution': tuple, + 'image_resolution': tuple, + 'numpy_resolution': tuple, + 'jpeg_quality': int, + 'analog_gain': float, + 'digital_gain': float, +} + + +def convert_config(config: dict) -> dict: + """Convert datatype of config based on type dictionary.""" + global TYPES + + for key in config: + if key in TYPES: + config[key] = TYPES[key](config[key]) + + return config + + +def load_config(config_path: str=None) -> dict: + """ + Open openflexurerc.yaml file and write to the microscope devices. + + Args: + config_path (str): Path to the config YAML file. If `None`, defaults to `DEFAULT_CONFIG_PATH` + """ + global DEFAULT_CONFIG_PATH, USER_CONFIG_PATH + + if not config_path: + if os.path.exists(USER_CONFIG_PATH): # If user config file already exists + config_path = USER_CONFIG_PATH # Load it + else: # If user config file doesn't yet exist + logging.warning("No user config found. Loading system defaults...") + logging.info("Copying default config to user config...") + shutil.copyfile(DEFAULT_CONFIG_PATH, USER_CONFIG_PATH) + logging.info("Loading user config...") + config_path = USER_CONFIG_PATH # Load defaults in + + with open(config_path) as config_file: + config_data = yaml.load(config_file) + + # Store config dictionary to self + return convert_config(config_data) + + +def save_config(self, config_dict: dict, config_path: str=None): + """ + Save current config dictionary to a YAML file. + + Args: + config_dict (dict): Dictionary of config data to save. + config_path (str): Path to the config YAML file. If `None`, defaults to `DEFAULT_CONFIG_PATH` + """ + global USER_CONFIG_PATH + if not config_path: + config_path = USER_CONFIG_PATH + + with open(config_path, 'w') as outfile: + yaml.dump(config_dict, outfile) diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index 487be8a8..f61d9513 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -3,12 +3,14 @@ Defines a microscope object, binding a camera and stage with basic functionality. """ import logging +import os import numpy as np from openflexure_stage import OpenFlexureStage from .camera.pi import StreamingCamera -from .plugins import PluginMount, load_plugin, search_plugin_paths +from .plugins import PluginMount, load_plugin, search_plugin_dirs +from .config import load_config, save_config, convert_config class Microscope(object): @@ -20,8 +22,11 @@ class Microscope(object): Args: camera (:py:class:`openflexure_microscope.camera.pi.StreamingCamera`): camera object microscope (:py:class:`openflexure_stage.stage.OpenFlexureStage`): stage object + load_config (bool): Should a config file be automatically loaded on init? + config_path (str): Path to the config YAML file to be loaded. If None, defaults to USER_CONFIG_PATH. """ def __init__(self, camera: StreamingCamera, stage: OpenFlexureStage): + self.attach(camera, stage) # Create plugin mountpoint @@ -40,7 +45,7 @@ class Microscope(object): self.camera.close() self.stage.close() - def attach(self, camera: StreamingCamera, stage: OpenFlexureStage): + def attach(self, camera: StreamingCamera, stage: OpenFlexureStage, apply_config: bool=True): """ Retroactively attaches a camera and stage to the microscope object. @@ -50,6 +55,7 @@ class Microscope(object): Args: camera (:py:class:`openflexure_microscope.camera.pi.StreamingCamera`): camera object microscope (:py:class:`openflexure_stage.stage.OpenFlexureStage`): stage object + apply_config (bool): Should the microscope config dictionary be applied to the attached hardware? """ self.camera = camera #: :py:class:`openflexure_microscope.camera.pi.StreamingCamera`: Picamera object @@ -69,7 +75,8 @@ class Microscope(object): plugin_paths (list): List of strings of plugin directories. include_default (bool): Also load plugins from the module default directory (DEFAULT_PLUGIN_PATH) """ - plugins_list = search_plugin_paths(plugin_paths, include_default=include_default) + # TODO: Get paths from rc file + plugins_list = search_plugin_dirs(plugin_paths, include_default=include_default) for i in plugins_list: module = load_plugin(i) diff --git a/openflexure_microscope/openflexurerc.default.yaml b/openflexure_microscope/openflexurerc.default.yaml new file mode 100644 index 00000000..b443a879 --- /dev/null +++ b/openflexure_microscope/openflexurerc.default.yaml @@ -0,0 +1,20 @@ +# The analog gain offers higher sensitivity and less noise than using digital gain only +analog_gain: 1. +digital_gain: 1. + +# Resolutions for streaming and capture +video_resolution: [832, 624] +image_resolution: [2592, 1944] +numpy_resolution: [1312, 976] + +# Capture quality +jpeg_quality: 75 + +picamera_params: + exposure_mode: 'off' + awb_mode: 'off' + awb_gains: [1.3, 1.2] + framerate: 24 + shutter_speed: 30000 + saturation: 0 + led: false \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2281fe87..2e446c5f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -e git://github.com/rwb27/openflexure_nano_motor_controller.git#egg=openflexure_stage +-e git://github.com/rwb27/picamera.git#egg=picamera flask >= 0.12.3 -picamera==1.13 pyyaml==3.13 numpy Pillow diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 8f2d1d40..c5afcfe7 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,15 +1,16 @@ #!/usr/bin/env python from openflexure_microscope.camera.pi import StreamingCamera from openflexure_stage import OpenFlexureStage -from openflexure_microscope import Microscope -from openflexure_microscope.plugins import PluginMount, load_plugin, search_plugin_paths +from openflexure_microscope import Microscope, config import atexit import logging, sys logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) if __name__ == '__main__': - microscope = Microscope(StreamingCamera(), OpenFlexureStage("/dev/ttyUSB0")) + openflexurerc = config.load_config() + + microscope = Microscope(StreamingCamera(openflexurerc), OpenFlexureStage("/dev/ttyUSB0")) microscope.find_plugins() # Automatically find microscope plugins @@ -18,4 +19,4 @@ if __name__ == '__main__': microscope.plugin.test1.run() microscope.plugin.test2.run() - microscope.close() \ No newline at end of file + microscope.close()