openflexure-microscope-server/openflexure_microscope/camera/pi.py
2019-06-05 16:04:14 +01:00

570 lines
20 KiB
Python

# -*- coding: utf-8 -*-
"""
Raspberry Pi camera implementation of the StreamingCamera class.
NOTES:
Still port used for image capture
Preview port reserved for onboard GPU preview
Video port:
Splitter port 0: Image capture (if use_video_port == True)
Splitter port 1: Streaming frames
Splitter port 2: Video capture
Splitter port 3: [Currently unused]
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.
Still capture (if use_video_port == False) uses pause_stream
to temporarily increase the capture resolution.
"""
from __future__ import division
import io
import time
import numpy as np
import logging
# Pi camera
import picamera
import picamera.array
# Type hinting
from typing import Tuple
from .base import BaseCamera, CaptureObject
# Richard's fix gain
from .set_picamera_gain import set_analog_gain, set_digital_gain
# MAIN CLASS
class StreamingCamera(BaseCamera):
"""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
self.camera = picamera.PiCamera() #: :py:class:`picamera.PiCamera`: Picamera object
# Store state of StreamingCamera
self.state.update({
'stream_active': False,
'record_active': False,
'preview_active': False,
})
# Reset variable states
self.set_zoom(1.0)
# 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()
# Start streaming
self.start_worker()
def initialisation(self):
"""Run any initialisation code when the frame iterator starts."""
pass
def close(self):
"""Close the Raspberry Pi StreamingCamera."""
# Run BaseCamera close method
BaseCamera.close(self)
# Detach Pi camera
if self.camera:
self.camera.close()
# HANDLE SETTINGS
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
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))
return conf_dict
def apply_config(self, config: dict) -> None:
"""
Write a config dictionary to the StreamingCamera config.
The passed dictionary may contain other parameters not relevant to
camera config. Eg. Passing a general config file will work fine.
Args:
config (dict): Dictionary of config parameters.
"""
# 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
# 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
if 'picamera_settings' in config: # If new settings are given
self.apply_picamera_settings(config['picamera_settings'], pause_for_effect=True)
# StreamingCamera parameters
for key, value in config.items(): # For each provided setting
if (key != 'picamera_settings') and hasattr(self, key):
setattr(self, key, value)
# If stream was paused to update config, unpause
if paused_stream:
logging.info("Resuming stream.")
self.start_stream_recording()
else:
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.
"""
with self.lock:
self.state['zoom_value'] = float(zoom_value)
if self.state['zoom_value'] < 1:
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']
# 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)
logging.info("setting zoom, centre {}, size {}".format(centre, 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):
"""Start the on board GPU camera preview."""
logging.info("Starting the GPU preview")
# TODO: Commented out as I honestly can't remember why this was here. May need to put back?
# self.start_stream_recording()
try:
if not self.camera.preview:
logging.debug("Starting preview")
self.camera.start_preview(fullscreen=fullscreen, window=window)
else:
logging.debug("Resizing preview")
if window:
self.camera.preview.window = window
if fullscreen:
self.camera.preview.fullscreen = fullscreen
self.state['preview_active'] = True
except picamera.exc.PiCameraMMALError as e:
logging.error("Suppressed a MMALError in start_preview. Exception: {}".format(e))
except picamera.exc.PiCameraValueError as e:
logging.error("Suppressed a ValueError exception in start_preview. Exception: {}".format(e))
def stop_preview(self):
"""Stop the on board GPU camera preview."""
self.camera.stop_preview()
self.state['preview_active'] = False
def start_recording(
self,
output,
fmt: str = 'h264',
quality: int = 15):
"""Start recording.
Start a new video recording, writing to a output object.
Args:
output (CaptureObject/str): Output object to write data bytes to.
fmt (str): Format of the capture.
quality (int): Video recording quality.
Returns:
output_object (str/BytesIO): Target object.
"""
with self.lock:
# Start recording method only if a current recording is not running
if not self.state['record_active']:
# If output is a StreamObject
if isinstance(output, CaptureObject):
# Set target to capture stream
output_stream = output.stream
else:
output_stream = output
# Start the camera video recording on port 2
logging.info("Recording to {}".format(output))
self.camera.start_recording(
output_stream,
format=fmt,
splitter_port=2,
resize=self.stream_resolution,
quality=quality)
# Update state dictionary
self.state['record_active'] = True
return output
else:
logging.error(
"Cannot start a new recording\
until the current recording has stopped.")
return None
def stop_recording(self):
"""Stop the last started video recording on splitter port 2."""
with self.lock:
# Stop the camera video recording on port 2
logging.info("Stopping recording")
self.camera.stop_recording(splitter_port=2)
logging.info("Recording stopped")
# Update state dictionary
self.state['record_active'] = False
def stop_stream_recording(
self,
splitter_port: int = 1,
resolution: Tuple[int, int] = None) -> None:
"""
Sets the camera resolution to the still-image resolution, and stops recording if the stream is active.
Args:
splitter_port (int): Splitter port to stop recording on
resolution ((int, int)): Resolution to set the camera to, after stopping recording.
"""
with self.lock:
# If no resolution is specified, default to image_resolution
if not resolution:
resolution = self.image_resolution
# Stop the camera video recording on port 1
try:
self.camera.stop_recording(splitter_port=splitter_port)
except picamera.exc.PiCameraNotRecording:
logging.info("Not recording on splitter_port {}".format(splitter_port))
else:
logging.info("Stopped MJPEG stream on port {1}. Switching to {0}.".format(resolution, splitter_port))
# Increase the resolution for taking an image
time.sleep(0.2) # Sprinkled a sleep to prevent camera getting confused by rapid commands
self.camera.resolution = resolution
def start_stream_recording(
self,
splitter_port: int = 1,
resolution: Tuple[int, int] = None) -> None:
"""
Sets the camera resolution to the video/stream resolution, and starts recording if the stream should be active.
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.stream_resolution`.
"""
with self.lock:
# If stream object was destroyed
if not hasattr(self, 'stream'):
self.stream = io.BytesIO() # Create a stream object
# If no explicit resolution is passed
if not resolution:
resolution = self.stream_resolution # Default to video recording resolution
# Reduce the resolution for video streaming
try:
self.camera._check_recording_stopped()
except picamera.exc.PiCameraRuntimeError:
logging.info("Error while changing resolution: Recording already running.")
else:
self.camera.resolution = resolution
# If the stream should be active
if self.state['stream_active']:
try:
# Start recording on stream port
self.camera.start_recording(
self.stream,
format='mjpeg',
quality=self.jpeg_quality,
bitrate=-1, # RWB: disable bitrate control
# (bitrate control makes JPEG size less good as a focus
# metric)
splitter_port=splitter_port)
except picamera.exc.PiCameraAlreadyRecording:
logging.info("Error while starting preview: Recording already running.")
else:
logging.debug("Started MJPEG stream at {} on port {}".format(resolution, splitter_port))
def capture(
self,
output,
fmt: str = 'jpeg',
use_video_port: bool = False,
resize: Tuple[int, int] = None,
bayer: bool = True):
"""
Capture a still image to a StreamObject.
Defaults to JPEG format.
Target object can be overridden for development purposes.
Args:
output (CaptureObject/str): Output object to write data bytes to.
use_video_port (bool): Capture from the video port used for streaming. Lower resolution, faster.
fmt (str): Format of the capture.
resize ((int, int)): Resize the captured image.
bayer (bool): Store raw bayer data in capture
"""
with self.lock:
# If output is a StreamObject
if isinstance(output, CaptureObject):
# Set target to capture stream
output_stream = output.stream
else:
output_stream = output
logging.info("Capturing to {}".format(output))
# Set resolution and stop stream recording if necessary
if not use_video_port:
self.stop_stream_recording()
self.camera.capture(
output_stream,
format=fmt,
quality=100,
resize=resize,
bayer=(not use_video_port) and bayer,
use_video_port=use_video_port)
# Set resolution and start stream recording if necessary
if not use_video_port:
self.start_stream_recording()
return output
def yuv(
self,
use_video_port: bool = True,
resize: Tuple[int, int] = None) -> np.ndarray:
"""Capture an uncompressed still YUV image to a Numpy array.
Args:
use_video_port (bool): Capture from the video port used for streaming. Lower resolution, faster.
resize ((int, int)): Resize the captured image.
"""
with self.lock:
if use_video_port:
resolution = self.stream_resolution
else:
resolution = self.numpy_resolution
if resize:
size = resize
else:
size = resolution
if not use_video_port:
self.stop_stream_recording(resolution=resolution)
logging.debug("Creating PiYUVArray")
with picamera.array.PiYUVArray(self.camera, size=size) as output:
logging.info("Capturing to {}".format(output))
self.camera.capture(
output,
resize=size,
format='yuv',
use_video_port=use_video_port)
if not use_video_port:
self.start_stream_recording()
return output.array
def array(
self,
use_video_port: bool = True,
resize: Tuple[int, int] = None) -> np.ndarray:
"""Capture an uncompressed still RGB image to a Numpy array.
Args:
use_video_port (bool): Capture from the video port used for streaming. Lower resolution, faster.
resize ((int, int)): Resize the captured image.
"""
with self.lock:
if use_video_port:
resolution = self.stream_resolution
else:
resolution = self.numpy_resolution
if resize:
size = resize
else:
size = resolution
# Always pause stream, to prevent resizer memory issues
self.stop_stream_recording(resolution=resolution)
logging.debug("Creating PiRGBArray")
with picamera.array.PiRGBArray(self.camera, size=size) as output:
logging.info("Capturing to {}".format(output))
self.camera.capture(
output,
resize=size,
format='rgb',
use_video_port=use_video_port)
# Resume stream
self.start_stream_recording()
return output.array
# HANDLE STREAM FRAMES
def frames(self):
"""
Create generator that returns frames from the camera.
Records video from port 1 to a byte stream,
and iterates sequential frames.
"""
# Run this initialisation method
self.initialisation()
self.wait_for_camera()
# Start stream recording (and set resolution)
self.start_stream_recording()
# Update state
logging.debug("STREAM ACTIVE")
# While the iterator is not closed
try:
while True:
# reset stream for next frame
self.stream.seek(0)
self.stream.truncate()
# to stream, read the new frame
time.sleep(1 / self.camera.framerate * 0.1)
# yield the result to be read
frame = self.stream.getvalue()
# ensure the size of package is right
if len(frame) == 0:
pass
else:
yield frame
# When GeneratorExit or StopIteration raised, run cleanup code
finally:
# Stop stream recording (and set resolution)
self.stop_stream_recording()
logging.debug("FRAME ITERATOR END")