[WIP] Rewritten camera.pi.legacy plugin.

This commit is contained in:
Fabio Manganiello 2024-02-24 14:07:07 +01:00
parent 9ad9bd20e4
commit e46704010b
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
7 changed files with 104 additions and 323 deletions

View file

@ -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:

View file

@ -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

View file

@ -141,11 +141,11 @@ class CameraPlugin(RunnablePlugin, ABC):
:param stream_format: Default format for the output when streamed to a :param stream_format: Default format for the output when streamed to a
network device. Available: network device. Available:
- ``MJPEG`` (default) - ``mjpeg`` (default)
- ``H264`` (over ``ffmpeg``) - ``h264`` (over ``ffmpeg``)
- ``H265`` (over ``ffmpeg``) - ``h265`` (over ``ffmpeg``)
- ``MKV`` (over ``ffmpeg``) - ``mkv`` (over ``ffmpeg``)
- ``MP4`` (over ``ffmpeg``) - ``mp4`` (over ``ffmpeg``)
""" """
super().__init__(**kwargs) super().__init__(**kwargs)

View file

@ -47,6 +47,7 @@ class CameraPiPlugin(CameraPlugin):
iso: int = 0, iso: int = 0,
exposure_compensation: float = 0.0, exposure_compensation: float = 0.0,
awb_mode: str = 'Auto', awb_mode: str = 'Auto',
stream_format: str = 'h264',
**camera, **camera,
): ):
""" """
@ -75,11 +76,21 @@ class CameraPiPlugin(CameraPlugin):
- ``Indoor`` - ``Indoor``
- ``Fluorescent`` - ``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 :param camera: Options for the base camera plugin (see
:class:`platypush.plugins.camera.CameraPlugin`). :class:`platypush.plugins.camera.CameraPlugin`).
""" """
super().__init__( 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 self.camera_info.sharpness = sharpness # type: ignore
@ -259,7 +270,7 @@ class CameraPiPlugin(CameraPlugin):
from picamera2 import Picamera2 # type: ignore from picamera2 import Picamera2 # type: ignore
from picamera2.outputs import FileOutput # 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 ( assert (
encoder_cls encoder_cls
), f'Invalid stream format: {stream_format}. Supported formats: {", ".join(self._video_encoders_by_format)}' ), 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.stop_encoder()
cam.close() 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: # vim:sw=4:ts=4:et:

View file

