Skip to content

Multiple encoder support #588

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Mar 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions examples/capture_circular_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
encoder = H264Encoder(1000000, repeat=True)
circ = CircularOutput()
encoder.output = [circ]
picam2.encoder = encoder
picam2.encoders = encoder
picam2.start()
picam2.start_encoder()

Expand All @@ -40,7 +40,7 @@ def server():
stream = conn.makefile("wb")
filestream = FileOutput(stream)
filestream.start()
picam2.encoder.output = [circ, filestream]
encoder.output = [circ, filestream]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose if we rename picam2.encoder to picam2.encoders then we could redefine picam2.encoder to give you the encoder object if there's exactly one, and otherwise complain. But maybe that's bending over backwards too much for backwards compatibility, it certainly feels like it's starting to get a bit fussy!

filestream.connectiondead = lambda _: event.set() # noqa
event.wait()

Expand Down
4 changes: 2 additions & 2 deletions examples/capture_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
sock.bind(("0.0.0.0", 10001))
sock.listen()

picam2.encoder = encoder
picam2.encoders = encoder

conn, addr = sock.accept()
stream = conn.makefile("wb")
picam2.encoder.output = FileOutput(stream)
encoder.output = FileOutput(stream)
picam2.start_encoder()
picam2.start()
time.sleep(20)
Expand Down
19 changes: 19 additions & 0 deletions examples/capture_video_multiple.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/python3
import time

from picamera2 import Picamera2
from picamera2.encoders import H264Encoder, MJPEGEncoder

picam2 = Picamera2()
video_config = picam2.create_video_configuration(main={"size": (1280, 720), "format": "RGB888"},
lores={"size": (640, 480), "format": "YUV420"})

picam2.configure(video_config)

encoder1 = H264Encoder(10000000)
encoder2 = MJPEGEncoder(10000000)

picam2.start_recording(encoder1, 'test1.h264')
picam2.start_recording(encoder2, 'test2.mjpeg', name="lores")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, this was why you made it OK to start the Picamera2 object twice! :)

time.sleep(10)
picam2.stop_recording()
22 changes: 22 additions & 0 deletions examples/capture_video_multiple_2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/python3
import time

from picamera2 import Picamera2
from picamera2.encoders import H264Encoder, MJPEGEncoder

picam2 = Picamera2()
video_config = picam2.create_video_configuration(main={"size": (1280, 720), "format": "RGB888"},
lores={"size": (640, 480), "format": "YUV420"})

picam2.configure(video_config)

encoder1 = H264Encoder(10000000)
encoder2 = MJPEGEncoder(10000000)

picam2.start_recording(encoder1, 'test1.h264')
time.sleep(5)
picam2.start_encoder(encoder2, 'test2.mjpeg', name="lores")
time.sleep(5)
picam2.stop_encoder(encoder2)
time.sleep(5)
picam2.stop_recording()
24 changes: 23 additions & 1 deletion picamera2/encoders/encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def __init__(self):
self._format = None
self._output = []
self._running = False
self._name = None
self._lock = threading.Lock()
self.firsttimestamp = None

Expand Down Expand Up @@ -185,6 +186,27 @@ def output(self, value):
raise RuntimeError("Must pass Output")
self._output = value

@property
def name(self):
"""Gets stream name

:return: Name
:rtype: str
"""
return self._name

@name.setter
def name(self, value):
"""Sets stream name

:param value: Name
:type value: str
:raises RuntimeError: Failed to set name
"""
if not isinstance(value, str):
raise RuntimeError("Name must be string")
self._name = value

def encode(self, stream, request):
"""Encode a frame

Expand All @@ -199,7 +221,7 @@ def encode(self, stream, request):
def _encode(self, stream, request):
fb = request.request.buffers[stream]
timestamp_us = self._timestamp(fb)
with _MappedBuffer(request, request.picam2.encode_stream_name) as b:
with _MappedBuffer(request, self.name) as b:
self.outputframe(b, keyframe=True, timestamp=timestamp_us)

def start(self):
Expand Down
6 changes: 3 additions & 3 deletions picamera2/encoders/h264_encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def __init__(self, bitrate=None, repeat=True, iperiod=None, framerate=None, enab
self.qp = qp
# The framerate can be reported in the sequence headers if enable_sps_framerate is set,
# but there's no guarantee that frames will be delivered to the codec at that rate!
self._framerate = framerate
self.framerate = framerate
self._enable_framerate = enable_sps_framerate

def _start(self):
Expand All @@ -51,8 +51,8 @@ def _start(self):
codec_level = 40
# We may need to up the codec level to 4.2 if we have a guidance framerate and the
# required macroblocks per second is too high.
if self._framerate is not None:
mbs_per_sec = ((self._width + 15) // 16) * ((self._height + 15) // 16) * self._framerate
if self.framerate is not None:
mbs_per_sec = ((self._width + 15) // 16) * ((self._height + 15) // 16) * self.framerate
if mbs_per_sec > 245760:
self._controls += [(V4L2_CID_MPEG_VIDEO_H264_LEVEL, V4L2_MPEG_VIDEO_H264_LEVEL_4_2)]
codec_level = 42
Expand Down
2 changes: 1 addition & 1 deletion picamera2/encoders/multi_encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def do_encode(self, request, stream):
"""
fb = request.request.buffers[stream]
timestamp_us = self._timestamp(fb)
buffer = self.encode_func(request, request.picam2.encode_stream_name)
buffer = self.encode_func(request, self.name)
request.release()
return (buffer, timestamp_us)

