From b153d212e341291b3adca4dee93dbfbb1bf6d07c Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 16 Aug 2019 11:11:22 +0200 Subject: [PATCH] New picamera plugin --- platypush/backend/camera/pi.py | 40 ++- platypush/plugins/camera/pi.py | 441 +++++++++++++++++++++++++++++++-- 2 files changed, 433 insertions(+), 48 deletions(-) diff --git a/platypush/backend/camera/pi.py b/platypush/backend/camera/pi.py index 2e595cb754..8ae9f58e72 100644 --- a/platypush/backend/camera/pi.py +++ b/platypush/backend/camera/pi.py @@ -1,6 +1,5 @@ import json import socket -import time from enum import Enum from threading import Thread @@ -8,6 +7,7 @@ from threading import Thread from platypush.backend import Backend from platypush.context import get_backend + class CameraPiBackend(Backend): """ Backend to interact with a Raspberry Pi camera. It can start and stop @@ -29,12 +29,13 @@ class CameraPiBackend(Backend): def __eq__(self, other): return self.value == other + # noinspection PyUnresolvedReferences,PyPackageRequirements def __init__(self, listen_port, x_resolution=640, y_resolution=480, redis_queue='platypush/camera/pi', start_recording_on_startup=True, framerate=24, hflip=False, vflip=False, sharpness=0, contrast=0, brightness=50, - video_stabilization=False, ISO=0, exposure_compensation=0, + video_stabilization=False, iso=0, exposure_compensation=0, exposure_mode='auto', meter_mode='average', awb_mode='auto', image_effect='none', color_effects=None, rotation=0, crop=(0.0, 0.0, 1.0, 1.0), **kwargs): @@ -46,7 +47,6 @@ class CameraPiBackend(Backend): :type listen_port: int """ - import picamera super().__init__(**kwargs) self.listen_port = listen_port @@ -54,6 +54,8 @@ class CameraPiBackend(Backend): self.server_socket.bind(('0.0.0.0', self.listen_port)) self.server_socket.listen(0) + import picamera + self.camera = picamera.PiCamera() self.camera.resolution = (x_resolution, y_resolution) self.camera.framerate = framerate @@ -63,7 +65,7 @@ class CameraPiBackend(Backend): self.camera.contrast = contrast self.camera.brightness = brightness self.camera.video_stabilization = video_stabilization - self.camera.ISO = ISO + self.camera.ISO = iso self.camera.exposure_compensation = exposure_compensation self.camera.exposure_mode = exposure_mode self.camera.meter_mode = meter_mode @@ -77,7 +79,6 @@ class CameraPiBackend(Backend): self.redis_queue = redis_queue self._recording_thread = None - def send_camera_action(self, action, **kwargs): action = { 'action': action.value, @@ -97,17 +98,20 @@ class CameraPiBackend(Backend): self.camera.capture(image_file) self.logger.info('Captured camera snapshot to {}'.format(image_file)) + # noinspection PyShadowingBuiltins def start_recording(self, video_file=None, format='h264'): """ Start a recording. - :param video_file: Output video file. If specified, the video will be recorded to file, otherwise it will be served via TCP/IP on the listen_port. Use ``stop_recording`` to stop the recording. + :param video_file: Output video file. If specified, the video will be recorded to file, otherwise it will be + served via TCP/IP on the listen_port. Use ``stop_recording`` to stop the recording. :type video_file: str :param format: Video format (default: h264) :type format: str """ + # noinspection PyBroadException def recording_thread(): if video_file: self.camera.start_recording(video_file, format=format) @@ -116,8 +120,7 @@ class CameraPiBackend(Backend): else: while True: connection = self.server_socket.accept()[0].makefile('wb') - self.logger.info('Accepted client connection on port {}'. - format(self.listen_port)) + self.logger.info('Accepted client connection on port {}'.format(self.listen_port)) try: self.camera.start_recording(connection, format=format) @@ -125,21 +128,18 @@ class CameraPiBackend(Backend): self.camera.wait_recording(2) except ConnectionError: self.logger.info('Client closed connection') - try: self.stop_recording() - except: pass + try: + self.stop_recording() + except: + pass - try: connection.close() - except: pass + try: + connection.close() + except: + pass self.send_camera_action(self.CameraAction.START_RECORDING) - self._recording_thread = None - - try: - self.camera.stop_recording() - except: - pass - if self._recording_thread: self.logger.info('Recording already running') return @@ -149,7 +149,6 @@ class CameraPiBackend(Backend): name='PiCameraRecorder') self._recording_thread.start() - def stop_recording(self): """ Stops recording """ @@ -187,4 +186,3 @@ class CameraPiBackend(Backend): # vim:sw=4:ts=4:et: - diff --git a/platypush/plugins/camera/pi.py b/platypush/plugins/camera/pi.py index d0e4568aa0..160861e0ee 100644 --- a/platypush/plugins/camera/pi.py +++ b/platypush/plugins/camera/pi.py @@ -2,48 +2,435 @@ .. moduleauthor:: Fabio Manganiello """ -from platypush.context import get_backend -from platypush.plugins import Plugin, action +import os +import socket +import threading +import time + +from platypush.plugins import action +from platypush.plugins.camera import CameraPlugin -class CameraPiPlugin(Plugin): +class CameraPiPlugin(CameraPlugin): """ Plugin to control a Pi camera. - It acts as a wrapper around the :mod:`platypush.backend.camera.pi` backend - to programmatically control the status. + + Requires: + + * **picamera** (``pip install picamera``) """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + _default_resolution = (800, 600) + + def __init__(self, resolution=(_default_resolution[0], _default_resolution[1]), framerate=24, + hflip=False, vflip=False, sharpness=0, contrast=0, brightness=50, video_stabilization=False, iso=0, + exposure_compensation=0, exposure_mode='auto', meter_mode='average', awb_mode='auto', + image_effect='none', color_effects=None, rotation=0, crop=(0.0, 0.0, 1.0, 1.0), **kwargs): + """ + See https://www.raspberrypi.org/documentation/usage/camera/python/README.md + for a detailed reference about the Pi camera options. + """ + super().__init__(**kwargs) + + self.camera_args = { + 'resolution': tuple(resolution), + 'framerate': framerate, + 'hflip': hflip, + 'vflip': vflip, + 'sharpness': sharpness, + 'contrast': contrast, + 'brightness': brightness, + 'video_stabilization': video_stabilization, + 'ISO': iso, + 'exposure_compensation': exposure_compensation, + 'exposure_mode': exposure_mode, + 'meter_mode': meter_mode, + 'awb_mode': awb_mode, + 'image_effect': image_effect, + 'color_effects': color_effects, + 'rotation': rotation, + 'crop': tuple(crop), + } + + self._camera = None + + self._time_lapse_thread = None + self._recording_thread = None + self._streaming_thread = None + self._time_lapse_stop_condition = threading.Condition() + self._recording_stop_condition = threading.Condition() + self._streaming_stop_condition = threading.Condition() + + # noinspection PyUnresolvedReferences,PyPackageRequirements + def _get_camera(self, **opts): + if self._camera: + return self._camera + + import picamera + self._camera = picamera.PiCamera() + + for (attr, value) in self.camera_args: + setattr(self._camera, attr, value) + for (attr, value) in opts: + setattr(self._camera, attr, value) + + return self._camera @action - def start_recording(self): - """ - Start recording - """ - camera = get_backend('camera.pi') - camera.send_camera_action(camera.CameraAction.START_RECORDING) - - @action - def stop_recording(self): - """ - Stop recording - """ - camera = get_backend('camera.pi') - camera.send_camera_action(camera.CameraAction.STOP_RECORDING) - - @action - def take_picture(self, image_file): + def take_picture(self, image_file, preview=False, warmup_time=2, resize=None, **opts): """ Take a picture. :param image_file: Path where the output image will be stored. :type image_file: str + + :param preview: Show a preview before taking the picture (default: False) + :type preview: bool + + :param warmup_time: Time before taking the picture (default: 2 seconds) + :type warmup_time: float + + :param resize: Set if you want to resize the picture to a new format + :type resize: list or tuple (with two elements) + + :param opts: Extra options to pass to the camera (see + https://www.raspberrypi.org/documentation/usage/camera/python/README.md) + + :return: dict:: + + {"image_file": path_to_the_image} + """ - camera = get_backend('camera.pi') - camera.send_camera_action(camera.CameraAction.TAKE_PICTURE, image_file=image_file) + + camera = self._get_camera(**opts) + image_file = os.path.abspath(os.path.expanduser(image_file)) + + if preview: + camera.start_preview() + + if warmup_time: + time.sleep(warmup_time) + + capture_opts = {} + if resize: + capture_opts['resize'] = tuple(resize) + + camera.capture(image_file, **capture_opts) return {'image_file': image_file} + @action + def capture_sequence(self, n_images, directory, name_format='image_%04d.jpg', preview=False, warmup_time=2, + resize=None, **opts): + """ + Capture a sequence of images + + :param n_images: Number of images to capture + :type n_images: int + + :param directory: Path where the images will be stored + :type directory: str + + :param name_format: Format for the name of the stored images. Use %d or any other format string for representing + the image index (default: image_%04d.jpg) + :type name_format: str + + :param preview: Show a preview before taking the picture (default: False) + :type preview: bool + + :param warmup_time: Time before taking the picture (default: 2 seconds) + :type warmup_time: float + + :param resize: Set if you want to resize the picture to a new format + :type resize: list or tuple (with two elements) + + :param opts: Extra options to pass to the camera (see + https://www.raspberrypi.org/documentation/usage/camera/python/README.md) + + :return: dict:: + + {"image_files": [list of captured images]} + + """ + + camera = self._get_camera(**opts) + directory = os.path.abspath(os.path.expanduser(directory)) + + if preview: + camera.start_preview() + + if warmup_time: + time.sleep(warmup_time) + camera.exposure_mode = 'off' + + camera.shutter_speed = camera.exposure_speed + g = camera.awb_gains + camera.awb_mode = 'off' + camera.awb_gains = g + capture_opts = {} + + if resize: + capture_opts['resize'] = tuple(resize) + + images = [os.path.join(directory, name_format % str(i+1)) for i in range(0, n_images)] + camera.capture_sequence(images, **capture_opts) + return {'image_files': images} + + @action + def start_time_lapse(self, directory, n_images=None, interval=0, warmup_time=2, + resize=None, **opts): + """ + Start a time lapse capture + + :param directory: Path where the images will be stored + :type directory: str + + :param n_images: Number of images to capture (default: None, capture until stop_time_lapse) + :type n_images: int + + :param interval: Interval in seconds between two pictures (default: 0) + :type interval: float + + :param warmup_time: Time before taking the picture (default: 2 seconds) + :type warmup_time: float + + :param resize: Set if you want to resize the picture to a new format + :type resize: list or tuple (with two elements) + + :param opts: Extra options to pass to the camera (see + https://www.raspberrypi.org/documentation/usage/camera/python/README.md) + """ + + if self._time_lapse_thread: + return None, 'A time lapse thread is already running' + + camera = self._get_camera(**opts) + directory = os.path.abspath(os.path.expanduser(directory)) + + if warmup_time: + time.sleep(warmup_time) + + capture_opts = {} + if resize: + capture_opts['resize'] = tuple(resize) + + def capture_thread(): + self.logger.info('Starting time lapse recording to directory {}'.format(directory)) + i = 0 + + for filename in camera.capture_continuous(os.path.join(directory, 'image_{counter:04d}.jpg')): + i += 1 + self.logger.debug('Captured {}'.format(filename)) + + if i >= n_images: + break + + self._time_lapse_stop_condition.acquire() + should_stop = self._time_lapse_stop_condition.wait(timeout=interval) + self._time_lapse_stop_condition.release() + + if should_stop: + break + + self._time_lapse_thread = None + self.logger.info('Stopped time lapse recording') + + self._time_lapse_thread = threading.Thread(target=capture_thread) + self._time_lapse_thread.start() + + @action + def stop_time_lapse(self): + """ + Stop a time lapse sequence if it's running + """ + + if not self._time_lapse_thread: + self.logger.info('No time lapse thread is running') + return + + self._time_lapse_stop_condition.acquire() + self._time_lapse_stop_condition.notify_all() + self._time_lapse_stop_condition.release() + + if self._time_lapse_thread: + self._time_lapse_thread.join() + + # noinspection PyMethodOverriding + @action + def start_recording(self, video_file=None, directory=None, name_format='video_%04d.h264', duration=None, + split_duration=None, **opts): + """ + Start recording to a video file or to multiple video files + + :param video_file: Path of the video file, if you want to keep the recording all in one file + :type video_file: str + + :param directory: Path of the directory that will store the video files, if you want to split the recording + on multiple files. Note that you need to specify either video_file (to save the recording to one single + file) or directory (to split the recording on multiple files) + :type directory: str + + :param name_format: If you're splitting the recording to multiple files, then you can specify the name format + for those files (default: 'video_%04d.h264') + on multiple files. Note that you need to specify either video_file (to save the recording to one single + file) or directory (to split the recording on multiple files) + :type name_format: str + + :param duration: Video duration in seconds (default: None, record until stop_recording is called) + :type duration: float + + :param split_duration: If you're splitting the recording to multiple files, then you should specify how long + each video should be in seconds + :type split_duration: float + + :param opts: Extra options to pass to the camera (see + https://www.raspberrypi.org/documentation/usage/camera/python/README.md) + """ + + if self._recording_thread: + return None, 'A recording thread is already running' + + multifile = not video_file + if multifile and not (directory and split_duration): + return None, 'No video_file specified for single file capture and no directory/split_duration specified for multifile split' + + camera = self._get_camera(**opts) + video_file = os.path.abspath(os.path.expanduser(video_file)) + + def recording_thread(): + if not multifile: + self.logger.info('Starting recording to video file {}'.format(video_file)) + camera.start_recording(video_file) + self._recording_stop_condition.acquire() + self._recording_stop_condition.wait(timeout=duration) + self._recording_stop_condition.release() + self.logger.info('Video recorded to {}'.format(video_file)) + else: + self.logger.info('Starting recording video files to directory {}'.format(directory)) + + i = 1 + start_time = time.time() + end_time = None + if duration is not None: + end_time = time.time() + duration + + camera.start_recording(name_format % i) + self._recording_stop_condition.acquire() + self._recording_stop_condition.wait(timeout=split_duration) + self._recording_stop_condition.release() + self.logger.info('Video file {} saved'.format(name_format % i)) + + while True: + i += 1 + remaining_duration = None + + if end_time: + remaining_duration = end_time - time.time() + split_duration = min(split_duration, remaining_duration) + if remaining_duration <= 0: + break + + camera.split_recording(name_format % i) + self._recording_stop_condition.acquire() + should_stop = self._recording_stop_condition.wait(timeout=split_duration) + self._recording_stop_condition.release() + self.logger.info('Video file {} saved'.format(name_format % i)) + + if should_stop: + break + + self.camera.stop_recording() + self._recording_thread = None + + self._recording_thread = threading.Thread(target=recording_thread) + self._recording_thread.start() + + @action + def stop_recording(self, **kwargs): + """ + Stop a camera recording + """ + + if not self._recording_thread: + self.logger.info('No recording thread is running') + return + + self._recording_stop_condition.acquire() + self._recording_stop_condition.notify_all() + self._recording_stop_condition.release() + + if self._recording_thread: + self._recording_thread.join() + + @action + def start_streaming(self, listen_port=5000, duration=None, format='h264' **opts): + """ + Start recording to a network stream + + :param listen_port: TCP listen port (default: 5000) + :type listen_port: int + + :param duration: Stream duration in seconds (default: None, record until stop_streaming is called) + :type duration: float + + :param format: Video stream format (default: h264) + :type format: str + + :param opts: Extra options to pass to the camera (see + https://www.raspberrypi.org/documentation/usage/camera/python/README.md) + """ + + if self._streaming_thread: + return None, 'A streaming thread is already running' + + camera = self._get_camera(**opts) + server_socket = socket.socket() + server_socket.bind(('0.0.0.0', listen_port)) + server_socket.listen(0) + + def streaming_thread(): + self.logger.info('Starting streaming on listen port {}'.format(listen_port)) + + while True: + socket = server_socket.accept()[0].makefile('wb') + self.logger.info('Accepted client connection from {}'.format(socket.getpeername())) + + try: + camera.start_recording(socket, format=format) + self._streaming_stop_condition.acquire() + self._streaming_stop_condition.wait(timeout=duration) + self._streaming_stop_condition.release() + break + except ConnectionError: + self.logger.info('Client closed connection') + + try: + socket.close() + except: + pass + + self.camera.stop_recording() + self._streaming_thread = None + self.logger.info('Stopped camera stream') + + self._streaming_thread = threading.Thread(target=streaming_thread) + self._streaming_thread.start() + + @action + def stop_streaming(self, **kwargs): + """ + Stop a camera streaming session + """ + + if not self._streaming_thread: + self.logger.info('No recording thread is running') + return + + self._streaming_stop_condition.acquire() + self._streaming_stop_condition.notify_all() + self._streaming_stop_condition.release() + + if self._streaming_thread: + self._streaming_thread.join() + # vim:sw=4:ts=4:et: -