Skip to content

Commit b297ece

Browse files
feat: lazy-start picamera2 encoders to save idle CPU
Previously, csi.py started both the MJPEG encoder and (when WebRTC was enabled) the H264 encoder unconditionally at server startup, regardless of whether any client was connected. Both encoders ran continuously, burning CPU on every frame even with zero consumers. This change introduces LazyEncoder, a small ref-counted wrapper around picamera2's start_encoder/stop_encoder. Encoders are now started on the first consumer (MJPEG /stream or /snapshot, WebRTC WHEP POST) and stopped when the last consumer disconnects. Measured CPU impact on a Raspberry Pi 4B with a Camera Module 3 (hardware encoders enabled, sampled with `top`, single python process, percentages are of one core; the kernel reports values >100% when the process spans multiple cores): Default config (640x480 @ 15 FPS): Idle (no clients): ~13-15% -> ~5% Live MJPEG / WebRTC client: unchanged Heavier config (1920x1080 @ 30 FPS): Idle (no clients): ~95-100% -> ~15% Live WebRTC only: ~120-135% -> ~60% Live MJPEG only: ~95-104% -> ~50-60% The biggest wins come from two effects: idle is no longer paying for either encoder, and a live WebRTC-only client no longer pays for the unused MJPEG encoder running in parallel (and vice versa). The remaining idle CPU is the picamera2 capture loop itself, which still runs continuously; pausing the camera entirely is left for a follow-up commit. Implementation notes: - LazyEncoder.acquire/release are protected by a threading.Lock; a failed start_encoder rolls back the refcount so callers can retry. - WebRTC release is wired to RTCPeerConnection 'closed' state with an idempotent guard, plus a try/except around setup so a failure during SDP exchange or ICE gathering does not leak an encoder. - USB camera path is unaffected: it never used encoders, and the new code uses getattr(handler, ..., None) so missing attributes fall back to no-op. All 129 existing unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 073a0f9 commit b297ece

4 files changed

Lines changed: 104 additions & 16 deletions

File tree

spyglass/camera/csi.py

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

77
from spyglass import WEBRTC_ENABLED, camera
8+
from spyglass.camera.lazy_encoder import LazyEncoder
89
from spyglass.server.http_server import StreamingHandler
910

1011

@@ -41,11 +42,17 @@ def get_frame(inner_self):
4142
output.condition.wait()
4243
return output.frame
4344

44-
self.picam2.start_encoder(MJPEGEncoder(), FileOutput(output))
45+
StreamingHandler.mjpeg_encoder = LazyEncoder(
46+
self.picam2, MJPEGEncoder, FileOutput(output)
47+
)
4548
if WEBRTC_ENABLED:
4649
from picamera2.encoders import H264Encoder
4750

48-
self.picam2.start_encoder(H264Encoder(), self.media_track)
51+
StreamingHandler.h264_encoder = LazyEncoder(
52+
self.picam2, H264Encoder, self.media_track
53+
)
54+
else:
55+
StreamingHandler.h264_encoder = None
4956
self.picam2.start()
5057