Expand Down
9 changes: 5 additions & 4 deletions picamera2/encoders/v4l2_encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ def __init__(self, bitrate, pixformat):
self._pixformat = pixformat
self._controls = []
self.vd = None
self._framerate = None
self.framerate = None
self._enable_framerate = False

def _start(self):
self.vd = open('/dev/video11', 'rb+', buffering=0)
Expand Down Expand Up @@ -74,14 +75,14 @@ def _start(self):
fmt.fmt.pix_mp.plane_fmt[0].sizeimage = 512 << 10
fcntl.ioctl(self.vd, VIDIOC_S_FMT, fmt)

if self._framerate is not None and self._enable_framerate:
if self.framerate is not None and self._enable_framerate:
# Some codecs, such as H264, support this parameter. Our other codecs do not,
# and do not allow you to set the _framerate property.
# and do not allow you to set the framerate property.
sparm = v4l2_streamparm()
sparm.type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE
sparm.parm.output.capabilities = V4L2_CAP_TIMEPERFRAME
sparm.parm.output.timeperframe.numerator = 1000
sparm.parm.output.timeperframe.denominator = round(self._framerate * 1000)
sparm.parm.output.timeperframe.denominator = round(self.framerate * 1000)
fcntl.ioctl(self.vd, VIDIOC_S_PARM, sparm)

if len(self._controls) > 0:
Expand Down
102 changes: 63 additions & 39 deletions picamera2/picamera2.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import time
from enum import Enum
from functools import partial
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Tuple

import libcamera
import numpy as np
Expand Down Expand Up @@ -270,7 +270,7 @@ def _reset_flags(self) -> None:
self.frames = 0
self._job_list = []
self.options = {}
self._encoder = None
self._encoders = set()
self.pre_callback = None
self.post_callback = None
self.completed_requests: List[CompletedRequest] = []
Expand Down Expand Up @@ -873,8 +873,6 @@ def configure_(self, camera_config="preview") -> None:
"""
if self.started:
raise RuntimeError("Camera must be stopped before configuring")
if self.encoder is not None and self.encoder.running:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed this check, to allow starting multiple encoders

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels to me as though we still want something like this. It would need to complain if any encoder in the set is still running. Does that sound correct?

raise RuntimeError("Encoder must be stopped before configuring")
initial_config = camera_config
if isinstance(initial_config, str):
if initial_config == "preview":
Expand Down Expand Up @@ -937,9 +935,6 @@ def configure_(self, camera_config="preview") -> None:
self.encode_stream_name = camera_config['encode']
if self.encode_stream_name is not None and self.encode_stream_name not in camera_config:
raise RuntimeError(f"Encode stream {self.encode_stream_name} was not defined")
elif self.encode_stream_name is None:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed this check as encoders don't need to always encode from encode_stream_name

# If no encode stream then remove the encoder
self._encoder = None

# Decide whether we are going to keep hold of the last completed request, or
# whether capture requests will always wait for the next frame. If there's only
Expand Down Expand Up @@ -990,7 +985,7 @@ def start_(self) -> None:
if self.camera_config is None:
raise RuntimeError("Camera has not been configured")
if self.started:
raise RuntimeError("Camera already started")
return
controls = self.controls.get_libcamera_controls()
self.controls = Controls(self)
if self.camera.start(controls) >= 0:
Expand Down Expand Up @@ -1114,17 +1109,15 @@ def process_requests(self, display) -> None:
if self._job_list[0].execute():
finished_jobs.append(self._job_list.pop(0))

if self.encode_stream_name in self.stream_map:
stream = self.stream_map[self.encode_stream_name]

for req in requests:
# Some applications may want to do something to the image after they've had a change
# to copy it, but before it goes to the video encoder.
if self.post_callback:
self.post_callback(req)

if self._encoder is not None:
self._encoder.encode(stream, req)
for encoder in self._encoders:
if encoder.name in self.stream_map:
encoder.encode(self.stream_map[encoder.name], req)

req.release()

Expand Down Expand Up @@ -1434,63 +1427,94 @@ def capture_image_and_switch_back_(self, preview_config, name) -> Image:
partial(capture_image_and_switch_back_, self, preview_config, name)]
return self.dispatch_functions(functions, wait, signal_function)

def start_encoder(self, encoder=None, output=None, pts=None, quality=Quality.MEDIUM) -> None:
def start_encoder(self, encoder=None, output=None, pts=None, quality=Quality.MEDIUM, name=None) -> None:
"""Start encoder

