Merge branch 'config-overhaul' into 'master'
Config overhaul See merge request openflexure/openflexure-microscope-server!23
This commit is contained in:
commit
fdbba08b66
8 changed files with 279 additions and 284 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue