Skip to content

Commit 8809d74

Browse files
antoinecellerierCopilotmryel00
committed
perf(csi): request YUV420 main stream when supported decreasing cpu usage (#147)
The picamera2 default main-stream format is XBGR8888 (4 bytes/pixel), but the V4L2 H264/MJPEG encoders both accept YUV420 natively. Letting libcamera deliver YUV420 directly avoids an in-encoder colour-space conversion and cuts main-stream memory bandwidth ~2.7x (1.5 bpp vs 4 bpp), reducing CPU substantially. Signed-off-by: Patrick Gehrsitz <github@mryel.de> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Patrick Gehrsitz <github@mryel.de>
1 parent 06a4570 commit 8809d74

4 files changed

Lines changed: 229 additions & 2 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# 002 - Main Stream Pixel Format for CSI Cameras
2+
3+
## Date
4+
5+
2026-05-31
6+
7+
## Status
8+
9+
Decision
10+
11+
## Category
12+
13+
Performance
14+
15+
## Authors
16+
17+
@antoinecellerier
18+
19+
## References
20+
21+
[picamera2 V4L2 encoder source](https://github.com/raspberrypi/picamera2/blob/main/picamera2/encoders/v4l2_encoder.py)
22+
23+
## Context
24+
25+
`picamera2.create_video_configuration()` defaults the main stream to `XBGR8888`
26+
(32 bits/pixel). Both the V4L2 hardware encoders (`MJPEGEncoder`,
27+
`H264Encoder`) and the `JpegEncoder` software fallback accept `YUV420`
28+
(12 bits/pixel) as a native input. Forcing `YUV420` upstream of the encoders
29+
skips an implicit colour-space conversion and reduces DMA bandwidth by ~2.7×.
30+
31+
## Options
32+
33+
1. Keep the picamera2 default (`XBGR8888`).
34+
2. Request `YUV420` for the main stream, falling back to the picamera2 default
35+
when the camera does not advertise it.
36+
37+
## Decision
38+
39+
We request `YUV420` for the main stream when libcamera reports it as supported,
40+
selected from a preference-ordered list of encoder-compatible formats with
41+
`YUV420` first. If none are supported, we omit `format` from the config and
42+
defer to the picamera2 default.
43+
44+
## Consequences
45+
46+
* Measured CPU on a Pi 4B with Camera Module 3 at 1920×1080 @ 30 fps drops by
47+
~28–39 % (idle and live WebRTC respectively) versus the `XBGR8888` default.
48+
* No expected image-quality impact: JPEG and H.264 both encode in YUV
49+
internally, so this change moves the colour-space conversion earlier in the
50+
pipeline rather than introducing a new one.

spyglass/camera/camera.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,18 @@ def configure(
5656
vflip=int(flip_vertical or upsidedown),
5757
)
5858

59+
main_cfg = self._main_stream_config(width, height)
5960
self.picam2.configure(
6061
self.picam2.create_video_configuration(
61-
main={"size": (width, height)}, controls=controls, transform=transform
62+
main=main_cfg, controls=controls, transform=transform
6263
)
6364
)
6465

66+
def _main_stream_config(self, width: int, height: int) -> dict:
67+
"""Picamera2 main-stream config dict. Subclasses override to pick the
68+
most efficient pixel format supported by their camera and encoders."""
69+
return {"size": (width, height)}
70+
6571
def _run_server(
6672
self,
6773
bind_address,

spyglass/camera/csi.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,67 @@
11
import io
22
from threading import Condition
33

4+
import libcamera
45
from picamera2.encoders import _hw_encoder_available
56
from picamera2.outputs import FileOutput
67

7-
from spyglass import WEBRTC_ENABLED, camera
8+
from spyglass import WEBRTC_ENABLED, camera, logger
89
from spyglass.camera.lazy_encoder import CameraSession, LazyEncoder
910
from spyglass.server.http_server import StreamingHandler
1011

12+
# Preference ordered pixel formats accepted by the picamera2 V4L2 HW and
13+
# JpegEncoder SW encoders.
14+
_PREFERRED_MAIN_STREAM_FORMATS = (
15+
"YUV420",
16+
"BGR888",
17+
"RGB888",
18+
"XBGR8888",
19+
"XRGB8888",
20+
)
21+
1122

1223
class CSI(camera.Camera):
24+
def _main_stream_config(self, width: int, height: int) -> dict:
25+
cfg = super()._main_stream_config(width, height)
26+
chosen = self._pick_main_stream_format()
27+
if chosen is not None:
28+
cfg["format"] = chosen
29+
return cfg
30+
31+
def _pick_main_stream_format(self) -> str | None:
32+
"""Return the highest-priority encoder-compatible format the camera
33+
actually supports, or ``None`` to defer to the picamera2 default."""
34+
supported = self._enumerate_supported_main_stream_formats()
35+
if not supported:
36+
return None
37+
38+
preferred_fmts = (f for f in _PREFERRED_MAIN_STREAM_FORMATS if f in supported)
39+
fmt = next(preferred_fmts, None)
40+
41+
logger.info("Supported formats: %s", sorted(supported))
42+
if fmt is None:
43+
logger.warning(
44+
"Camera reports no encoder-compatible main-stream formats; using picamera2 default.",
45+
)
46+
return None
47+
48+
logger.info(f"Main stream using %r.", fmt)
49+
return fmt
50+
51+
def _enumerate_supported_main_stream_formats(self) -> set[str]:
52+
try:
53+
libcamera_cfg = self.picam2.camera.generate_configuration(
54+
[libcamera.StreamRole.VideoRecording]
55+
)
56+
return {str(pf) for pf in libcamera_cfg.at(0).formats.pixel_formats}
57+
except Exception as exc:
58+
logger.warning(
59+
"Could not enumerate supported main-stream formats from libcamera "
60+
"(%s); using picamera2 default.",
61+
exc,
62+
)
63+
return set()
64+
1365
def start_and_run_server(
1466
self,
1567
bind_address,

tests/test_camera_configure.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import sys
2+
from unittest.mock import MagicMock
3+
4+
AF_MODE_ENUM_MANUAL = 3
5+
AF_SPEED_ENUM_NORMAL = 1
6+
7+
mock_libcamera = MagicMock()
8+
mock_picamera2 = MagicMock()
9+
mock_picamera2.encoders._hw_encoder_available = False
10+
mock_picamera2.outputs.Output = MagicMock
11+
sys.modules.update(
12+
{
13+
"libcamera": mock_libcamera,
14+
"picamera2": mock_picamera2,
15+
"picamera2.encoders": mock_picamera2.encoders,
16+
"picamera2.outputs": mock_picamera2.outputs,
17+
}
18+
)
19+
mock_libcamera.controls.AfModeEnum.Manual = AF_MODE_ENUM_MANUAL
20+
mock_libcamera.controls.AfSpeedEnum.Normal = AF_SPEED_ENUM_NORMAL
21+
mock_libcamera.StreamRole.VideoRecording = object()
22+
23+
24+
def _make_picam2(supported_formats=[], enumerate_raises=None):
25+
"""Build a mocked Picamera2 whose libcamera-level
26+
``camera.generate_configuration(...)`` reports the given pixel formats."""
27+
picam2 = MagicMock()
28+
picam2.camera_controls = {}
29+
picam2.create_video_configuration.side_effect = lambda **kw: dict(kw)
30+
31+
if enumerate_raises is not None:
32+
picam2.camera.generate_configuration.side_effect = enumerate_raises
33+
else:
34+
formats = MagicMock()
35+
formats.pixel_formats = supported_formats
36+
stream_cfg = MagicMock()
37+
stream_cfg.formats = formats
38+
libcam_cfg = MagicMock()
39+
libcam_cfg.at.return_value = stream_cfg
40+
picam2.camera.generate_configuration.return_value = libcam_cfg
41+
return picam2
42+
43+
44+
def _run_configure(cam):
45+
cam.configure(
46+
width=1920,
47+
height=1080,
48+
fps=30,
49+
autofocus=AF_MODE_ENUM_MANUAL,
50+
lens_position=0.0,
51+
autofocus_speed=AF_SPEED_ENUM_NORMAL,
52+
)
53+
54+
55+
def test_csi_picks_yuv420_when_supported():
56+
from spyglass.camera.csi import CSI
57+
58+
picam2 = _make_picam2(
59+
supported_formats=["YUV420", "XBGR8888", "BGR888", "NV12", "RGB565"]
60+
)
61+
cam = CSI(picam2)
62+
_run_configure(cam)
63+
64+
main = picam2.create_video_configuration.call_args.kwargs["main"]
65+
assert main == {"size": (1920, 1080), "format": "YUV420"}
66+
67+
68+
def test_csi_falls_through_preference_when_yuv420_missing():
69+
from spyglass.camera.csi import CSI
70+
71+
picam2 = _make_picam2(supported_formats=["XBGR8888", "BGR888", "RGB565"])
72+
cam = CSI(picam2)
73+
_run_configure(cam)
74+
75+
main = picam2.create_video_configuration.call_args.kwargs["main"]
76+
assert main == {"size": (1920, 1080), "format": "BGR888"}
77+
78+
79+
def test_csi_omits_format_when_no_encoder_compatible_supported():
80+
from spyglass.camera.csi import CSI
81+
82+
picam2 = _make_picam2(supported_formats=["NV12", "NV21", "RGB565", "YUYV"])
83+
cam = CSI(picam2)
84+
_run_configure(cam)
85+
86+
main = picam2.create_video_configuration.call_args.kwargs["main"]
87+
assert "format" not in main
88+
assert main == {"size": (1920, 1080)}
89+
90+
91+
def test_csi_omits_format_when_enumeration_fails():
92+
from spyglass.camera.csi import CSI
93+
94+
picam2 = _make_picam2(enumerate_raises=RuntimeError("camera not yet acquired"))
95+
cam = CSI(picam2)
96+
_run_configure(cam)
97+
98+
main = picam2.create_video_configuration.call_args.kwargs["main"]
99+
assert "format" not in main
100+
101+
102+
def test_base_camera_does_not_query_libcamera_formats():
103+
"""Subclasses other than CSI (e.g. USB) should not auto-pick a format."""
104+
from spyglass.camera.camera import Camera
105+
106+
class _StubCamera(Camera):
107+
def start_and_run_server(self, *args, **kwargs):
108+
raise NotImplementedError
109+
110+
def stop(self):
111+
raise NotImplementedError
112+
113+
picam2 = _make_picam2(supported_formats=["YUV420"])
114+
cam = _StubCamera(picam2)
115+
_run_configure(cam)
116+
117+
picam2.camera.generate_configuration.assert_not_called()
118+
main = picam2.create_video_configuration.call_args.kwargs["main"]
119+
assert "format" not in main

0 commit comments

Comments
 (0)