@ -1,30 +1,39 @@
import threading 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 import action
from platypush.plugins.camera import CameraPlugin, Camera from platypush.plugins.camera import CameraPlugin, Camera
from platypush.utils import wait_for_either
from .model import PiCameraInfo, PiCamera from .model import PiCameraInfo, PiCamera
class CameraPiLegacyPlugin(CameraPlugin): class CameraPiLegacyPlugin(CameraPlugin):
""" """
Plugin to control a Pi camera over the legacy ``picamera`` module. Plugin to interact with a `Pi Camera
<https://www.raspberrypi.com/documentation/accessories/camera.html>`_.
.. warning:: .. warning::
This plugin is **DEPRECATED**, as it relies on the old ``picamera`` module. This plugin is **DEPRECATED**, as it relies on the old ``picamera``
Recent operating systems should probably use the module.
: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`.
The ``picamera`` module used in this plugin is deprecated and no longer
maintained. The `picamera2 <https://github.com/raspberrypi/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_class = PiCamera
_camera_info_class = PiCameraInfo _camera_info_class = PiCameraInfo
_supported_encoders = ('h264', 'mjpeg')
def __init__( def __init__(
self, self,
@ -44,7 +53,8 @@ class CameraPiLegacyPlugin(CameraPlugin):
led_pin: Optional[int] = None, led_pin: Optional[int] = None,
color_effects: Optional[Union[str, List[str]]] = None, color_effects: Optional[Union[str, List[str]]] = None,
zoom: Tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0), 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 :param device: Camera device number (default: 0). Only supported on
@ -124,11 +134,21 @@ class CameraPiLegacyPlugin(CameraPlugin):
PIN and you want to control it. PIN and you want to control it.
:param zoom: Camera zoom, in the format ``(x, y, width, height)`` :param zoom: Camera zoom, in the format ``(x, y, width, height)``
(default: ``(0.0, 0.0, 1.0, 1.0)``). (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 :param camera: Options for the base camera plugin (see
:class:`platypush.plugins.camera.CameraPlugin`). :class:`platypush.plugins.camera.CameraPlugin`).
""" """
super().__init__( 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 self.camera_info.sharpness = sharpness # type: ignore
@ -145,9 +165,10 @@ class CameraPiLegacyPlugin(CameraPlugin):
self.camera_info.zoom = zoom # type: ignore self.camera_info.zoom = zoom # type: ignore
self.camera_info.led_pin = led_pin # type: ignore self.camera_info.led_pin = led_pin # type: ignore
def prepare_device(self, device: PiCamera): def prepare_device(self, device: Camera, **_):
import picamera import picamera # type: ignore
assert isinstance(device, PiCamera), f'Invalid camera type: {type(device)}'
camera = picamera.PiCamera( camera = picamera.PiCamera(
camera_num=device.info.device, camera_num=device.info.device,
resolution=device.info.resolution, resolution=device.info.resolution,
@ -173,9 +194,10 @@ class CameraPiLegacyPlugin(CameraPlugin):
return camera return camera
def release_device(self, device: PiCamera): def release_device(self, device: Camera):
import picamera import picamera # type: ignore
assert isinstance(device, PiCamera), f'Invalid camera type: {type(device)}'
if device.object: if device.object:
try: try:
device.object.stop_recording() device.object.stop_recording()
@ -188,30 +210,36 @@ class CameraPiLegacyPlugin(CameraPlugin):
except (ConnectionError, picamera.PiCameraClosed): except (ConnectionError, picamera.PiCameraClosed):
pass pass
def capture_frame(self, camera: Camera, *args, **kwargs): def capture_frame(self, device: Camera, *_, **__):
import numpy as np import numpy as np
from PIL import Image from PIL import Image
assert device.info.resolution, 'Invalid resolution'
assert device.object, 'Camera not opened'
shape = ( shape = (
camera.info.resolution[1] + (camera.info.resolution[1] % 16), device.info.resolution[1] + (device.info.resolution[1] % 16),
camera.info.resolution[0] + (camera.info.resolution[0] % 32), device.info.resolution[0] + (device.info.resolution[0] % 32),
3, 3,
) )
frame = np.empty(shape, dtype=np.uint8) frame = np.empty(shape, dtype=np.uint8)
camera.object.capture(frame, 'rgb') device.object.capture(frame, 'rgb')
return Image.fromarray(frame) return Image.fromarray(frame)
def start_preview(self, camera: Camera): def start_preview(self, camera: Camera):
""" """
Start camera preview. Start camera preview.
""" """
assert camera.object, 'Camera not opened'
camera.object.start_preview() camera.object.start_preview()
def stop_preview(self, camera: Camera): def stop_preview(self, camera: Camera):
""" """
Stop camera preview. Stop camera preview.
""" """
if not camera.object:
return
try: try:
camera.object.stop_preview() camera.object.stop_preview()
except Exception as e: except Exception as e:
@ -219,9 +247,13 @@ class CameraPiLegacyPlugin(CameraPlugin):
@action @action
def capture_preview( 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: ) -> dict:
camera = self.open_device(**camera) camera = self.open_device(device=device, **camera)
self.start_preview(camera) self.start_preview(camera)
if n_frames: if n_frames:
@ -229,56 +261,41 @@ class CameraPiLegacyPlugin(CameraPlugin):
if duration: if duration:
threading.Timer(duration, lambda: self.stop_preview(camera)) threading.Timer(duration, lambda: self.stop_preview(camera))
return self.status() return self.status() # type: ignore
def streaming_thread( def _streaming_loop(self, camera: Camera, stream_format: str, sock: IO, *_, **__):
self, camera: PiCamera, stream_format: str, duration: Optional[float] = None from picamera import PiCamera as PiCamera_ # type: ignore
):
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)
)
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: try:
while camera.stream_event.is_set(): cam.start_recording(sock, format=stream_format)
if duration and time.time() - streaming_started_time >= duration: while not wait_for_either(
break camera.stop_stream_event, self._should_stop, timeout=1
):
sock = self._accept_client(server_socket) cam.wait_recording(1)
if not sock: except ConnectionError:
continue self.logger.info('Client closed connection')
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)
finally: finally:
self._cleanup_stream(camera, server_socket, sock) try:
self.logger.info('Stopped camera stream') cam.stop_recording()
self.stop_streaming()
except Exception as e:
self.logger.warning('Could not stop streaming: %s', e)
@action def _prepare_stream_writer(self, *_, **__):
def start_streaming( """
self, duration: Optional[float] = None, stream_format: str = 'h264', **camera Overrides the base method to do nothing - the stream writer is handled
) -> dict: by the picamera library.
camera = self.open_device(stream_format=stream_format, **camera) """
return self._start_streaming(camera, duration, stream_format)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -1,4 +1,4 @@
from dataclasses import dataclass from dataclasses import asdict, dataclass
from typing import Optional, Union, List, Tuple from typing import Optional, Union, List, Tuple
from platypush.plugins.camera import CameraInfo, Camera from platypush.plugins.camera import CameraInfo, Camera
@ -35,12 +35,12 @@ class PiCameraInfo(CameraInfo):
'color_effects': self.color_effects, 'color_effects': self.color_effects,
'zoom': self.zoom, 'zoom': self.zoom,
'led_pin': self.led_pin, 'led_pin': self.led_pin,
**super().to_dict() **asdict(super()),
} }
class PiCamera(Camera): class PiCamera(Camera):
info: PiCameraInfo info: PiCameraInfo # type: ignore
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et: