[WIP] Rewritten `camera.pi.legacy` plugin.
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Fabio Manganiello 2024-02-24 14:07:07 +01:00
parent 9ad9bd20e4
commit e46704010b
Signed by: 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
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)

View File

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

View File

@ -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
<https://www.raspberrypi.com/documentation/accessories/camera.html>`_.
.. 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 <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_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:

View File

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