From ba6c890a42f922342272081354164f8eb1b13a18 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 29 Dec 2019 16:28:07 +0100 Subject: [PATCH] Refactored and improved camera plugin --- .../app/routes/plugins/camera/__init__.py | 99 +++----- platypush/plugins/camera/__init__.py | 234 +++++++++++------- platypush/plugins/camera/pi.py | 30 +-- 3 files changed, 192 insertions(+), 171 deletions(-) diff --git a/platypush/backend/http/app/routes/plugins/camera/__init__.py b/platypush/backend/http/app/routes/plugins/camera/__init__.py index 77663f7b..a0d129a1 100644 --- a/platypush/backend/http/app/routes/plugins/camera/__init__.py +++ b/platypush/backend/http/app/routes/plugins/camera/__init__.py @@ -1,10 +1,7 @@ -import os -import shutil -import tempfile -import time - -from flask import Response, Blueprint, send_from_directory +from flask import Response, Blueprint +from platypush.plugins.camera import CameraPlugin +from platypush import Config from platypush.backend.http.app import template_folder from platypush.backend.http.app.utils import authenticate, send_request @@ -18,85 +15,60 @@ __routes__ = [ def get_device_id(device_id=None): if device_id is None: - device_id = str(send_request(action='camera.get_default_device_id').output) + device_id = int(send_request(action='camera.get_default_device_id').output) return device_id -def get_frame_file(device_id=None): +def get_camera(device_id=None): device_id = get_device_id(device_id) - was_recording = True - frame_file = None - status = send_request(action='camera.status', device_id=device_id).output + camera_conf = Config.get('camera') or {} + camera_conf['device_id'] = device_id + return CameraPlugin(**camera_conf) - if device_id not in status: - was_recording = False - send_request(action='camera.start_recording', - device_id=device_id) - while not frame_file: - frame_file = send_request(action='camera.status', device_id=device_id). \ - output.get(device_id, {}).get('image_file') +def get_frame(device_id=None): + cam = get_camera(device_id) + with cam: + frame = None - if not frame_file: - time.sleep(0.1) + for _ in range(cam.warmup_frames): + output = cam.get_stream() - if not was_recording: - with tempfile.NamedTemporaryFile(prefix='camera_capture_', suffix='.jpg', - delete=False) as f: - # stop_recording will delete the temporary frames. Copy the image file - # to a temporary file before stopping recording - tmp_file = f.name + with output.ready: + output.ready.wait() + frame = output.frame + print(frame, type(frame)) - shutil.copyfile(frame_file, tmp_file) - frame_file = tmp_file - send_request(action='camera.stop_recording', device_id=device_id) - - return frame_file + return frame def video_feed(device_id=None): - device_id = get_device_id(device_id) - send_request(action='camera.start_recording', device_id=device_id) - last_frame_file = None + cam = get_camera(device_id) - try: + with cam: while True: - frame_file = get_frame_file(device_id) - if frame_file == last_frame_file: - continue + output = cam.get_stream() + with output.ready: + output.ready.wait() + frame = output.frame - with open(frame_file, 'rb') as f: - frame = f.read() - - yield (b'--frame\r\n' - b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') - - last_frame_file = frame_file - finally: - send_request(action='camera.stop_recording', device_id=device_id) + if frame and len(frame): + yield (b'--frame\r\n' + b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') @camera.route('/camera//frame', methods=['GET']) @authenticate() def get_camera_frame(device_id): - frame_file = get_frame_file(device_id) - return send_from_directory(os.path.dirname(frame_file), - os.path.basename(frame_file)) + frame = get_frame(device_id) + return Response(frame, mimetype='image/jpeg') @camera.route('/camera/frame', methods=['GET']) @authenticate() def get_default_camera_frame(): - frame_file = get_frame_file() - return send_from_directory(os.path.dirname(frame_file), - os.path.basename(frame_file)) - - -@camera.route('/camera/stream', methods=['GET']) -@authenticate() -def get_default_stream_feed(): - return Response(video_feed(), - mimetype='multipart/x-mixed-replace; boundary=frame') + frame = get_frame() + return Response(frame, mimetype='image/jpeg') @camera.route('/camera//stream', methods=['GET']) @@ -106,4 +78,11 @@ def get_stream_feed(device_id): mimetype='multipart/x-mixed-replace; boundary=frame') +@camera.route('/camera/stream', methods=['GET']) +@authenticate() +def get_default_stream_feed(): + return Response(video_feed(), + mimetype='multipart/x-mixed-replace; boundary=frame') + + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/camera/__init__.py b/platypush/plugins/camera/__init__.py index 5e666c3b..b36ba78c 100644 --- a/platypush/plugins/camera/__init__.py +++ b/platypush/plugins/camera/__init__.py @@ -1,3 +1,4 @@ +import io import os import re import shutil @@ -5,8 +6,10 @@ import threading import time from datetime import datetime +from typing import Optional from platypush.config import Config +from platypush.message import Mapping from platypush.message.response import Response from platypush.message.event.camera import CameraRecordingStartedEvent, \ CameraRecordingStoppedEvent, CameraVideoRenderedEvent, \ @@ -15,6 +18,29 @@ from platypush.message.event.camera import CameraRecordingStartedEvent, \ from platypush.plugins import Plugin, action +class StreamingOutput: + def __init__(self): + self.frame = None + self.buffer = io.BytesIO() + self.ready = threading.Condition() + + @staticmethod + def is_new_frame(buf): + # JPEG header begin + return buf.startswith(b'\xff\xd8') + + def write(self, buf): + if self.is_new_frame(buf): + # New frame, copy the existing buffer's content and notify all clients that it's available + self.buffer.truncate() + with self.ready: + self.frame = self.buffer.getvalue() + self.ready.notify_all() + self.buffer.seek(0) + + return self.buffer.write(buf) + + class CameraPlugin(Plugin): """ Plugin to control generic cameras over OpenCV. @@ -33,6 +59,7 @@ class CameraPlugin(Plugin): Requires: * **opencv** (``pip install opencv-python``) + """ _default_warmup_frames = 5 @@ -68,7 +95,8 @@ class CameraPlugin(Plugin): :param video_type: Default video type to use when exporting captured frames to camera (default: 0, infers the type from the video file - extension). See https://docs.opencv.org/4.0.1/dd/d9e/classcv_1_1VideoWriter.html#afec93f94dc6c0b3e28f4dd153bc5a7f0 + extension). See + `here `_ for a reference on the supported types (e.g. 'MJPEG', 'XVID', 'H264' etc') :type video_type: str or int @@ -120,7 +148,7 @@ class CameraPlugin(Plugin): if isinstance(video_type, str): import cv2 - self.video_type = cv2.VideoWriter_fourcc(*video_type) + self.video_type = cv2.VideoWriter_fourcc(*video_type.upper()) self.sleep_between_frames = sleep_between_frames self.max_stored_frames = max_stored_frames @@ -134,6 +162,7 @@ class CameraPlugin(Plugin): self._devices = {} # device_id => VideoCapture map self._recording_threads = {} # device_id => Thread map self._recording_info = {} # device_id => recording info map + self._output = None def _init_device(self, device_id, frames_dir=None, **info): import cv2 @@ -190,7 +219,7 @@ class CameraPlugin(Plugin): ret = sorted([ os.path.join(frames_dir, f) for f in os.listdir(frames_dir) if os.path.isfile(os.path.join(frames_dir, f)) and - re.search(self._frame_filename_regex, f) + re.search(self._frame_filename_regex, f) ]) return ret @@ -243,14 +272,12 @@ class CameraPlugin(Plugin): frames_dir, n_frames, sleep_between_frames, max_stored_frames, color_transform, video_type, scale_x, scale_y, rotate, flip): - import cv2 device = self._devices[device_id] - color_transform = getattr(cv2, self.color_transform) + color_transform = getattr(cv2, color_transform or self.color_transform) rotation_matrix = None self._is_recording[device_id].wait() - self.logger.info('Starting recording from video device {}'. - format(device_id)) + self.logger.info('Starting recording from video device {}'.format(device_id)) recording_started_time = time.time() captured_frames = 0 @@ -280,8 +307,7 @@ class CameraPlugin(Plugin): if rotate: rows, cols = frame.shape if not rotation_matrix: - rotation_matrix = cv2.getRotationMatrix2D( - (cols / 2, rows / 2), rotate, 1) + rotation_matrix = cv2.getRotationMatrix2D((cols / 2, rows / 2), rotate, 1) frame = cv2.warpAffine(frame, rotation_matrix, (cols, rows)) @@ -294,8 +320,16 @@ class CameraPlugin(Plugin): frame = cv2.resize(frame, None, fx=scale_x, fy=scale_y, interpolation=cv2.INTER_CUBIC) - self._store_frame_to_file(frame=frame, frames_dir=frames_dir, - image_file=image_file) + if self._output: + result, frame = cv2.imencode('.jpg', frame) + if not result: + self.logger.warning('Unable to convert frame to JPEG') + continue + + self._output.write(frame.tobytes()) + elif frames_dir: + self._store_frame_to_file(frame=frame, frames_dir=frames_dir, image_file=image_file) + captured_frames += 1 self.fire_event(CameraFrameCapturedEvent(filename=image_file)) @@ -326,31 +360,32 @@ class CameraPlugin(Plugin): return thread @action - def start_recording(self, duration=None, video_file=None, video_type=None, - device_id=None, frames_dir=None, - sleep_between_frames=None, max_stored_frames=None, - color_transform=None, scale_x=None, scale_y=None, - rotate=None, flip=None): + def start_recording(self, duration: Optional[float] = None, video_file: Optional[str] = None, + video_type: Optional[str] = None, device_id: Optional[int] = None, + frames_dir: Optional[str] = None, sleep_between_frames: Optional[float] = None, + max_stored_frames: Optional[int] = None, color_transform: Optional[str] = None, + scale_x: Optional[float] = None, scale_y: Optional[float] = None, + rotate: Optional[float] = None, flip: Optional[int] = None): """ Start recording :param duration: Record duration in seconds (default: None, record until ``stop_recording``) - :type duration: float - :param video_file: If set, the stream will be recorded to the specified video file (default: None) - :type video_file: str - :param video_type: Overrides the default configured ``video_type`` - :type video_file: str - :param device_id, frames_dir, sleep_between_frames, max_stored_frames, - color_transform, scale_x, scale_y, rotate, flip: Set - these parameters if you want to override the default configured ones. + :param device_id: Override default device_id + :param frames_dir: Override default frames_dir + :param sleep_between_frames: Override default sleep_between_frames + :param max_stored_frames: Override default max_stored_frames + :param color_transform: Override default color_transform + :param scale_x: Override default scale_x + :param scale_y: Override default scale_y + :param rotate: Override default rotate + :param flip: Override default flip """ - import cv2 device_id = device_id if device_id is not None else self.default_device_id if device_id in self._is_recording and \ self._is_recording[device_id].is_set(): @@ -360,56 +395,55 @@ class CameraPlugin(Plugin): recording_started = threading.Event() + # noinspection PyUnusedLocal def on_recording_started(event): recording_started.set() - frames_dir = os.path.abspath(os.path.expanduser(frames_dir)) \ - if frames_dir is not None else self.frames_dir - sleep_between_frames = sleep_between_frames if sleep_between_frames \ - is not None else self.sleep_between_frames - max_stored_frames = max_stored_frames if max_stored_frames \ - is not None else self.max_stored_frames - color_transform = color_transform if color_transform \ - is not None else self.color_transform - scale_x = scale_x if scale_x is not None else self.scale_x - scale_y = scale_y if scale_y is not None else self.scale_y - rotate = rotate if rotate is not None else self.rotate - flip = flip if flip is not None else self.flip + attrs = self._get_attributes(frames_dir=frames_dir, sleep_between_frames=sleep_between_frames, + max_stored_frames=max_stored_frames, color_transform=color_transform, + scale_x=scale_x, scale_y=scale_y, rotate=rotate, flip=flip, video_type=video_type) - if video_type is not None: - video_type = cv2.VideoWriter_fourcc(*video_type.upper()) \ - if isinstance(video_type, str) else video_type - else: - video_type = self.video_type + # noinspection PyUnresolvedReferences + if attrs.frames_dir: + # noinspection PyUnresolvedReferences + attrs.frames_dir = os.path.join(attrs.frames_dir, str(device_id)) + if video_file: + video_file = os.path.abspath(os.path.expanduser(video_file)) + attrs.frames_dir = os.path.join(attrs.frames_dir, 'recording_{}'.format( + datetime.now().strftime('%Y-%m-%d_%H-%M-%S-%f'))) - frames_dir = os.path.join(frames_dir, str(device_id)) - if video_file: - video_file = os.path.abspath(os.path.expanduser(video_file)) - frames_dir = os.path.join(frames_dir, 'recording_{}'.format( - datetime.now().strftime('%Y-%m-%d_%H-%M-%S-%f'))) - - self._init_device(device_id, video_file=video_file, - video_type=video_type, - frames_dir=frames_dir, - sleep_between_frames=sleep_between_frames, - max_stored_frames=max_stored_frames, - color_transform=color_transform, scale_x=scale_x, - scale_y=scale_y, rotate=rotate, flip=flip) + # noinspection PyUnresolvedReferences + self._init_device(device_id, + video_file=video_file, + video_type=attrs.video_type, + frames_dir=attrs.frames_dir, + sleep_between_frames=attrs.sleep_between_frames, + max_stored_frames=attrs.max_stored_frames, + color_transform=attrs.color_transform, + scale_x=attrs.scale_x, + scale_y=attrs.scale_y, + rotate=attrs.rotate, + flip=attrs.flip) self.register_handler(CameraRecordingStartedEvent, on_recording_started) + # noinspection PyUnresolvedReferences self._recording_threads[device_id] = threading.Thread( target=self._recording_thread(), kwargs={ 'duration': duration, 'video_file': video_file, - 'video_type': video_type, - 'image_file': None, 'device_id': device_id, - 'frames_dir': frames_dir, 'n_frames': None, - 'sleep_between_frames': sleep_between_frames, - 'max_stored_frames': max_stored_frames, - 'color_transform': color_transform, - 'scale_x': scale_x, 'scale_y': scale_y, - 'rotate': rotate, 'flip': flip + 'video_type': attrs.video_type, + 'image_file': None, + 'device_id': device_id, + 'frames_dir': attrs.frames_dir, + 'n_frames': None, + 'sleep_between_frames': attrs.sleep_between_frames, + 'max_stored_frames': attrs.max_stored_frames, + 'color_transform': attrs.color_transform, + 'scale_x': attrs.scale_x, + 'scale_y': attrs.scale_y, + 'rotate': attrs.rotate, + 'flip': attrs.flip, }) self._recording_threads[device_id].start() @@ -430,24 +464,52 @@ class CameraPlugin(Plugin): self._release_device(device_id) shutil.rmtree(frames_dir, ignore_errors=True) + def _get_attributes(self, frames_dir=None, warmup_frames=None, + color_transform=None, scale_x=None, scale_y=None, + rotate=None, flip=None, sleep_between_frames=None, + max_stored_frames=None, video_type=None) -> Mapping: + import cv2 + + warmup_frames = warmup_frames if warmup_frames is not None else self.warmup_frames + frames_dir = os.path.abspath(os.path.expanduser(frames_dir)) if frames_dir is not None else self.frames_dir + sleep_between_frames = sleep_between_frames if sleep_between_frames is not None else self.sleep_between_frames + max_stored_frames = max_stored_frames if max_stored_frames is not None else self.max_stored_frames + color_transform = color_transform if color_transform is not None else self.color_transform + scale_x = scale_x if scale_x is not None else self.scale_x + scale_y = scale_y if scale_y is not None else self.scale_y + rotate = rotate if rotate is not None else self.rotate + flip = flip if flip is not None else self.flip + if video_type is not None: + video_type = cv2.VideoWriter_fourcc(*video_type.upper()) if isinstance(video_type, str) else video_type + else: + video_type = self.video_type + + return Mapping(warmup_frames=warmup_frames, frames_dir=frames_dir, sleep_between_frames=sleep_between_frames, + max_stored_frames=max_stored_frames, color_transform=color_transform, scale_x=scale_x, + scale_y=scale_y, rotate=rotate, flip=flip, video_type=video_type) + @action - def take_picture(self, image_file, device_id=None, warmup_frames=None, - color_transform=None, scale_x=None, scale_y=None, - rotate=None, flip=None): + def take_picture(self, image_file: str, device_id: Optional[int] = None, warmup_frames: Optional[int] = None, + color_transform: Optional[str] = None, scale_x: Optional[float] = None, + scale_y: Optional[float] = None, rotate: Optional[float] = None, flip: Optional[int] = None): """ Take a picture. :param image_file: Path where the output image will be stored. - :type image_file: str - - :param device_id, warmup_frames, color_transform, scale_x, scale_y, - rotate, flip: Overrides the configured default parameters + :param device_id: Override default device_id + :param warmup_frames: Override default warmup_frames + :param color_transform: Override default color_transform + :param scale_x: Override default scale_x + :param scale_y: Override default scale_y + :param rotate: Override default rotate + :param flip: Override default flip """ device_id = device_id if device_id is not None else self.default_device_id image_file = os.path.abspath(os.path.expanduser(image_file)) picture_taken = threading.Event() + # noinspection PyUnusedLocal def on_picture_taken(event): picture_taken.set() @@ -464,20 +526,13 @@ class CameraPlugin(Plugin): raise RuntimeError('Recording already in progress and no images ' + 'have been captured yet') - warmup_frames = warmup_frames if warmup_frames is not None else \ - self.warmup_frames - color_transform = color_transform if color_transform \ - is not None else self.color_transform - scale_x = scale_x if scale_x is not None else self.scale_x - scale_y = scale_y if scale_y is not None else self.scale_y - rotate = rotate if rotate is not None else self.rotate - flip = flip if flip is not None else self.flip + attrs = self._get_attributes(warmup_frames=warmup_frames, color_transform=color_transform, scale_x=scale_x, + scale_y=scale_y, rotate=rotate, flip=flip) - self._init_device(device_id, image_file=image_file, - warmup_frames=warmup_frames, - color_transform=color_transform, - scale_x=scale_x, scale_y=scale_y, rotate=rotate, - flip=flip) + # noinspection PyUnresolvedReferences + self._init_device(device_id, image_file=image_file, warmup_frames=attrs.warmup_frames, + color_transform=attrs.color_transform, scale_x=attrs.scale_x, scale_y=attrs.scale_y, + rotate=attrs.rotate, flip=attrs.flip) self.register_handler(CameraPictureTakenEvent, on_picture_taken) self._recording_threads[device_id] = threading.Thread( @@ -524,5 +579,18 @@ class CameraPlugin(Plugin): def get_default_device_id(self): return self.default_device_id + def get_stream(self): + return self._output + + def __enter__(self): + device_id = self.default_device_id + self._output = StreamingOutput() + self._init_device(device_id=device_id) + self.start_recording(device_id=device_id) + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop_recording(self.default_device_id) + self._output = None + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/camera/pi.py b/platypush/plugins/camera/pi.py index bac51625..86353fc6 100644 --- a/platypush/plugins/camera/pi.py +++ b/platypush/plugins/camera/pi.py @@ -2,34 +2,15 @@ .. moduleauthor:: Fabio Manganiello """ -import io import os import socket import threading import time -from typing import Optional, Union +from typing import Optional from platypush.plugins import action -from platypush.plugins.camera import CameraPlugin - - -class StreamingOutput: - def __init__(self): - self.frame = None - self.buffer = io.BytesIO() - self.ready = threading.Condition() - - def write(self, buf): - if buf.startswith(b'\xff\xd8'): - # New frame, copy the existing buffer's content and notify all clients that it's available - self.buffer.truncate() - with self.ready: - self.frame = self.buffer.getvalue() - self.ready.notify_all() - self.buffer.seek(0) - - return self.buffer.write(buf) +from platypush.plugins.camera import CameraPlugin, StreamingOutput class CameraPiPlugin(CameraPlugin): @@ -85,7 +66,6 @@ class CameraPiPlugin(CameraPlugin): self._streaming_thread = None self._time_lapse_stop_condition = threading.Condition() self._recording_stop_condition = threading.Condition() - self._output = None self._can_stream = False # noinspection PyUnresolvedReferences,PyPackageRequirements @@ -200,9 +180,6 @@ class CameraPiPlugin(CameraPlugin): self._output = StreamingOutput() camera.start_recording(self._output, format='mjpeg') - def get_stream(self): - return self._output - def __exit__(self, exc_type, exc_val, exc_tb): self.close() @@ -501,9 +478,6 @@ class CameraPiPlugin(CameraPlugin): self.logger.info('Starting streaming on port {}'.format(listen_port)) while self._can_stream: - sock = None - stream = None - try: sock = server_socket.accept()[0] stream = sock.makefile('wb')