platypush/platypush/plugins/camera/__init__.py

1057 lines
38 KiB
Python

import io
import os
import pathlib
import socket
import threading
import time
from abc import ABC, abstractmethod
from contextlib import contextmanager
from dataclasses import asdict
from datetime import datetime
from multiprocessing import Process
from queue import Queue
from typing import Generator, Optional, Union, Dict, Tuple, IO
from platypush.config import Config
from platypush.message.event.camera import (
CameraRecordingStartedEvent,
CameraPictureTakenEvent,
CameraRecordingStoppedEvent,
CameraVideoRenderedEvent,
)
from platypush.plugins import RunnablePlugin, action
from platypush.plugins.camera.model.camera import CameraInfo, Camera
from platypush.plugins.camera.model.exceptions import (
CameraException,
CaptureAlreadyRunningException,
)
from platypush.plugins.camera.model.writer import VideoWriter, StreamWriter
from platypush.plugins.camera.model.writer.ffmpeg import FFmpegFileWriter
from platypush.plugins.camera.model.writer.preview import (
PreviewWriter,
PreviewWriterFactory,
)
from platypush.utils import get_plugin_name_by_class, wait_for_either
__all__ = [
'Camera',
'CameraInfo',
'CameraException',
'CameraPlugin',
'CaptureAlreadyRunningException',
'VideoWriter',
'StreamWriter',
'PreviewWriter',
]
class CameraPlugin(RunnablePlugin, ABC):
"""
Abstract plugin to control camera devices.
If the :class:`platypush.backend.http.HttpBackend` is enabled then the plugins that implement this class can
expose two endpoints:
- ``http://host:8008/camera/<plugin>/photo<.extension>`` to capture a photo from the camera, where
``.extension`` can be ``.jpg``, ``.png`` or ``.bmp``.
- ``http://host:8008/camera/<plugin>/video<.extension>`` to get a live feed from the camera, where
``.extension`` can be ``.mjpeg``, ``.mkv``/``.webm``, ``.mp4``/``.h264`` or ``.h265``.
Both the endpoints support the same parameters of the constructor of this class (e.g. ``device``, ``warmup_frames``,
``duration`` etc.) as ``GET`` parameters.
"""
_camera_class = Camera
_camera_info_class = CameraInfo
_video_writer_class = FFmpegFileWriter
def __init__(
self,
device: Optional[Union[int, str]] = None,
resolution: Tuple[int, int] = (640, 480),
frames_dir: Optional[str] = None,
warmup_frames: int = 5,
warmup_seconds: Optional[float] = 0.0,
capture_timeout: Optional[float] = 20.0,
scale_x: Optional[float] = None,
scale_y: Optional[float] = None,
rotate: Optional[float] = None,
grayscale: Optional[bool] = None,
color_transform: Optional[Union[int, str]] = None,
fps: float = 16,
horizontal_flip: bool = False,
vertical_flip: bool = False,
input_format: Optional[str] = None,
output_format: Optional[str] = None,
stream_format: str = 'mjpeg',
listen_port: Optional[int] = 5000,
bind_address: str = '0.0.0.0',
stream_on_start: bool = False,
ffmpeg_bin: str = 'ffmpeg',
input_codec: Optional[str] = None,
output_codec: Optional[str] = None,
**kwargs,
):
"""
:param device: Identifier of the default capturing device.
:param resolution: Default resolution, as a tuple of two integers.
:param frames_dir: Directory where the camera frames will be stored
(default: ``~/.local/share/platypush/<plugin.name>/frames``)
:param warmup_frames: Cameras usually take a while to adapt their
luminosity and focus to the environment when taking a picture.
This parameter allows you to specify the number of "warmup" frames
to capture upon picture command before actually capturing a frame
(default: 5 but you may want to calibrate this parameter for your
camera)
:param warmup_seconds: Number of seconds to wait before a picture is
taken or the first frame of a video/sequence is captured (default:
0).
:param capture_timeout: Maximum number of seconds to wait between the
programmed termination of a capture session and the moment the
device is released.
:param scale_x: If set, the images will be scaled along the x-axis by
the specified factor
:param scale_y: If set, the images will be scaled along the y-axis by
the specified factor
:param color_transform: Color transformation to apply to the images.
:param grayscale: Whether the output should be converted to grayscale.
:param rotate: If set, the images will be rotated by the specified
number of degrees
:param fps: Frames per second (default: 25).
:param horizontal_flip: If set, the images will be flipped on the
horizontal axis.
:param vertical_flip: If set, the images will be flipped on the vertical
axis.
:param listen_port: Default port to be used for streaming over TCP
(default: 5000).
:param bind_address: Default bind address for TCP streaming (default:
0.0.0.0, accept connections on any network interface).
:param stream_on_start: If set, the camera will start streaming on the
specified ``bind_address`` and ``listen_port`` as soon as the plugin
is started. Otherwise, the stream will be started only when the
:meth:`.start_streaming` method is called. Default: False.
:param input_codec: Specify the ffmpeg video codec (``-vcodec``) used
for the input.
:param output_codec: Specify the ffmpeg video codec (``-vcodec``) to be
used for encoding the output. For some ffmpeg output formats (e.g.
``h264`` and ``rtp``) this may default to ``libxvid``.
:param input_format: Plugin-specific format/type for the input stream.
:param output_format: Plugin-specific format/type for the output videos.
:param ffmpeg_bin: Path to the ffmpeg binary (default: ``ffmpeg``).
: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``)
"""
super().__init__(**kwargs)
workdir = Config.get('workdir')
plugin_name = get_plugin_name_by_class(self)
assert isinstance(workdir, str) and plugin_name
self.workdir = os.path.join(workdir, plugin_name)
self._stream_on_start = stream_on_start
pathlib.Path(self.workdir).mkdir(mode=0o755, exist_ok=True, parents=True)
self.camera_info = self._camera_info_class(
device,
color_transform=color_transform,
warmup_frames=warmup_frames,
warmup_seconds=warmup_seconds or 0,
rotate=rotate,
scale_x=scale_x,
scale_y=scale_y,
capture_timeout=capture_timeout or 20,
fps=fps,
input_format=input_format,
output_format=output_format,
stream_format=stream_format,
resolution=resolution,
grayscale=grayscale,
listen_port=listen_port,
horizontal_flip=horizontal_flip,
vertical_flip=vertical_flip,
ffmpeg_bin=ffmpeg_bin,
input_codec=input_codec,
output_codec=output_codec,
bind_address=bind_address,
frames_dir=os.path.abspath(
os.path.expanduser(frames_dir or os.path.join(self.workdir, 'frames'))
),
)
self._devices: Dict[Union[int, str], Camera] = {}
self._streams: Dict[Union[int, str], Camera] = {}
def _merge_info(self, **info) -> CameraInfo:
merged_info = self.camera_info.clone()
merged_info.set(**info)
return merged_info
def open_device(
self,
device: Optional[Union[int, str]] = None,
stream: bool = False,
redis_queue: Optional[str] = None,
ctx: Optional[dict] = None,
**params,
) -> Camera:
"""
Initialize and open a device.
:return: The initialized camera device.
:raises: :class:`platypush.plugins.camera.CaptureSessionAlreadyRunningException`
"""
info = self._merge_info(**params)
if device is None:
device = info.device
elif device not in self._devices:
info.device = device
else:
info = self._devices[device].info.clone()
assert device is not None, 'No device specified/configured'
if device in self._devices:
camera = self._devices[device]
if (
camera.capture_thread
and camera.capture_thread.is_alive()
and camera.start_event.is_set()
):
raise CaptureAlreadyRunningException(device)
camera.start_event.clear()
camera.capture_thread = None
else:
camera = self._camera_class(info=info)
ctx = ctx or {}
ctx['stream'] = stream
camera.info.set(**params)
camera.object = self.prepare_device(camera, **ctx)
if stream and camera.info.stream_format:
self._prepare_stream_writer(camera, redis_queue=redis_queue)
if camera.info.frames_dir:
pathlib.Path(
os.path.abspath(os.path.expanduser(camera.info.frames_dir))
).mkdir(mode=0o755, exist_ok=True, parents=True)
self._devices[device] = camera
return camera
def _prepare_stream_writer(self, camera: Camera, redis_queue: Optional[str] = None):
assert camera.info.stream_format, 'No stream format specified'
writer_class = StreamWriter.get_class_by_name(camera.info.stream_format)
camera.stream = writer_class(
camera=camera, plugin=self, redis_queue=redis_queue
)
def close_device(self, camera: Camera, wait_capture: bool = True) -> None:
"""
Close and release a device.
"""
name = camera.info.device
self.stop_preview(camera)
self.release_device(camera)
camera.start_event.clear()
if wait_capture:
self.wait_capture(camera)
if name in self._devices:
del self._devices[name]
def wait_capture(self, camera: Camera) -> None:
"""
Wait until a capture session terminates.
:param camera: Camera object. ``camera.info.capture_timeout`` is used as a capture thread termination timeout
if set.
"""
if (
camera.capture_thread
and camera.capture_thread.is_alive()
and threading.get_ident() != camera.capture_thread.ident
):
try:
camera.capture_thread.join(timeout=camera.info.capture_timeout)
except Exception as e:
self.logger.warning('Error on FFmpeg capture wait: %s', e)
@contextmanager
def open(
self,
device: Optional[Union[int, str]] = None,
stream: bool = False,
redis_queue: Optional[str] = None,
**info,
) -> Generator[Camera, None, None]:
"""
Initialize and open a device using a context manager pattern.
:param device: Capture device by name, path or ID.
:param stream: If set, the frames will be streamed to ``camera.stream``.
:param redis_queue: If set, the frames will be streamed to
``redis_queue``.
:param info: Camera parameters override - see constructors parameters.
:return: The initialized :class:`platypush.plugins.camera.Camera` object.
"""
camera = None
try:
camera = self.open_device(
device, stream=stream, redis_queue=redis_queue, **info
)
yield camera
finally:
if camera:
self.close_device(camera)
@abstractmethod
def prepare_device(self, device: Camera, **_):
"""
Prepare a device using the plugin-specific logic - to be implemented by the derived classes.
:param device: An initialized :class:`platypush.plugins.camera.Camera` object.
"""
raise NotImplementedError()
@abstractmethod
def release_device(self, device: Camera):
"""
Release a device using the plugin-specific logic - to be implemented by the derived classes.
:param device: An initialized :class:`platypush.plugins.camera.Camera` object.
"""
raise NotImplementedError()
@abstractmethod
def capture_frame(self, device: Camera, *args, **kwargs):
"""
Capture a frame from a device using the plugin-specific logic - to be implemented by the derived classes.
:param device: An initialized :class:`platypush.plugins.camera.Camera` object.
"""
raise NotImplementedError()
@staticmethod
def store_frame( # pylint: disable=redefined-builtin
frame, filepath: str, format: Optional[str] = None
):
"""
Capture a frame to the filesystem using the ``PIL`` library - it can be overridden by derived classes.
:param frame: Frame object (default: a byte-encoded object or a ``PIL.Image`` object).
:param filepath: Destination file.
:param format: Output format.
"""
from PIL import Image
if isinstance(frame, bytes):
frame = list(frame)
if not isinstance(frame, Image.Image):
frame = Image.fromarray(frame)
save_args = {}
if format:
save_args['format'] = format
frame.save(filepath, **save_args)
def _store_frame(
self,
frame,
*args,
frames_dir: Optional[str] = None,
image_file: Optional[str] = None,
**kwargs,
) -> str:
"""
:meth:`.store_frame` wrapper.
"""
if image_file:
filepath = os.path.abspath(os.path.expanduser(image_file))
else:
filepath = os.path.abspath(
os.path.expanduser(
os.path.join(
frames_dir or '',
datetime.now().strftime('%Y-%m-%d_%H-%M-%S-%f.jpg'),
)
)
)
pathlib.Path(filepath).parent.mkdir(mode=0o755, exist_ok=True, parents=True)
self.store_frame(frame, filepath, *args, **kwargs)
return filepath
def start_preview(self, camera: Camera):
if camera.preview and not camera.preview.closed:
self.logger.info(
'A preview window is already active on device %s', camera.info.device
)
return
camera.preview = PreviewWriterFactory.get(camera, self)
if isinstance(camera.preview, Process):
camera.preview.start()
def stop_preview(self, camera: Camera):
if camera.preview and not camera.preview.closed:
camera.preview.close()
if isinstance(camera.preview, Process) and camera.preview.is_alive():
camera.preview.terminate()
camera.preview.join(timeout=5.0)
if isinstance(camera.preview, Process) and camera.preview.is_alive():
camera.preview.kill()
camera.preview = None
def frame_processor(
self, frame_queue: Queue, camera: Camera, image_file: Optional[str] = None
):
while True:
frame = frame_queue.get()
if frame is None:
break
frame = self.transform_frame(frame, camera.info.color_transform)
if camera.info.grayscale:
frame = self.to_grayscale(frame)
frame = self.rotate_frame(frame, camera.info.rotate)
frame = self.flip_frame(
frame, camera.info.horizontal_flip, camera.info.vertical_flip
)
frame = self.scale_frame(frame, camera.info.scale_x, camera.info.scale_y)
for output in camera.get_outputs():
output.write(frame)
if camera.info.frames_dir or image_file:
self._store_frame(
frame=frame,
frames_dir=camera.info.frames_dir,
image_file=image_file,
)
def capturing_thread(
self,
camera: Camera,
duration: Optional[float] = None,
video_file: Optional[str] = None,
image_file: Optional[str] = None,
n_frames: Optional[int] = None,
preview: bool = False,
**kwargs,
):
"""
Camera capturing thread.
:param camera: An initialized :class:`platypush.plugins.camera.Camera` object.
:param duration: Capturing session duration in seconds (default: until :meth:`.stop_capture` is called).
:param video_file: If set, the session will be recorded to this output video file (video capture mode).
:param image_file: If set, the output of the session will be a single image file (photo mode).
:param n_frames: Number of frames to be captured (default: until :meth:`.stop_capture` is called).
:param preview: Start a preview window.
:param kwargs: Extra arguments to be passed to :meth:`.capture_frame`.
"""
camera.start_event.wait()
recording_started_time = time.time()
captured_frames = 0
evt_args = {
'device': camera.info.device,
}
if video_file or image_file:
evt_args['filename'] = video_file or image_file
if camera.info.frames_dir:
evt_args['frames_dir'] = camera.info.frames_dir
if preview:
self.start_preview(camera)
if duration and camera.info.warmup_seconds:
duration = duration + camera.info.warmup_seconds
if video_file:
camera.file_writer = self._video_writer_class(
camera=camera, plugin=self, output_file=video_file
)
frame_queue = Queue()
frame_processor = threading.Thread(
target=self.frame_processor,
kwargs={
'frame_queue': frame_queue,
'camera': camera,
'image_file': image_file,
},
)
frame_processor.start()
self.fire_event(CameraRecordingStartedEvent(**evt_args))
try:
while camera.start_event.is_set():
if (duration and time.time() - recording_started_time >= duration) or (
n_frames and captured_frames >= n_frames
):
break
frame_capture_start = time.time()
try:
frame = self.capture_frame(camera, **kwargs)
if not frame:
self.logger.warning(
'Invalid frame received, terminating the capture session'
)
break
frame_queue.put(frame)
except AssertionError as e:
self.logger.warning(str(e))
continue
if (
not n_frames
or not camera.info.warmup_seconds
or (
time.time() - recording_started_time
>= camera.info.warmup_seconds
)
):
captured_frames += 1
if camera.info.fps:
wait_time = (1.0 / camera.info.fps) - (
time.time() - frame_capture_start
)
if wait_time > 0:
time.sleep(wait_time)
finally:
frame_queue.put(None)
self.stop_preview(camera)
for output in camera.get_outputs():
try:
output.close()
except Exception as e:
self.logger.warning('Could not close camera output: %s', e)
self.close_device(camera, wait_capture=False)
frame_processor.join(timeout=5.0)
self.fire_event(CameraRecordingStoppedEvent(**evt_args))
if image_file:
self.fire_event(CameraPictureTakenEvent(filename=image_file))
if video_file:
self.fire_event(CameraVideoRenderedEvent(filename=video_file))
def start_camera(self, camera: Camera, *args, preview: bool = False, **kwargs):
"""
Start a camera capture session.
:param camera: An initialized :class:`platypush.plugins.camera.Camera` object.
:param preview: Show a preview of the camera frames.
"""
assert not (
camera.capture_thread and camera.capture_thread.is_alive()
), 'A capture session is already in progress'
camera.capture_thread = threading.Thread(
target=self.capturing_thread,
args=(camera, *args),
kwargs={'preview': preview, **kwargs},
)
camera.capture_thread.start()
camera.start_event.set()
@action
def capture_video(
self,
device: Optional[Union[int, str]] = None,
duration: Optional[float] = None,
video_file: Optional[str] = None,
preview: bool = False,
**camera,
) -> Optional[Union[str, dict]]:
"""
Capture a video.
:param device: Name/path/ID of the device to capture from (default: None, use the default device).
:param duration: Record duration in seconds (default: None, record until ``stop_capture``).
:param video_file: If set, the stream will be recorded to the specified video file (default: None).
:param camera: Camera parameters override - see constructors parameters.
:param preview: Show a preview of the camera frames.
:return: If duration is specified, the method will wait until the recording is done and return the local path
to the recorded resource. Otherwise, it will return the status of the camera device after starting it.
"""
camera = self.open_device(device=device, **camera)
self.start_camera(
camera,
duration=duration,
video_file=video_file,
frames_dir=None,
image_file=None,
preview=preview,
)
if duration:
self.wait_capture(camera)
return video_file
return self.status(camera.info.device).output
@action
def stop_capture(self, device: Optional[Union[int, str]] = None):
"""
Stop any capturing session on the specified device.
:param device: Name/path/ID of the device to stop (default: all the active devices).
"""
devices = self._devices.copy()
stop_devices = list(devices.values())
if device:
stop_devices = [self._devices[device]] if device in self._devices else []
for dev in stop_devices:
self.close_device(dev)
@action
def capture_image(
self,
image_file: str,
device: Optional[Union[int, str]] = None,
preview: bool = False,
**camera,
) -> str:
"""
Capture an image.
:param image_file: Path where the output image will be stored.
:param device: Name/path/ID of the device to capture from (default: None, use the default device).
:param camera: Camera parameters override - see constructors parameters.
:param preview: Show a preview of the camera frames.
:return: The local path to the saved image.
"""
with self.open(device=device, **camera) as camera:
warmup_frames = (
camera.info.warmup_frames if camera.info.warmup_frames else 1
)
self.start_camera(
camera, image_file=image_file, n_frames=warmup_frames, preview=preview
)
self.wait_capture(camera)
return image_file
@action
def take_picture(
self, image_file: str, device: Optional[Union[int, str]] = None, **camera
) -> str:
"""
Alias for :meth:`.capture_image`.
:param image_file: Path where the output image will be stored.
:param device: Name/path/ID of the device to capture from (default: None, use the default device).
:param camera: Camera parameters override - see constructors parameters.
:return: The local path to the saved image.
"""
return str(self.capture_image(image_file, device=device, **camera).output)
@action
def capture_sequence(
self,
device: Optional[Union[int, str]] = None,
duration: Optional[float] = None,
n_frames: Optional[int] = None,
preview: bool = False,
**camera,
) -> Optional[str]:
"""
Capture a sequence of frames from a camera and store them to a directory.
:param device: Name/path/ID of the device to capture from (default: None, use the default device).
:param duration: Duration of the sequence in seconds (default: until :meth:`.stop_capture` is called).
:param n_frames: Number of images to be captured (default: until :meth:`.stop_capture` is called).
:param camera: Camera parameters override - see constructors parameters. ``frames_dir`` and ``fps`` in
particular can be specifically tuned for ``capture_sequence``.
:param preview: Show a preview of the camera frames.
:return: The directory where the image files have been stored.
"""
with self.open(device=device, **camera) as camera:
self.start_camera(
camera, duration=duration, n_frames=n_frames, preview=preview
)
self.wait_capture(camera)
return camera.info.frames_dir
@action
def capture_preview(
self,
device: Optional[Union[int, str]] = None,
duration: Optional[float] = None,
n_frames: Optional[int] = None,
**camera,
) -> dict:
"""
Start a camera preview session.
:param device: Name/path/ID of the device to capture from (default: None, use the default device).
:param duration: Preview duration (default: until :meth:`.stop_capture` is called).
:param n_frames: Number of frames to display before closing (default: until :meth:`.stop_capture` is called).
:param camera: Camera object properties.
:return: The status of the device.
"""
camera = self.open_device(device=device, frames_dir=None, **camera)
self.start_camera(camera, duration=duration, n_frames=n_frames, preview=True)
return self.status(camera.info.device) # type: ignore
@staticmethod
@contextmanager
def _prepare_server_socket(camera: Camera) -> Generator[socket.socket, None, None]:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv_sock:
srv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv_sock.bind(
( # lgtm [py/bind-socket-all-network-interfaces]
camera.info.bind_address or '0.0.0.0',
camera.info.listen_port,
)
)
srv_sock.listen(1)
srv_sock.settimeout(1)
yield srv_sock
def _accept_client(
self, server_socket: socket.socket
) -> Tuple[Optional[socket.socket], Optional[IO]]:
try:
sock = server_socket.accept()[0]
self.logger.info('Accepted client connection from %s', sock.getpeername())
return sock, sock.makefile('wb')
except socket.timeout:
return None, None
def streaming_thread(
self, camera: Camera, stream_format: str, duration: Optional[float] = None
):
with self._prepare_server_socket(camera) as srv_sock:
streaming_started_time = time.time()
sock, fp = None, None
self.logger.info('Starting streaming on port %s', camera.info.listen_port)
try:
while (
camera.stream_event.is_set()
and not camera.stop_stream_event.is_set()
and not self.should_stop()
):
if duration and time.time() - streaming_started_time >= duration:
break
sock, fp = self._accept_client(srv_sock)
if not (sock and fp):
continue
if duration and time.time() - streaming_started_time >= duration:
break
self._streaming_loop(
camera, stream_format, sock=fp, duration=duration
)
finally:
self._cleanup_stream(camera, srv_sock, fp)
self.logger.info('Stopped camera stream')
def _wait_stream_stop(self, camera: Camera):
try:
wait_for_either(self._should_stop, camera.stop_stream_event)
except Exception as e:
self.logger.debug('Error on streaming poll: %s', e)
def _streaming_loop(
self,
camera: Camera,
stream_format: str,
sock: IO,
duration: Optional[float] = None,
):
if camera.info.device not in self._devices:
info = asdict(camera.info)
info['stream_format'] = stream_format
camera = self.open_device(stream=True, **info)
assert camera.stream, 'No camera stream available'
camera.stream.sock = sock
self.start_camera(camera, duration=duration, frames_dir=None, image_file=None)
self._wait_stream_stop(camera)
def _cleanup_stream(
self, camera: Camera, server_socket: socket.socket, client: Optional[IO]
):
camera.stream_event.clear()
camera.stop_stream_event.set()
if client:
try:
client.close()
except Exception as e:
self.logger.warning('Error on client socket close: %s', e)
try:
server_socket.close()
except Exception as e:
self.logger.warning('Error on server socket close: %s', e)
if camera.stream:
try:
camera.stream.close()
except Exception as e:
self.logger.warning('Error while closing the encoding stream: %s', e)
@action
def start_streaming(
self,
device: Optional[Union[int, str]] = None,
duration: Optional[float] = None,
stream_format: Optional[str] = None,
**camera,
) -> dict:
"""
Expose the video stream of a camera over a TCP connection.
When the streaming is started, the plugin will listen on the specified
``bind_address`` and ``listen_port`` and stream camera frames to
connected clients. If ``stream_format`` is a video format (H264, H265,
MKV, MP4 etc.) then the camera stream can be viewed using a video
player - for example, using ``vlc``:
.. code-block:: bash
vlc tcp://<host>:<port>
:param device: Name/path/ID of the device to capture from (default:
None, use the default device).
:param duration: Streaming thread duration (default: until
:meth:`.stop_streaming` is called).
:param stream_format: Format of the output stream - e.g. ``h264``,
``mjpeg``, ``mkv`` etc. If not specified, the ``stream_format``
configured on the plugin will be used.
:param camera: Camera object properties - see constructor parameters.
:return: The status of the device.
"""
camera = self.open_device(
device=device,
stream=True,
stream_format=stream_format or self.camera_info.stream_format,
**camera,
)
return self._start_streaming(camera, duration, camera.info.stream_format) # type: ignore
def _start_streaming(
self, camera: Camera, duration: Optional[float], stream_format: str
):
assert camera.info.listen_port, 'No listen_port specified/configured'
assert (
not camera.stream_event.is_set() and camera.info.device not in self._streams
), f'A streaming session is already running for device {camera.info.device}'
assert camera.info.device is not None, 'No device name available'
self._streams[camera.info.device] = camera
camera.stream_event.set()
camera.stop_stream_event.clear()
camera.stream_thread = threading.Thread(
target=self.streaming_thread,
kwargs={
'camera': camera,
'duration': duration,
'stream_format': stream_format,
},
)
camera.stream_thread.start()
return self.status(camera.info.device)
@action
def stop_streaming(self, device: Optional[Union[int, str]] = None):
"""
Stop a camera over TCP session.
:param device: Name/path/ID of the device to stop (default: all the active devices).
"""
streams = self._streams.copy()
stop_devices = list(streams.values())
if device:
stop_devices = [self._streams[device]] if device in self._streams else []
for dev in stop_devices:
self._stop_streaming(dev)
def _stop_streaming(self, camera: Camera):
camera.stream_event.clear()
camera.stop_stream_event.set()
if camera.stream_thread and camera.stream_thread.is_alive():
camera.stream_thread.join(timeout=5.0)
if camera.info.device in self._streams:
del self._streams[camera.info.device]
def _status(self, device: Union[int, str]) -> dict:
camera = self._devices.get(device, self._streams.get(device))
if not camera:
return {}
return {
**asdict(camera.info),
'active': bool(camera.capture_thread and camera.capture_thread.is_alive()),
'capturing': bool(
camera.capture_thread
and camera.capture_thread.is_alive()
and camera.start_event.is_set()
),
'streaming': (
camera.stream_thread
and camera.stream_thread.is_alive()
and camera.stream_event.is_set()
),
}
@action
def status(self, device: Optional[Union[int, str]] = None):
"""
Returns the status of the specified camera or all the active cameras if ``device`` is ``None``.
"""
if device:
return self._status(device)
return {id: self._status(id) for id in self._devices}
@staticmethod
def transform_frame(frame, color_transform):
"""
Frame color space (e.g. ``RGB24``, ``YUV`` etc.) transform logic. Does nothing unless implemented by a
derived plugin.
"""
return frame.convert(color_transform)
def to_grayscale(self, frame):
"""
Convert a frame to grayscale. The default implementation assumes that frame is a ``PIL.Image`` object.
:param frame: Image frame (default: a ``PIL.Image`` object).
"""
from PIL import ImageOps
return ImageOps.grayscale(frame)
@staticmethod
def rotate_frame(frame, rotation: Optional[Union[float, int]] = None):
"""
Frame rotation logic. The default implementation assumes that frame is a ``PIL.Image`` object.
:param frame: Image frame (default: a ``PIL.Image`` object).
:param rotation: Rotation angle in degrees.
"""
if not rotation:
return frame
return frame.rotate(rotation, expand=True)
@staticmethod
def flip_frame(frame, horizontal_flip: bool = False, vertical_flip: bool = False):
"""
Frame flip logic. Does nothing unless implemented by a derived plugin.
:param frame: Image frame (default: a ``PIL.Image`` object).
:param horizontal_flip: Flip along the horizontal axis.
:param vertical_flip: Flip along the vertical axis.
"""
from PIL import Image
if horizontal_flip:
frame = frame.transpose(Image.FLIP_TOP_BOTTOM)
if vertical_flip:
frame = frame.transpose(Image.FLIP_LEFT_RIGHT)
return frame
@staticmethod
def scale_frame(
frame, scale_x: Optional[float] = None, scale_y: Optional[float] = None
):
"""
Frame scaling logic. The default implementation assumes that frame is a ``PIL.Image`` object.
:param frame: Image frame (default: a ``PIL.Image`` object).
:param scale_x: X-scale factor.
:param scale_y: Y-scale factor.
"""
from PIL import Image
if not (scale_x and scale_y) or (scale_x == 1 and scale_y == 1):
return frame
size = (int(frame.size[0] * scale_x), int(frame.size[1] * scale_y))
return frame.resize(size, Image.BICUBIC)
@staticmethod
def encode_frame(frame, encoding: str = 'jpeg') -> bytes:
"""
Encode a frame to a target type. The default implementation assumes that frame is a ``PIL.Image`` object.
:param frame: Image frame (default: a ``PIL.Image`` object).
:param encoding: Image encoding (e.g. ``jpeg``).
"""
if not encoding:
return frame
with io.BytesIO() as buf:
frame.save(buf, format=encoding)
return buf.getvalue()
@staticmethod
def _get_warmup_seconds(camera: Camera) -> float:
if camera.info.warmup_seconds:
return camera.info.warmup_seconds
if camera.info.warmup_frames and camera.info.fps:
return camera.info.warmup_frames / camera.info.fps
return 0
def main(self):
if not self._stream_on_start:
self.wait_stop()
return
while not self.should_stop():
if self._stream_on_start:
self.start_streaming()
cameras = list(self._streams.values())
if not cameras:
self.logger.warning('No camera devices could be streamed')
self.wait_stop()
break
camera = cameras[0]
try:
wait_for_either(self._should_stop, camera.stop_stream_event)
self.stop_streaming()
except Exception as e:
self.logger.warning('Error while stopping the camera stream: %s', e)
finally:
self.wait_stop(timeout=2)
# vim:sw=4:ts=4:et: