Skip to content

Commit 8fae89d

Browse files
feat: stop the picamera2 capture loop when no consumers
Building on the previous LazyEncoder commit, this commit ref-counts the Picamera2 instance itself: the camera only runs while at least one encoder is active. When the last encoder is released, picam2.stop() is called; on the next consumer, picam2.start() runs again. Adds a small CameraSession wrapper next to LazyEncoder; each LazyEncoder takes an optional session and acquires/releases it together with the underlying encoder, so the camera is the union of the encoders' refs. Measured CPU impact, applied on top of the previous commit (Raspberry Pi 4B, Camera Module 3, hardware encoders, sampled with `top`): Default config (640x480 @ 15 FPS): Idle (no clients): ~5% -> ~0% Live MJPEG / WebRTC client: unchanged Heavier config (1920x1080 @ 30 FPS): Idle (no clients): ~15% -> ~0% Live WebRTC client: ~60% (unchanged) Cumulative effect of both commits at 1080p30: Idle: ~95-100% -> ~0% Live WebRTC: ~120-135% -> ~60% Cold-start latency on first connect (camera off -> on -> first JPEG): ~150-500 ms for /snapshot at 1080p30, dominated by libcamera sensor init and AE/AWB warm-up. The first cold start after process boot is typically slowest (~500 ms); subsequent cold starts are faster (~150-200 ms) since some libcamera state appears to be retained. There is no warm path while no consumers are active: every snapshot released triggers a full camera stop. Implementation notes: - CameraSession.acquire/release are protected by a threading.Lock and roll back the refcount if picam2.start() raises, mirroring the LazyEncoder pattern. - LazyEncoder.acquire takes the session ref before calling start_encoder so the camera is always running when the encoder starts. release does the inverse: stop_encoder first, then drop the session ref. If start_encoder fails, the session ref is rolled back too. - Lock acquisition order is consistent (encoder lock then session lock), so two encoders sharing one session cannot deadlock. - CSI.stop() now defensively calls picam2.stop_encoder() and picam2.stop() under try/except, since at systemd shutdown the camera may already be stopped (no consumers) and stop_encoder/stop would otherwise raise. - USB camera path is unaffected; only csi.py wires the session in. All 129 existing unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b297ece commit 8fae89d

2 files changed

Lines changed: 62 additions & 9 deletions

File tree

spyglass/camera/csi.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from picamera2.outputs import FileOutput
66

77
from spyglass import WEBRTC_ENABLED, camera
8-
from spyglass.camera.lazy_encoder import LazyEncoder
8+
from spyglass.camera.lazy_encoder import CameraSession, LazyEncoder
99
from spyglass.server.http_server import StreamingHandler
1010

1111

@@ -42,18 +42,21 @@ def get_frame(inner_self):
4242
output.condition.wait()
4343
return output.frame
4444

45+
session = CameraSession(self.picam2)
4546
StreamingHandler.mjpeg_encoder = LazyEncoder(
46-
self.picam2, MJPEGEncoder, FileOutput(output)
47+
self.picam2, MJPEGEncoder, FileOutput(output), session=session
4748
)
4849
if WEBRTC_ENABLED:
4950
from picamera2.encoders import H264Encoder
5051

5152
StreamingHandler.h264_encoder = LazyEncoder(
52-
self.picam2, H264Encoder, self.media_track
53+
self.picam2,
54+
H264Encoder,
55+
self.media_track,
56+
session=session,
5357
)
5458
else:
5559
StreamingHandler.h264_encoder = None
56-
self.picam2.start()
5760

5861
self._run_server(
5962
bind_address,
@@ -67,4 +70,13 @@ def get_frame(inner_self):
6770
)
6871

6972
def stop(self):
70-
self.picam2.stop_recording()
73+
# Encoders / camera may already be stopped (no consumers); guard the
74+
# systemd shutdown path so we don't error out on that case.
75+
try:
76+
self.picam2.stop_encoder()
77+
except Exception:
78+
pass
79+
try:
80+
self.picam2.stop()
81+
except Exception:
82+
pass

spyglass/camera/lazy_encoder.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,57 @@
1-
"""Reference-counted lazy start/stop wrapper for picamera2 encoders.
1+
"""Reference-counted lazy start/stop wrappers for picamera2.
22
3-
The encoder is only running while at least one consumer holds a reference.
4-
This avoids burning CPU on encoders that have no clients.
3+
CameraSession wraps Picamera2.start()/stop(): the camera only runs while
4+
at least one consumer (encoder) holds a reference.
5+
6+
LazyEncoder wraps Picamera2.start_encoder()/stop_encoder(): the encoder
7+
only runs while at least one consumer (HTTP stream / snapshot / WebRTC
8+
peer connection) holds a reference. Each LazyEncoder also holds a
9+
reference on the CameraSession while running, so the camera itself
10+
turns off when no encoders are active.
511
"""
612

713
import threading
814

915

16+
class CameraSession:
17+
def __init__(self, picam2):
18+
self._picam2 = picam2
19+
self._refs = 0
20+
self._lock = threading.Lock()
21+
22+
def acquire(self):
23+
with self._lock:
24+
self._refs += 1
25+
if self._refs == 1:
26+
try:
27+
self._picam2.start()
28+
except Exception:
29+
self._refs -= 1
30+
raise
31+
32+
def release(self):
33+
with self._lock:
34+
if self._refs == 0:
35+
return
36+
self._refs -= 1
37+
if self._refs == 0:
38+
self._picam2.stop()
39+
40+
1041
class LazyEncoder:
11-
def __init__(self, picam2, encoder_factory, output):
42+
def __init__(self, picam2, encoder_factory, output, session=None):
1243
"""
1344
:param picam2: the Picamera2 instance to start/stop the encoder on.
1445
:param encoder_factory: zero-arg callable returning a fresh Encoder.
1546
:param output: the picamera2 Output to attach to the encoder.
47+
:param session: optional CameraSession. If provided, the camera is
48+
started/stopped together with the encoder so the camera only runs
49+
when at least one encoder is active.
1650
"""
1751
self._picam2 = picam2
1852
self._encoder_factory = encoder_factory
1953
self._output = output
54+
self._session = session
2055
self._encoder = None
2156
self._refs = 0
2257
self._lock = threading.Lock()
@@ -25,13 +60,17 @@ def acquire(self):
2560
with self._lock:
2661
self._refs += 1
2762
if self._refs == 1:
63+
if self._session is not None:
64+
self._session.acquire()
2865
try:
2966
self._encoder = self._encoder_factory()
3067
self._picam2.start_encoder(self._encoder, self._output)
3168
except Exception:
3269
# Roll back so a future caller can retry.
3370
self._refs -= 1
3471
self._encoder = None
72+
if self._session is not None:
73+
self._session.release()
3574
raise
3675

3776
def release(self):
@@ -42,6 +81,8 @@ def release(self):
4281
if self._refs == 0 and self._encoder is not None:
4382
self._picam2.stop_encoder(self._encoder)
4483
self._encoder = None
84+
if self._session is not None:
85+
self._session.release()
4586

4687
def __enter__(self):
4788
self.acquire()

0 commit comments

Comments
 (0)