From e46704010b08f2a0a82ba75bc9f6eb8293d2b63f Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 24 Feb 2024 14:07:07 +0100 Subject: [PATCH] [WIP] Rewritten `camera.pi.legacy` plugin. --- platypush/backend/camera/__init__.py | 0 platypush/backend/camera/pi/__init__.py | 214 ------------------ platypush/backend/camera/pi/manifest.yaml | 21 -- platypush/plugins/camera/__init__.py | 10 +- platypush/plugins/camera/pi/__init__.py | 27 ++- .../plugins/camera/pi/legacy/__init__.py | 149 ++++++------ platypush/plugins/camera/pi/legacy/model.py | 6 +- 7 files changed, 104 insertions(+), 323 deletions(-) delete mode 100644 platypush/backend/camera/__init__.py delete mode 100644 platypush/backend/camera/pi/__init__.py delete mode 100644 platypush/backend/camera/pi/manifest.yaml diff --git a/platypush/backend/camera/__init__.py b/platypush/backend/camera/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/platypush/backend/camera/pi/__init__.py b/platypush/backend/camera/pi/__init__.py deleted file mode 100644 index 169a158844..0000000000 --- a/platypush/backend/camera/pi/__init__.py +++ /dev/null @@ -1,214 +0,0 @@ -import json -import socket - -from enum import Enum -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 - recordings and take pictures. It can be programmatically controlled through - the :class:`platypush.plugins.camera.pi` plugin. Note that the Redis backend - must be configured and running to enable camera control. - - This backend is **DEPRECATED**. Use the plugin :class:`platypush.plugins.camera.pi.CameraPiPlugin` instead to run - Pi camera actions. If you want to start streaming the camera on application start then simply create an event hook - on :class:`platypush.message.event.application.ApplicationStartedEvent` that runs ``camera.pi.start_streaming``. - """ - - class CameraAction(Enum): - START_RECORDING = 'START_RECORDING' - STOP_RECORDING = 'STOP_RECORDING' - TAKE_PICTURE = 'TAKE_PICTURE' - - def __eq__(self, other): - return self.value == other - - # noinspection PyUnresolvedReferences,PyPackageRequirements - def __init__( - self, - listen_port, - bind_address='0.0.0.0', - 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, - 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. - - :param listen_port: Port where the camera process will provide the video output while recording - :type listen_port: int - - :param bind_address: Bind address (default: 0.0.0.0). - :type bind_address: str - """ - - super().__init__(**kwargs) - - self.bind_address = bind_address - self.listen_port = listen_port - self.server_socket = socket.socket() - self.server_socket.bind( - (self.bind_address, self.listen_port) - ) # lgtm [py/bind-socket-all-network-interfaces] - self.server_socket.listen(0) - - import picamera - - self.camera = picamera.PiCamera() - self.camera.resolution = (x_resolution, y_resolution) - self.camera.framerate = framerate - self.camera.hflip = hflip - self.camera.vflip = vflip - self.camera.sharpness = sharpness - self.camera.contrast = contrast - self.camera.brightness = brightness - self.camera.video_stabilization = video_stabilization - self.camera.ISO = iso - self.camera.exposure_compensation = exposure_compensation - self.camera.exposure_mode = exposure_mode - self.camera.meter_mode = meter_mode - self.camera.awb_mode = awb_mode - self.camera.image_effect = image_effect - self.camera.color_effects = color_effects - self.camera.rotation = rotation - self.camera.crop = crop - self.start_recording_on_startup = start_recording_on_startup - self.redis = None - self.redis_queue = redis_queue - self._recording_thread = None - - def send_camera_action(self, action, **kwargs): - action = {'action': action.value, **kwargs} - - self.redis.send_message(msg=json.dumps(action), queue_name=self.redis_queue) - - def take_picture(self, image_file): - """ - Take a picture. - - :param image_file: Output image file - :type image_file: str - """ - self.logger.info('Capturing camera snapshot to {}'.format(image_file)) - 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. - :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) - while True: - self.camera.wait_recording(2) - else: - while not self.should_stop(): - connection = self.server_socket.accept()[0].makefile('wb') - self.logger.info( - 'Accepted client connection on port {}'.format(self.listen_port) - ) - - try: - self.camera.start_recording(connection, format=format) - while True: - self.camera.wait_recording(2) - except ConnectionError: - self.logger.info('Client closed connection') - try: - self.stop_recording() - except Exception as e: - self.logger.warning( - 'Could not stop recording: {}'.format(str(e)) - ) - - try: - connection.close() - except Exception as e: - self.logger.warning( - 'Could not close connection: {}'.format(str(e)) - ) - - self.send_camera_action(self.CameraAction.START_RECORDING) - - if self._recording_thread: - self.logger.info('Recording already running') - return - - self.logger.info('Starting camera recording') - self._recording_thread = Thread( - target=recording_thread, name='PiCameraRecorder' - ) - self._recording_thread.start() - - def stop_recording(self): - """Stops recording""" - - self.logger.info('Stopping camera recording') - - try: - self.camera.stop_recording() - except Exception as e: - self.logger.warning('Failed to stop recording') - self.logger.exception(e) - - def run(self): - super().run() - - if not self.redis: - self.redis = get_backend('redis') - - if self.start_recording_on_startup: - self.send_camera_action(self.CameraAction.START_RECORDING) - - self.logger.info('Initialized Pi camera backend') - - while not self.should_stop(): - try: - msg = self.redis.get_message(self.redis_queue) - - if msg.get('action') == self.CameraAction.START_RECORDING: - self.start_recording() - elif msg.get('action') == self.CameraAction.STOP_RECORDING: - self.stop_recording() - elif msg.get('action') == self.CameraAction.TAKE_PICTURE: - self.take_picture(image_file=msg.get('image_file')) - except Exception as e: - self.logger.exception(e) - - -# vim:sw=4:ts=4:et: diff --git a/platypush/backend/camera/pi/manifest.yaml b/platypush/backend/camera/pi/manifest.yaml deleted file mode 100644 index 1a9b5690c7..0000000000 --- a/platypush/backend/camera/pi/manifest.yaml +++ /dev/null @@ -1,21 +0,0 @@ -manifest: - events: {} - install: - apk: - - py3-numpy - - py3-pillow - dnf: - - python-numpy - - python-pillow - pacman: - - python-numpy - - python-pillow - apt: - - python3-numpy - - python3-pillow - pip: - - picamera - - numpy - - Pillow - package: platypush.backend.camera.pi - type: backend diff --git a/platypush/plugins/camera/__init__.py b/platypush/plugins/camera/__init__.py index 79bcd3aca6..0830fbdb19 100644 --- a/platypush/plugins/camera/__init__.py +++ b/platypush/plugins/camera/__init__.py @@ -141,11 +141,11 @@ class CameraPlugin(RunnablePlugin, ABC): :param stream_format: Default format for the output when streamed to a network device. Available: - - ``MJPEG`` (default) - - ``H264`` (over ``ffmpeg``) - - ``H265`` (over ``ffmpeg``) - - ``MKV`` (over ``ffmpeg``) - - ``MP4`` (over ``ffmpeg``) + - ``mjpeg`` (default) + - ``h264`` (over ``ffmpeg``) + - ``h265`` (over ``ffmpeg``) + - ``mkv`` (over ``ffmpeg``) + - ``mp4`` (over ``ffmpeg``) """ super().__init__(**kwargs) diff --git a/platypush/plugins/camera/pi/__init__.py b/platypush/plugins/camera/pi/__init__.py index 69c504c2c1..82ed413598 100644 --- a/platypush/plugins/camera/pi/__init__.py +++ b/platypush/plugins/camera/pi/__init__.py @@ -47,6 +47,7 @@ class CameraPiPlugin(CameraPlugin): iso: int = 0, exposure_compensation: float = 0.0, awb_mode: str = 'Auto', + stream_format: str = 'h264', **camera, ): """ @@ -75,11 +76,21 @@ class CameraPiPlugin(CameraPlugin): - ``Indoor`` - ``Fluorescent`` + :param stream_format: Default format for the output when streamed to a + network device. Available: + + - ``h264`` (default) + - ``mjpeg`` + :param camera: Options for the base camera plugin (see :class:`platypush.plugins.camera.CameraPlugin`). """ super().__init__( - device=device, fps=fps, warmup_seconds=warmup_seconds, **camera + device=device, + fps=fps, + warmup_seconds=warmup_seconds, + stream_format=stream_format, + **camera, ) self.camera_info.sharpness = sharpness # type: ignore @@ -259,7 +270,7 @@ class CameraPiPlugin(CameraPlugin): from picamera2 import Picamera2 # type: ignore from picamera2.outputs import FileOutput # type: ignore - encoder_cls = self._video_encoders_by_format.get(stream_format) + encoder_cls = self._video_encoders_by_format.get(stream_format.lower()) assert ( encoder_cls ), f'Invalid stream format: {stream_format}. Supported formats: {", ".join(self._video_encoders_by_format)}' @@ -290,17 +301,5 @@ class CameraPiPlugin(CameraPlugin): cam.stop_encoder() cam.close() - @action - def start_streaming( - self, - device: Optional[Union[int, str]] = None, - duration: Optional[float] = None, - stream_format: str = 'h264', - **camera, - ) -> dict: - return super().start_streaming( # type: ignore - device=device, duration=duration, stream_format=stream_format, **camera - ) - # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/camera/pi/legacy/__init__.py b/platypush/plugins/camera/pi/legacy/__init__.py index 8054bae27d..c4786065a3 100644 --- a/platypush/plugins/camera/pi/legacy/__init__.py +++ b/platypush/plugins/camera/pi/legacy/__init__.py @@ -1,30 +1,39 @@ import threading -import time -from typing import Optional, List, Tuple, Union +from typing import IO, Optional, List, Tuple, Union from platypush.plugins import action from platypush.plugins.camera import CameraPlugin, Camera +from platypush.utils import wait_for_either from .model import PiCameraInfo, PiCamera class CameraPiLegacyPlugin(CameraPlugin): """ - Plugin to control a Pi camera over the legacy ``picamera`` module. + Plugin to interact with a `Pi Camera + `_. .. warning:: - This plugin is **DEPRECATED**, as it relies on the old ``picamera`` module. - Recent operating systems should probably use the - :class:`platypush.plugins.camera.pi.CameraPiPlugin` plugin instead, or - the generic v4l2 driver through - :class:`platypush.plugins.camera.ffmpeg.FfmpegCameraPlugin` or - :class:`platypush.plugins.camera.gstreamer.GStreamerCameraPlugin`. + This plugin is **DEPRECATED**, as it relies on the old ``picamera`` + module. + + The ``picamera`` module used in this plugin is deprecated and no longer + maintained. The `picamera2 `_ + module is advised instead, which is used by + :class:`platypush.plugins.camera.pi.CameraPiPlugin`. + + You may want to use this plugin if you are running an old OS that does not + support the new ``picamera2`` module. Even in that case, you may probably + consider using :class:`platypush.plugins.camera.ffmpeg.FfmpegCameraPlugin` + or :class:`platypush.plugins.camera.gstreamer.GStreamerCameraPlugin`, as + ``picamera`` is not maintained anymore and may not work properly. """ _camera_class = PiCamera _camera_info_class = PiCameraInfo + _supported_encoders = ('h264', 'mjpeg') def __init__( self, @@ -44,7 +53,8 @@ class CameraPiLegacyPlugin(CameraPlugin): led_pin: Optional[int] = None, color_effects: Optional[Union[str, List[str]]] = None, zoom: Tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0), - **camera + stream_format: str = 'h264', + **camera, ): """ :param device: Camera device number (default: 0). Only supported on @@ -124,11 +134,21 @@ class CameraPiLegacyPlugin(CameraPlugin): PIN and you want to control it. :param zoom: Camera zoom, in the format ``(x, y, width, height)`` (default: ``(0.0, 0.0, 1.0, 1.0)``). + :param stream_format: Default format for the output when streamed to a + network device. Available: + + - ``h264`` (default) + - ``mjpeg`` + :param camera: Options for the base camera plugin (see :class:`platypush.plugins.camera.CameraPlugin`). """ super().__init__( - device=device, fps=fps, warmup_seconds=warmup_seconds, **camera + device=device, + fps=fps, + warmup_seconds=warmup_seconds, + stream_format=stream_format, + **camera, ) self.camera_info.sharpness = sharpness # type: ignore @@ -145,9 +165,10 @@ class CameraPiLegacyPlugin(CameraPlugin): self.camera_info.zoom = zoom # type: ignore self.camera_info.led_pin = led_pin # type: ignore - def prepare_device(self, device: PiCamera): - import picamera + def prepare_device(self, device: Camera, **_): + import picamera # type: ignore + assert isinstance(device, PiCamera), f'Invalid camera type: {type(device)}' camera = picamera.PiCamera( camera_num=device.info.device, resolution=device.info.resolution, @@ -173,9 +194,10 @@ class CameraPiLegacyPlugin(CameraPlugin): return camera - def release_device(self, device: PiCamera): - import picamera + def release_device(self, device: Camera): + import picamera # type: ignore + assert isinstance(device, PiCamera), f'Invalid camera type: {type(device)}' if device.object: try: device.object.stop_recording() @@ -188,30 +210,36 @@ class CameraPiLegacyPlugin(CameraPlugin): except (ConnectionError, picamera.PiCameraClosed): pass - def capture_frame(self, camera: Camera, *args, **kwargs): + def capture_frame(self, device: Camera, *_, **__): import numpy as np from PIL import Image + assert device.info.resolution, 'Invalid resolution' + assert device.object, 'Camera not opened' shape = ( - camera.info.resolution[1] + (camera.info.resolution[1] % 16), - camera.info.resolution[0] + (camera.info.resolution[0] % 32), + device.info.resolution[1] + (device.info.resolution[1] % 16), + device.info.resolution[0] + (device.info.resolution[0] % 32), 3, ) frame = np.empty(shape, dtype=np.uint8) - camera.object.capture(frame, 'rgb') + device.object.capture(frame, 'rgb') return Image.fromarray(frame) def start_preview(self, camera: Camera): """ Start camera preview. """ + assert camera.object, 'Camera not opened' camera.object.start_preview() def stop_preview(self, camera: Camera): """ Stop camera preview. """ + if not camera.object: + return + try: camera.object.stop_preview() except Exception as e: @@ -219,9 +247,13 @@ class CameraPiLegacyPlugin(CameraPlugin): @action def capture_preview( - self, duration: Optional[float] = None, n_frames: Optional[int] = None, **camera + self, + device: Optional[Union[str, int]] = None, + duration: Optional[float] = None, + n_frames: Optional[int] = None, + **camera, ) -> dict: - camera = self.open_device(**camera) + camera = self.open_device(device=device, **camera) self.start_preview(camera) if n_frames: @@ -229,56 +261,41 @@ class CameraPiLegacyPlugin(CameraPlugin): if duration: threading.Timer(duration, lambda: self.stop_preview(camera)) - return self.status() + return self.status() # type: ignore - def streaming_thread( - self, camera: PiCamera, stream_format: str, duration: Optional[float] = None - ): - server_socket = self._prepare_server_socket(camera) - sock = None - streaming_started_time = time.time() - self.logger.info( - 'Starting streaming on port {}'.format(camera.info.listen_port) - ) + def _streaming_loop(self, camera: Camera, stream_format: str, sock: IO, *_, **__): + from picamera import PiCamera as PiCamera_ # type: ignore + stream_format = stream_format.lower() + assert ( + stream_format in self._supported_encoders + ), f'Invalid stream format: {stream_format}. Supported formats: {", ".join(self._supported_encoders)}' + assert isinstance(camera, PiCamera), f'Invalid camera type: {type(camera)}' + assert camera.object and isinstance( + camera.object, PiCamera_ + ), f'Invalid camera object type: {type(camera.object)}' + + cam = camera.object try: - while camera.stream_event.is_set(): - if duration and time.time() - streaming_started_time >= duration: - break - - sock = self._accept_client(server_socket) - if not sock: - continue - - if camera.object is None or camera.object.closed: - camera = self.open_device(**camera.info.to_dict()) - - try: - camera.object.start_recording(sock, format=stream_format) - while camera.stream_event.is_set(): - camera.object.wait_recording(1) - except ConnectionError: - self.logger.info('Client closed connection') - finally: - if sock: - try: - sock.close() - except Exception as e: - self.logger.warning( - 'Error while closing client socket: {}'.format(str(e)) - ) - - self.close_device(camera) + cam.start_recording(sock, format=stream_format) + while not wait_for_either( + camera.stop_stream_event, self._should_stop, timeout=1 + ): + cam.wait_recording(1) + except ConnectionError: + self.logger.info('Client closed connection') finally: - self._cleanup_stream(camera, server_socket, sock) - self.logger.info('Stopped camera stream') + try: + cam.stop_recording() + self.stop_streaming() + except Exception as e: + self.logger.warning('Could not stop streaming: %s', e) - @action - def start_streaming( - self, duration: Optional[float] = None, stream_format: str = 'h264', **camera - ) -> dict: - camera = self.open_device(stream_format=stream_format, **camera) - return self._start_streaming(camera, duration, stream_format) + def _prepare_stream_writer(self, *_, **__): + """ + Overrides the base method to do nothing - the stream writer is handled + by the picamera library. + """ # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/camera/pi/legacy/model.py b/platypush/plugins/camera/pi/legacy/model.py index 9d2e989610..09016445ac 100644 --- a/platypush/plugins/camera/pi/legacy/model.py +++ b/platypush/plugins/camera/pi/legacy/model.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import asdict, dataclass from typing import Optional, Union, List, Tuple from platypush.plugins.camera import CameraInfo, Camera @@ -35,12 +35,12 @@ class PiCameraInfo(CameraInfo): 'color_effects': self.color_effects, 'zoom': self.zoom, 'led_pin': self.led_pin, - **super().to_dict() + **asdict(super()), } class PiCamera(Camera): - info: PiCameraInfo + info: PiCameraInfo # type: ignore # vim:sw=4:ts=4:et: