Merge branch 'config-overhaul' into 'master'

Config overhaul

See merge request openflexure/openflexure-microscope-server!23
This commit is contained in:
Joel Collins 2019-06-05 16:55:27 +00:00
commit fdbba08b66
8 changed files with 279 additions and 284 deletions

View file

@ -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)
app.run(host='0.0.0.0', port="5000", threaded=True, debug=True, use_reloader=False)

View file

@ -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

View file

@ -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()

View file

@ -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,

View file

@ -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:

View file

@ -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:

View file

@ -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)

View file

@ -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 = [