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 from openflexure_microscope.api.v1 import blueprints
import time
import atexit import atexit
import logging import logging
import sys import sys
@ -59,7 +60,7 @@ else:
root.setLevel(logging.getLogger("gunicorn.error").level) root.setLevel(logging.getLogger("gunicorn.error").level)
# Create a dummy microscope object, with no hardware attachments # Create a dummy microscope object, with no hardware attachments
api_microscope = Microscope(None, None) api_microscope = Microscope()
# Rebuild the capture list # Rebuild the capture list
# TODO: Offload to a thread? # TODO: Offload to a thread?
@ -89,13 +90,15 @@ handler = JSONExceptionHandler(app)
@app.before_first_request @app.before_first_request
def attach_microscope(): def attach_microscope():
# Create the microscope object globally (common to all spawned server threads) # 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("First request made. Populating microscope with hardware...")
logging.debug("Creating camera object...") 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...") logging.debug("Creating stage object...")
# TODO: Tidy this up. api_stage may be referenced before assignment. Use some form of Maybe monad?
try: try:
api_stage = SangaStage() api_stage = SangaStage()
except (SerialException, OSError) as e: except (SerialException, OSError) as e:
@ -166,17 +169,27 @@ def err_log():
def cleanup(): def cleanup():
global api_microscope global api_microscope
logging.debug("App teardown started...") logging.debug("App teardown started...")
logging.debug("Settling...")
time.sleep(0.5)
# Save config # Save config
if api_microscope.rc: logging.debug("Saving config for teardown...")
api_microscope.rc.save(backup=True) api_microscope.save_config(backup=True)
logging.debug("Settling...")
time.sleep(0.5)
# Close down the microscope # Close down the microscope
logging.debug("Closing devices...")
api_microscope.close() api_microscope.close()
logging.debug("Settling...")
time.sleep(0.5)
logging.debug("App teardown complete.") logging.debug("App teardown complete.")
atexit.register(cleanup) atexit.register(cleanup)
if __name__ == "__main__": 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 datetime
import logging import logging
from abc import ABCMeta, abstractmethod
try: try:
from greenlet import getcurrent as get_ident from greenlet import getcurrent as get_ident
except ImportError: except ImportError:
@ -43,6 +45,11 @@ def generate_numbered_basename(obj_list: list) -> str:
return basename 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): class CameraEvent(object):
""" """
A frame-signaller object used by any instances or subclasses of BaseCamera. 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() self.events[get_ident()][0].clear()
class BaseCamera(object): class BaseCamera(metaclass=ABCMeta):
""" """
Base implementation of StreamingCamera. 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 (int): Number of inactive seconds before timing out the stream
stream_timeout_enabled (bool): Enable or disable timing out the stream stream_timeout_enabled (bool): Enable or disable timing out the stream
state (dict): Dictionary for capture state state (dict): Dictionary for capture state
config (dict): Dictionary of base camera config
paths (dict): Dictionary of capture paths paths (dict): Dictionary of capture paths
images (list): List of image capture objects images (list): List of image capture objects
videos (list): List of video capture objects videos (list): List of video capture objects
@ -115,12 +121,12 @@ class BaseCamera(object):
self.frame = None self.frame = None
self.last_access = 0 self.last_access = 0
self.event = CameraEvent() self.event = CameraEvent()
self.stop = False # Used to indicate that the stream loop should break
self.stream_timeout = 20 self.stream_timeout = 20
self.stream_timeout_enabled = False self.stream_timeout_enabled = False
self.state = {} self.state = {}
self.config = {}
self.paths = { self.paths = {
'image': BASE_CAPTURE_PATH, 'image': BASE_CAPTURE_PATH,
'video': BASE_CAPTURE_PATH, 'video': BASE_CAPTURE_PATH,
@ -132,13 +138,15 @@ class BaseCamera(object):
self.images = [] self.images = []
self.videos = [] self.videos = []
def apply_config(self, config): @abstractmethod
def apply_config(self, config: dict):
"""Update settings from a config dictionary""" """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 the current settings as a dictionary"""
return self.config pass
def __enter__(self): def __enter__(self):
"""Create camera on context enter.""" """Create camera on context enter."""
@ -176,7 +184,7 @@ class BaseCamera(object):
self.last_access = time.time() self.last_access = time.time()
self.stop = False self.stop = False
if not self.state['stream_active']: if not self.state['stream_active']:
# start background frame thread # start background frame thread
self.thread = threading.Thread(target=self._thread) self.thread = threading.Thread(target=self._thread)
@ -222,9 +230,10 @@ class BaseCamera(object):
return self.frame return self.frame
@abstractmethod
def frames(self): def frames(self):
"""Create generator that returns frames from the camera.""" """Create generator that returns frames from the camera."""
raise RuntimeError('Must be implemented by subclasses.') pass
# RETURNING CAPTURES # RETURNING CAPTURES
@ -248,10 +257,6 @@ class BaseCamera(object):
# CREATING NEW CAPTURES # 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( def new_image(
self, self,
write_to_file: bool = True, write_to_file: bool = True,
@ -268,6 +273,7 @@ class BaseCamera(object):
temporary (bool): Should the data be deleted after session ends. temporary (bool): Should the data be deleted after session ends.
Creating the capture with a content manager sets this to true. Creating the capture with a content manager sets this to true.
filename (str): Name of the stored file. Defaults to timestamp. 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. fmt (str): Format of the capture.
""" """
@ -291,7 +297,7 @@ class BaseCamera(object):
filepath=filepath) filepath=filepath)
# Update capture list # Update capture list
self.shunt_captures(self.images) shunt_captures(self.images)
self.images.append(output) self.images.append(output)
return output return output
@ -312,6 +318,7 @@ class BaseCamera(object):
temporary (bool): Should the data be deleted after session ends. temporary (bool): Should the data be deleted after session ends.
Creating the capture with a content manager sets this to true. Creating the capture with a content manager sets this to true.
filename (str): Name of the stored file. Defaults to timestamp. 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. fmt (str): Format of the capture.
""" """
@ -335,7 +342,7 @@ class BaseCamera(object):
filepath=filepath) filepath=filepath)
# Update capture list # Update capture list
self.shunt_captures(self.videos) shunt_captures(self.videos)
self.videos.append(output) self.videos.append(output)
return output return output