5158
self._run_server(

spyglass/camera/lazy_encoder.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Reference-counted lazy start/stop wrapper for picamera2 encoders.
2+
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.
5+
"""
6+
7+
import threading
8+
9+
10+
class LazyEncoder:
11+
def __init__(self, picam2, encoder_factory, output):
12+
"""
13+
:param picam2: the Picamera2 instance to start/stop the encoder on.
14+
:param encoder_factory: zero-arg callable returning a fresh Encoder.
15+
:param output: the picamera2 Output to attach to the encoder.
16+
"""
17+
self._picam2 = picam2
18+
self._encoder_factory = encoder_factory
19+
self._output = output
20+
self._encoder = None
21+
self._refs = 0
22+
self._lock = threading.Lock()
23+
24+
def acquire(self):
25+
with self._lock:
26+
self._refs += 1
27+
if self._refs == 1:
28+
try:
29+
self._encoder = self._encoder_factory()
30+
self._picam2.start_encoder(self._encoder, self._output)
31+
except Exception:
32+
# Roll back so a future caller can retry.
33+
self._refs -= 1
34+
self._encoder = None
35+
raise
36+
37+
def release(self):
38+
with self._lock:
39+
if self._refs == 0:
40+
return
41+
self._refs -= 1
42+
if self._refs == 0 and self._encoder is not None:
43+
self._picam2.stop_encoder(self._encoder)
44+
self._encoder = None
45+
46+
def __enter__(self):
47+
self.acquire()
48+
return self
49+
50+
def __exit__(self, *exc):
51+
self.release()

spyglass/server/jpeg.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010

1111

1212
def start_streaming(handler: "StreamingHandler"):
13+
encoder = getattr(handler, "mjpeg_encoder", None)
14+
if encoder is not None:
15+
encoder.acquire()
1316
try:
1417
send_default_headers(handler)
1518
handler.send_header("Content-Type", "multipart/x-mixed-replace; boundary=FRAME")
@@ -30,9 +33,15 @@ def start_streaming(handler: "StreamingHandler"):
3033
logger.warning(
3134
"Removed streaming client %s: %s", handler.client_address, str(e)
3235
)
36+
finally:
37+
if encoder is not None:
38+
encoder.release()
3339

3440

3541
def send_snapshot(handler: "StreamingHandler"):
42+
encoder = getattr(handler, "mjpeg_encoder", None)
43+
if encoder is not None:
44+
encoder.acquire()
3645
try:
3746
send_default_headers(handler)
3847
frame = handler.get_frame()
@@ -45,6 +54,9 @@ def send_snapshot(handler: "StreamingHandler"):
4554
handler.wfile.write(frame[2:])
4655
except Exception as e:
4756
logger.warning("Removed client %s: %s", handler.client_address, str(e))
57+
finally:
58+
if encoder is not None:
59+
encoder.release()
4860

4961

5062
def send_default_headers(handler: "StreamingHandler"):

spyglass/server/webrtc_whep.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -77,30 +77,48 @@ async def do_POST_async(handler: "StreamingHandler"):
7777
pc = RTCPeerConnection()
7878
secret = uuid.uuid4()
7979

80+
h264_encoder = getattr(handler, "h264_encoder", None)
81+
if h264_encoder is not None:
82+
h264_encoder.acquire()
83+
encoder_released = False
84+
85+
def _release_encoder_once():
86+
nonlocal encoder_released
87+
if h264_encoder is not None and not encoder_released:
88+
encoder_released = True
89+
h264_encoder.release()
90+
8091
@pc.on("connectionstatechange")
8192
async def on_connectionstatechange():
8293
print(f"Connection state {pc.connectionState}")
8394
if pc.connectionState == "failed":
8495
await pc.close()
8596
elif pc.connectionState == "closed":
86-
pcs.pop(str(secret))
97+
pcs.pop(str(secret), None)
98+
_release_encoder_once()
8799
print(f"{len(pcs)} connections still open.")
88100

89101
pcs[str(secret)] = pc
90-
track = media_relay.subscribe(handler.media_track)
91-
sender = pc.addTrack(track)
92-
codecs = RTCRtpSender.getCapabilities("video").codecs
93-
transceiver = next(t for t in pc.getTransceivers() if t.sender == sender)
94-
transceiver.setCodecPreferences(
95-
[codec for codec in codecs if codec.mimeType == "video/H264"]
96-
)
97-
98-
await pc.setRemoteDescription(offer)
99-
answer = await pc.createAnswer()
100-
await pc.setLocalDescription(answer)
102+
try:
103+
track = media_relay.subscribe(handler.media_track)
104+
sender = pc.addTrack(track)
105+
codecs = RTCRtpSender.getCapabilities("video").codecs
106+
transceiver = next(t for t in pc.getTransceivers() if t.sender == sender)
107+
transceiver.setCodecPreferences(
108+
[codec for codec in codecs if codec.mimeType == "video/H264"]
109+
)
101110

102-
while pc.iceGatheringState != "complete":
103-
await asyncio.sleep(1)
111+
await pc.setRemoteDescription(offer)
112+
answer = await pc.createAnswer()
113+
await pc.setLocalDescription(answer)
114+
115+
while pc.iceGatheringState != "complete":
116+
await asyncio.sleep(1)
117+
except Exception:
118+
pcs.pop(str(secret), None)
119+
_release_encoder_once()
120+
await pc.close()
121+
raise
104122

105123
send_default_headers(HTTPStatus.CREATED, handler)
106124

0 commit comments

Comments
 (0)