:param encoder: Sets encoder or uses existing, defaults to None
:type encoder: Encoder, optional
:raises RuntimeError: No encoder set or no stream
"""
_encoder = None
if encoder is not None:
self.encoder = encoder
_encoder = encoder
else:
if len(self._encoders) > 1:
raise RuntimeError("Multiple possible encoders, need to pass encoder")
elif len(self._encoders) == 1:
_encoder = list(self._encoders)[0]
if _encoder is None:
raise RuntimeError("No encoder specified")
if output is not None:
if isinstance(output, str):
output = FileOutput(output, pts=pts)
encoder.output = output
_encoder.output = output
streams = self.camera_configuration()
if self.encoder is None:
raise RuntimeError("No encoder specified")
name = self.encode_stream_name
if name is None:
name = self.encode_stream_name
if streams.get(name, None) is None:
raise RuntimeError(f"Encode stream {name} was not defined")
self.encoder.width, self.encoder.height = streams[name]['size']
self.encoder.format = streams[name]['format']
self.encoder.stride = streams[name]['stride']
_encoder.name = name
_encoder.width, _encoder.height = streams[name]['size']
_encoder.format = streams[name]['format']
_encoder.stride = streams[name]['stride']
# Also give the encoder a nominal framerate, which we'll peg at 30fps max
# in case we only have a dummy value
min_frame_duration = self.camera_ctrl_info["FrameDurationLimits"][1].min
min_frame_duration = max(min_frame_duration, 33333)
self.encoder.framerate = 1000000 / min_frame_duration
try:
if _encoder.framerate is None:
_encoder.framerate = 1000000 / min_frame_duration
except AttributeError:
pass
# Finally the encoder must set up any remaining unknown parameters (e.g. bitrate).
self.encoder._setup(quality)
self.encoder.start()
_encoder._setup(quality)
_encoder.start()
with self.lock:
self._encoders.add(_encoder)

def stop_encoder(self) -> None:
def stop_encoder(self, encoders=None) -> None:
"""Stops the encoder"""
self.encoder.stop()
remove = []
if encoders is None:
for encoder in self._encoders:
encoder.stop()
remove += [encoder]
elif isinstance(encoders, Encoder):
encoders.stop()
remove += [encoders]
elif isinstance(encoders, list) or isinstance(encoders, set):
for encoder in encoders:
encoder.stop()
remove += [encoder]
with self.lock:
for encoder in remove:
self._encoders.remove(encoder)

@property
def encoder(self) -> Optional[Encoder]:
"""Extract current Encoder object
def encoders(self) -> set[Encoder]:
"""Extract current Encoder objects

:return: Encoder
:rtype: Encoder
:return: Set of encoders
:rtype: set
"""
return self._encoder
return self._encoders

@encoder.setter
def encoder(self, value):
@encoders.setter
def encoders(self, value):
"""Set Encoder to be used

:param value: Encoder to be set
:type value: Encoder
:raises RuntimeError: Fail to pass Encoder
"""
if not isinstance(value, Encoder):
raise RuntimeError("Must pass encoder instance")
self._encoder = value
if isinstance(value, Encoder):
self._encoders.add(value)
elif isinstance(value, set):
self._encoders.update(value)
else:
raise RuntimeError("Must pass Encoder or set of")

def start_recording(self, encoder, output, pts=None, config=None, quality=Quality.MEDIUM) -> None:
def start_recording(self, encoder, output, pts=None, config=None, quality=Quality.MEDIUM, name=None) -> None:
"""Start recording a video using the given encoder and to the given output.

Output may be a string in which case the correspondingly named file is opened.
Expand All @@ -1504,7 +1528,7 @@ def start_recording(self, encoder, output, pts=None, config=None, quality=Qualit
config = "video"
if config is not None:
self.configure(config)
self.start_encoder(encoder, output, pts=pts, quality=quality)
self.start_encoder(encoder, output, pts=pts, quality=quality, name=name)
self.start()

def stop_recording(self) -> None:
Expand Down
2 changes: 2 additions & 0 deletions tests/test_list.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ examples/capture_old_request.py
examples/capture_png.py
examples/capture_to_buffer.py
examples/capture_video.py
examples/capture_video_multiple.py
examples/capture_video_multiple_2.py
examples/controls.py
examples/controls_2.py
examples/display_transform_qtgl.py
Expand Down