View file

@ -12,7 +12,7 @@ Video port:
Splitter port 2: Video capture Splitter port 2: Video capture
Splitter port 3: [Currently unused] Splitter port 3: [Currently unused]
StreamingCamera streams at stream_resolution StreamingCamera streams at video_resolution
Camera capture resolution set to stream_resolution in frames() Camera capture resolution set to stream_resolution in frames()
Video port uses that resolution for everything. If a different resolution Video port uses that resolution for everything. If a different resolution
is specified for video capture, this is handled by the resizer. is specified for video capture, this is handled by the resizer.
@ -39,36 +39,23 @@ from .base import BaseCamera, CaptureObject
# Richard's fix gain # Richard's fix gain
from .set_picamera_gain import set_analog_gain, set_digital_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 # MAIN CLASS
class StreamingCamera(BaseCamera): class StreamingCamera(BaseCamera):
"""Raspberry Pi camera implementation of StreamingCamera. """Raspberry Pi camera implementation of StreamingCamera."""
picamera_settings_keys = [
Args: 'exposure_mode',
config (dict): Dictionary of config parameters to apply on init. If None, will default to basic config. 'analog_gain',
""" 'digital_gain',
def __init__(self, config: dict = None): 'shutter_speed',
global CONFIG_KEYS 'awb_gains',
'awb_mode',
'framerate',
'saturation',
'lens_shading_table'
]
def __init__(self):
# Run BaseCamera init # Run BaseCamera init
BaseCamera.__init__(self) BaseCamera.__init__(self)
# Attach to Pi camera # Attach to Pi camera
@ -83,17 +70,11 @@ class StreamingCamera(BaseCamera):
# Reset variable states # Reset variable states
self.set_zoom(1.0) self.set_zoom(1.0)
# Populate config and settings with all available keys # Update config properties
self.config.update(CONFIG_KEYS) self.image_resolution = tuple(self.camera.MAX_RESOLUTION)
self.stream_resolution = (832, 624)
# Update config based on PiCamera parameters self.numpy_resolution = (1312, 976)
self.config.update({ self.jpeg_quality = 75
'image_resolution': tuple(self.camera.MAX_RESOLUTION)
})
# Load config dictionary if passed
if config:
self.apply_config(config)
# Create an empty stream # Create an empty stream
self.stream = io.BytesIO() self.stream = io.BytesIO()
@ -114,40 +95,28 @@ class StreamingCamera(BaseCamera):
self.camera.close() self.camera.close()
# HANDLE SETTINGS # 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): def read_config(self):
""" """
Return config dictionary of the StreamingCamera. Return config dictionary of the StreamingCamera.
""" """
conf_dict = { 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_settings': {},
} }
# PiCamera parameters (obtained directly from PiCamera object) # PiCamera parameters
for key, _ in CONFIG_KEYS['picamera_settings'].items(): for key in StreamingCamera.picamera_settings_keys:
try: try:
value = getattr(self.camera, key) value = getattr(self.camera, key)
logging.debug("Reading PiCamera().{}: {}".format(key, value))
conf_dict['picamera_settings'][key] = value conf_dict['picamera_settings'][key] = value
except AttributeError: except AttributeError:
logging.debug("Unable to read PiCamera attribute {}".format(key)) 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 return conf_dict
def apply_config(self, config: dict) -> None: def apply_config(self, config: dict) -> None:
@ -160,49 +129,31 @@ class StreamingCamera(BaseCamera):
Args: Args:
config (dict): Dictionary of config parameters. config (dict): Dictionary of config parameters.
""" """
# TODO: Include timing and batching logic when applying PiCamera settings
paused_stream = False paused_stream = False
logging.debug("StreamingCamera: Applying config:")
logging.debug(config)
with self.lock: with self.lock:
# Apply valid config params to Picamera object # Apply valid config params to Picamera object
if not self.state['record_active']: # If not recording a video if not self.state['record_active']: # If not recording a video
logging.debug("Applying config:")
logging.debug(config)
# Pause stream while changing settings # Pause stream while changing settings
if self.state['stream_active']: # If stream is active if self.state['stream_active']: # If stream is active
logging.info("Pausing stream to update config.") logging.info("Pausing stream to update config.")
self.stop_stream_recording() # Pause stream self.stop_stream_recording() # Pause stream
paused_stream = True # Remember to unpause stream when done 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 if 'picamera_settings' in config: # If new settings are given
for key, value in config['picamera_settings'].items(): # For each given setting self.apply_picamera_settings(config['picamera_settings'], pause_for_effect=True)
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]))
# Handle special attributes: # StreamingCamera parameters
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)
for key, value in config.items(): # For each provided setting for key, value in config.items(): # For each provided setting
if key != 'picamera_settings': # We already handled this if (key != 'picamera_settings') and hasattr(self, key):
if key not in CONFIG_KEYS.keys(): setattr(self, key, value)
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 stream was paused to update config, unpause # If stream was paused to update config, unpause
if paused_stream: if paused_stream:
@ -213,6 +164,53 @@ class StreamingCamera(BaseCamera):
raise Exception( raise Exception(
"Cannot update camera config while recording is active.") "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: def set_zoom(self, zoom_value: float = 1.) -> None:
""" """
Change the camera zoom, handling re-centering and scaling. Change the camera zoom, handling re-centering and scaling.
@ -223,21 +221,21 @@ class StreamingCamera(BaseCamera):
self.state['zoom_value'] = 1 self.state['zoom_value'] = 1
# Richard's code for zooming ! # Richard's code for zooming !
fov = self.camera.zoom fov = self.camera.zoom
centre = np.array([fov[0] + fov[2]/2.0, fov[1] + fov[3]/2.0]) centre = np.array([fov[0] + fov[2] / 2.0, fov[1] + fov[3] / 2.0])
size = 1.0/self.state['zoom_value'] size = 1.0 / self.state['zoom_value']
# If the new zoom value would be invalid, move the centre to # If the new zoom value would be invalid, move the centre to
# keep it within the camera's sensor (this is only relevant # 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) # when zooming out, if the FoV is not centred on (0.5, 0.5)
for i in range(2): for i in range(2):
if np.abs(centre[i] - 0.5) + size/2 > 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) centre[i] = 0.5 + (1.0 - size) / 2 * np.sign(centre[i] - 0.5)
logging.info("setting zoom, centre {}, size {}".format(centre, size)) 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 self.camera.zoom = new_fov
# LAUNCH ACTIONS # 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.""" """Start the on board GPU camera preview."""
logging.info("Starting the GPU preview") logging.info("Starting the GPU preview")
@ -260,7 +258,7 @@ class StreamingCamera(BaseCamera):
except picamera.exc.PiCameraValueError as e: except picamera.exc.PiCameraValueError as e:
logging.error("Suppressed a ValueError exception in start_preview. Exception: {}".format(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.""" """Stop the on board GPU camera preview."""
self.camera.stop_preview() self.camera.stop_preview()
self.state['preview_active'] = False self.state['preview_active'] = False
@ -289,8 +287,6 @@ class StreamingCamera(BaseCamera):
# If output is a StreamObject # If output is a StreamObject
if isinstance(output, CaptureObject): if isinstance(output, CaptureObject):
# Lock the StreamObject while recording
output.lock()
# Set target to capture stream # Set target to capture stream
output_stream = output.stream output_stream = output.stream
else: else:
@ -303,7 +299,7 @@ class StreamingCamera(BaseCamera):
output_stream, output_stream,
format=fmt, format=fmt,
splitter_port=2, splitter_port=2,
resize=self.config['stream_resolution'], resize=self.stream_resolution,
quality=quality) quality=quality)
# Update state dictionary # Update state dictionary
@ -342,7 +338,7 @@ class StreamingCamera(BaseCamera):
with self.lock: with self.lock:
# If no resolution is specified, default to image_resolution # If no resolution is specified, default to image_resolution
if not resolution: if not resolution:
resolution = self.config['image_resolution'] resolution = self.image_resolution
# Stop the camera video recording on port 1 # Stop the camera video recording on port 1
try: try:
@ -366,7 +362,7 @@ class StreamingCamera(BaseCamera):
Args: Args:
splitter_port (int): Splitter port to start recording on splitter_port (int): Splitter port to start recording on
resolution ((int, int)): Resolution to set the camera to, before starting recording. 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: with self.lock:
# If stream object was destroyed # If stream object was destroyed
@ -375,7 +371,7 @@ class StreamingCamera(BaseCamera):
# If no explicit resolution is passed # If no explicit resolution is passed
if not resolution: 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 # Reduce the resolution for video streaming
try: try:
@ -392,7 +388,7 @@ class StreamingCamera(BaseCamera):
self.camera.start_recording( self.camera.start_recording(
self.stream, self.stream,
format='mjpeg', format='mjpeg',
quality=self.config['jpeg_quality'], quality=self.jpeg_quality,
bitrate=-1, # RWB: disable bitrate control bitrate=-1, # RWB: disable bitrate control
# (bitrate control makes JPEG size less good as a focus # (bitrate control makes JPEG size less good as a focus
# metric) # metric)
@ -463,9 +459,9 @@ class StreamingCamera(BaseCamera):
""" """
with self.lock: with self.lock:
if use_video_port: if use_video_port:
resolution = self.config['stream_resolution'] resolution = self.stream_resolution
else: else:
resolution = self.config['numpy_resolution'] resolution = self.numpy_resolution
if resize: if resize:
size = resize size = resize
@ -503,9 +499,9 @@ class StreamingCamera(BaseCamera):
""" """
with self.lock: with self.lock:
if use_video_port: if use_video_port:
resolution = self.config['stream_resolution'] resolution = self.stream_resolution
else: else:
resolution = self.config['numpy_resolution'] resolution = self.numpy_resolution
if resize: if resize:
size = resize size = resize
@ -557,7 +553,7 @@ class StreamingCamera(BaseCamera):
self.stream.seek(0) self.stream.seek(0)
self.stream.truncate() self.stream.truncate()
# to stream, read the new frame # 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 # yield the result to be read
frame = self.stream.getvalue() 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 # 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""" """Make an individual attribute JSON-safe"""
if isinstance(v, Fraction): if isinstance(v, Fraction):
return float(v) return float(v)
@ -66,7 +66,7 @@ def json_convert(v):
return v return v
def to_map(data, func): def recursively_apply(data, func):
""" """
Recursively apply a function to a dictionary, list, array, or tuple 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 the object is a dictionary
if isinstance(data, abc.Mapping): 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 # If the object is iterable but NOT a dictionary or a string
elif (isinstance(data, abc.Iterable) and elif (isinstance(data, abc.Iterable) and
not isinstance(data, abc.Mapping) and not isinstance(data, abc.Mapping) and
not isinstance(data, str)): 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 # if the object is neither a map nor iterable
else: else:
return func(data) 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 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 we're cleaning up unsuitable keys
if clean_keys: if clean_keys:
# Convert lens_shading_table to a bool # 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") logging.debug("Bool-ifying lens_shading_table")
if d['picamera_settings']['lens_shading_table'] is not None: if d['camera_settings']['picamera_settings']['lens_shading_table'] is not None:
d['picamera_settings']['lens_shading_table'] = True 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 # 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) config_path = os.path.expanduser(config_path)
logging.info("Saving {}...".format(config_path)) logging.info("Saving {}...".format(config_path))
logging.debug(config_dict)
with open(config_path, 'w') as outfile: with open(config_path, 'w') as outfile:
if not safe: if not safe:
@ -183,7 +184,7 @@ def initialise_file(config_path, populate: str = ""):
# MAIN CONFIG CLASS # MAIN CONFIG CLASS
class OpenflexureConfig: class OpenflexureSettingsFile:
""" """
An object to handle expansion, conversion, and saving of the microscope configuration. An object to handle expansion, conversion, and saving of the microscope configuration.
@ -195,83 +196,57 @@ class OpenflexureConfig:
global DEFAULT_CONFIG, USER_CONFIG_FILE global DEFAULT_CONFIG, USER_CONFIG_FILE
self.expandable_keys = { self.expandable_keys = {
'picamera_settings': None, 'camera_settings': None,
'openflexure_stage_settings': None 'stage_settings': None
} #: Dictionary of keys that can be passed as a file path string and expanded automatically } #: Dictionary of keys that can be passed as a file path string and expanded automatically
# Set arguments # Set arguments
self.config_path = config_path or USER_CONFIG_FILE self.config_path = config_path or USER_CONFIG_FILE
self.expand = expand self.expand = expand
# Create empty config dictionaries
self._config = {}
# Initialise basic config file with defaults if it doesn't exist # Initialise basic config file with defaults if it doesn't exist
initialise_file(self.config_path, populate=DEFAULT_CONFIG) 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): def load(self):
""" """
Loads config from a file on-disk, and expands auxillary config files if available. Loads config from a file on-disk, and expands auxillary config files if available.
""" """
# Unexpanded config dictionary (used at load/save time) # 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 the loaded config is in contracted format
if self.expand: if self.expand:
# Expand self.raw_config into self._config # 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. Save config to a file on-disk, and splits into auxillary config files if available.
""" """
# If the loaded config was in contracted format # If the loaded config was in contracted format
if self.expand: if self.expand:
# Contract self._config into self.raw_config # Contract self._config into self.raw_config
save_config = self.contract_config(self._config) save_config = self.contract_config(config)
else: else:
save_config = self._config save_config = config
if backup: if backup:
if os.path.isfile(self.config_path): if os.path.isfile(self.config_path):
shutil.copyfile(self.config_path, self.config_path+".bk") shutil.copyfile(self.config_path, self.config_path+".bk")
logging.debug("Saving settings dictionary to disk")
save_yaml_file(self.config_path, save_config) 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): def expand_config(self, config_dict):
""" """
Search a config dictionary for paths to valid auxillary config files, 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. Defines a microscope object, binding a camera and stage with basic functionality.
""" """
import logging import logging
import numpy as np import pkg_resources
import uuid import uuid
from openflexure_stage import OpenFlexureStage from openflexure_stage import OpenFlexureStage
from .camera.pi import StreamingCamera from .camera.pi import StreamingCamera
from .plugins import PluginMount from .plugins import PluginMount
from .utilities import axes_to_array from .utilities import axes_to_array
from .task import TaskOrchestrator from .task import TaskOrchestrator
from .lock import CompositeLock from .lock import CompositeLock
from .config import OpenflexureConfig from .config import OpenflexureSettingsFile, settings_to_json
class Microscope(object): class Microscope:
""" """
A basic microscope object. A basic microscope object.
The camera and stage should already be initialised, and passed as arguments. 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: 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. automatically created if None.
lock (:py:class:`openflexure_microscope.lock.CompositeLock`): Composite lock controlling thread access lock (:py:class:`openflexure_microscope.lock.CompositeLock`): Composite lock controlling thread access
to multiple pieces of hardware. to multiple pieces of hardware.
@ -40,43 +34,30 @@ class Microscope(object):
background tasks using microscope hardware background tasks using microscope hardware
plugin (:py:class:`openflexure_microscope.plugins.PluginMount`): Mounting point for all microscope plugins 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 def __init__(self):
if config: # Initial attributes
self.rc = config self.id = uuid.uuid4().hex
else: self.name = self.id
self.rc = OpenflexureConfig(expand=True) self.fov = [0, 0]
self.plugin_maps = []
self.camera = None
self.stage = None
# Initialise with an empty composite lock # Initialise with an empty composite lock
self.lock = CompositeLock([]) self.lock = CompositeLock([])
# Attach initial hardware (may be NoneTypes)
self.camera = None
self.stage = None
self.attach(camera, stage)
# Create a task orchestrator # Create a task orchestrator
self.task = TaskOrchestrator() 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) self.plugin = PluginMount(self)
self.attach_plugins(self.plugin_maps)
# 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]})
def __enter__(self): def __enter__(self):
"""Create microscope on context enter.""" """Create microscope on context enter."""
@ -107,54 +88,47 @@ class Microscope(object):
stage (:py:class:`openflexure_stage.stage.OpenFlexureStage`): stage 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...") logging.debug("Attaching camera...")
self.camera = camera #: :py:class:`openflexure_microscope.camera.pi.StreamingCamera`: Picamera object 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("Attached camera {}".format(camera))
logging.info("Updating camera settings")
self.write_config(self.camera.read_config()) if hasattr(self.camera, 'lock'): # If camera has a lock
elif not self.camera: logging.info("Attaching {} to composite lock.".format(self.camera.lock))
logging.info("Attached dummy camera.") # Add the lock to the microscope composite lock
# If camera has a lock self.lock.locks.append(self.camera.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)
logging.debug("Attaching stage...") logging.debug("Attaching stage...")
self.stage = stage #: :py:class:`openflexure_stage.stage.OpenFlexureStage`: OpenFlexure stage object 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)) 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): if hasattr(self.stage, 'lock'): # If stage object has a lock
""" logging.info("Attaching lock {} to composite lock.".format(self.stage.lock))
Attach plugins from a list of plugin map strings # Add the lock to the microscope composite lock
self.lock.locks.append(self.stage.lock)
Args: logging.info("Reapplying settings to newly attached devices")
plugin_maps (list): List of strings of plugin maps. self.apply_config(settings_full)
"""
logging.debug("Attaching plugins...")
for plugin_map in plugin_maps: def attach_plugins(self, plugin_maps: list):
self.plugin.attach(plugin_map)
logging.debug("Plugins attached.")
def attach_plugins(self):
""" """
Automatically search for plugin maps in self.config, and attach. Automatically search for plugin maps in self.config, and attach.
""" """
if 'plugins' in self.rc.config: if plugin_maps:
self.attach_plugin_maps(self.rc.config['plugins']) for plugin_map in plugin_maps:
self.plugin.attach(plugin_map)
else: else:
logging.warning("No plugins specified in microscope_settings.yaml. Skipping.") logging.warning("No plugins specified. Skipping.")
def reload_plugins(self): def reload_plugins(self):
""" """
@ -163,7 +137,7 @@ class Microscope(object):
logging.info("Tearing down existing PluginMount...") logging.info("Tearing down existing PluginMount...")
self.plugin = PluginMount(self) self.plugin = PluginMount(self)
logging.info("Repopulating PluginMount...") logging.info("Repopulating PluginMount...")
self.attach_plugins() self.attach_plugins(self.plugin_maps)
# Create unified state # Create unified state
@property @property
@ -177,68 +151,92 @@ class Microscope(object):
state = { state = {
'camera': self.camera.state, 'camera': self.camera.state,
'stage': self.stage.state, 'stage': self.stage.state,
'plugin': self.plugin.state 'plugin': self.plugin.state,
'version': pkg_resources.get_distribution('openflexure_microscope').version
} }
return state 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 attached to a camera
if self.camera: if ('camera_settings' in config) and self.camera:
# Update camera config # Update camera config
self.camera.apply_config(config) self.camera.apply_config(config['camera_settings'])
# If attached to a stage # If attached to a stage
# TODO: Convert stage settings into a config expansion if ('stage_settings' in config) and self.stage:
if self.stage:
if 'backlash' in config: if 'backlash' in config:
# Construct backlash array # Construct backlash array
backlash = axes_to_array(config['backlash'], ['x', 'y', 'z'], [0, 0, 0]) backlash = axes_to_array(config['backlash'], ['x', 'y', 'z'], [0, 0, 0])
self.stage.backlash = backlash self.stage.backlash = backlash
# Cache config # Todo: tidy up with some loopy goodness
self.rc.write(config) 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): 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 attached to a camera
if self.camera: if self.camera:
# Update camera config params from StreamingCamera object settings_current_camera = self.camera.read_config()
self.rc.write(self.camera.read_config()) settings_current['camera_settings'] = settings_current_camera
# If attached to a stage # If attached to a stage
# TODO: Offload to stage object
if self.stage: if self.stage:
if hasattr(self.stage.backlash, 'tolist'): if hasattr(self.stage.backlash, 'tolist'):
backlash = self.stage.backlash.tolist() backlash = self.stage.backlash.tolist()
else: else:
backlash = self.stage.backlash backlash = self.stage.backlash
else:
backlash = [0, 0, 0]
backlash = { settings_current_stage = {
'backlash': { 'backlash': {
'x': backlash[0], 'x': backlash[0],
'y': backlash[1], 'y': backlash[1],
'z': backlash[2], '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): 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.settings_file.save(self.read_config(), backup=True)
self.read_config(json_safe=False)
# Save to disk
self.rc.save(backup=backup)
@property @property
def config(self) -> dict: def config(self) -> dict:

View file

@ -4,8 +4,11 @@ fov: [4100, 3146]
# Capture quality # Capture quality
jpeg_quality: 75 jpeg_quality: 75
# Parameters specific to PiCamera objects # Parameters specific to camera objects
picamera_settings: ~/.openflexure/picamera_settings.yaml camera_settings: ~/.openflexure/camera_settings.yaml
# Parameters specific to stage objects
stage_settings: ~/.openflexure/stage_settings.yaml
# Default plugins # Default plugins
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 might slightly hurt accuracy, but is unlikely to be a big issue. Too big
may cause you to overshoot, which is a problem. 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! df = dz #TODO: refactor so I actually use dz in the code below!
if initial_move_up: if initial_move_up:
m.focus_rel(df/2) m.focus_rel(df/2)

View file

@ -4,7 +4,7 @@ build-backend = "poetry.masonry.api"
[tool.poetry] [tool.poetry]
name = "openflexure_microscope" 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." description = "Python module, and Flask-based web API, to run the OpenFlexure Microscope."
authors = [ authors = [