Skip to content

Commit 06a4570

Browse files
antoinecellerierCopilotmryel00
authored
feat: lazy-start picamera2 encoders and camera to save idle CPU (#146)
Adds two new options to control how long each encoder (and the underlying camera, when no other encoder is active) keeps running after the last consumer disconnects: --mjpeg-linger-seconds / MJPEG_LINGER_SECONDS (default: -1) --webrtc-linger-seconds / WEBRTC_LINGER_SECONDS (default: 5) Semantics: -1 keeps the encoder running once started; spyglass pre-warms it at startup so the first consumer never pays cold-start latency. 0 stops the encoder immediately when the last consumer releases (the behavior introduced in the first two commits of this branch). > 0 stops the encoder N seconds after the last consumer releases; a fresh acquire within the window cancels the pending stop. The MJPEG default (-1) restores the upstream 'always on' idle behavior so /snapshot use cases (notably timelapse) and the Mainsail Adaptive MJPEG service keep working without paying cold-start latency on each request. The WebRTC default (5s) keeps the bulk of the lazy CPU win while bridging brief peer reconnects. Implementation: - LazyEncoder gains a linger_seconds ctor parameter and a shared threading.Timer for the delayed stop. A monotonically increasing _stop_token invalidates any stale timer callback that already raced past Timer.cancel(). - _stop_now_locked centralizes the stop_encoder / session.release sequence so both the immediate and lingered stop paths use the same code with the same try/finally guarantees. - CSI.start_and_run_server pre-warms each encoder for which linger < 0 by issuing a single never-released acquire() at startup. - Camera.start_and_run_server abstract signature and the USB override gain the new kwargs for compatibility; USB ignores them since it does not use LazyEncoder. Adds focused unit tests for LazyEncoder covering acquire/release ref-counting, all three linger modes, timer cancellation on re-acquire, session sharing across two encoders, and rollback on start_encoder / stop_encoder failure. 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 c150b0c commit 06a4570

11 files changed

Lines changed: 545 additions & 37 deletions

File tree

resources/spyglass.conf

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,38 @@ CONTROLS=""
8383
#### NOTE: Name of the file to be used to apply tuning filter.
8484
#### If dir not defined, default pycamera2 directories will be used.
8585
# TUNING_FILTER="ov5647_noir.json"
86+
87+
#### MJPEG encoder linger (INTEGER)[default: -1]
88+
#### NOTE: Seconds the MJPEG encoder (and the camera, when no other
89+
#### encoder is active) keeps running after the last consumer
90+
#### disconnects. Use 0 or a small positive value to reduce idle
91+
#### CPU at the cost of cold-start latency on the next /snapshot
92+
#### or /stream.
93+
#### -1 keeps the encoder running once started; spyglass
94+
#### pre-warms it at startup. Preserves the legacy "always
95+
#### on" behavior and the lowest /snapshot latency (e.g.
96+
#### for timelapses).
97+
#### 0 stops the encoder immediately when the last consumer
98+
#### disconnects. Lowest idle CPU.
99+
#### > 0 stops the encoder N seconds after the last consumer
100+
#### disconnects; a fresh request within the window cancels
101+
#### the stop. Bridges brief reconnects without paying
102+
#### cold-start latency on each one.
103+
# MJPEG_LINGER_SECONDS="-1"
104+
105+
#### WebRTC encoder linger (INTEGER)[default: 5]
106+
#### NOTE: Seconds the WebRTC (H264) encoder (and the camera, when no
107+
#### other encoder is active) keeps running after the last peer
108+
#### disconnects. Use 0 or a small positive value to reduce idle
109+
#### CPU at the cost of cold-start latency on the next peer
110+
#### connection.
111+
#### -1 keeps the encoder running once started; spyglass
112+
#### pre-warms it at startup.
113+
#### 0 stops the encoder immediately when the last peer
114+
#### disconnects. Lowest idle CPU; every new peer pays
115+
#### cold-start latency.
116+
#### > 0 (default: 5) stops the encoder N seconds after the
117+
#### last peer disconnects; a fresh connection within the
118+
#### window cancels the stop. Bridges brief reconnects
119+
#### without paying cold-start latency on each one.
120+
# WEBRTC_LINGER_SECONDS="5"

scripts/spyglass

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ run_spyglass() {
121121
--orientation_exif "${ORIENTATION_EXIF:-h}" \
122122
--tuning_filter "${TUNING_FILTER:-}"\
123123
--tuning_filter_dir "${TUNING_FILTER_DIR:-}" \
124+
--mjpeg-linger-seconds "${MJPEG_LINGER_SECONDS:--1}" \
125+
--webrtc-linger-seconds "${WEBRTC_LINGER_SECONDS:-5}" \
124126
--controls-string "${CONTROLS:-0=0}" # 0=0 to prevent error on empty string
125127
}
126128

spyglass/camera/camera.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ def start_and_run_server(
105105
webrtc_url="/webrtc",
106106
orientation_exif=0,
107107
use_sw_encoding=False,
108+
mjpeg_linger_seconds=-1,
109+
webrtc_linger_seconds=5,
108110
):
109111
pass
110112

spyglass/camera/csi.py

Lines changed: 35 additions & 4 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 CameraSession, LazyEncoder
89
from spyglass.server.http_server import StreamingHandler
910

1011

@@ -18,6 +19,8 @@ def start_and_run_server(
1819
webrtc_url="/webrtc",
1920
orientation_exif=0,
2021
use_sw_encoding=False,
22+
mjpeg_linger_seconds=-1,
23+
webrtc_linger_seconds=5,
2124
):
2225
if _hw_encoder_available and not use_sw_encoding:
2326
from picamera2.encoders import MJPEGEncoder
@@ -41,12 +44,33 @@ def get_frame(inner_self):
4144
output.condition.wait()
4245
return output.frame
4346

44-
self.picam2.start_encoder(MJPEGEncoder(), FileOutput(output))
47+
session = CameraSession(self.picam2)
48+
mjpeg_encoder = LazyEncoder(
49+
self.picam2,
50+
MJPEGEncoder,
51+
FileOutput(output),
52+
session=session,
53+
linger_seconds=mjpeg_linger_seconds,
54+
)
55+
StreamingHandler.mjpeg_encoder = mjpeg_encoder
4556
if WEBRTC_ENABLED:
4657
from picamera2.encoders import H264Encoder
4758

48-
self.picam2.start_encoder(H264Encoder(), self.media_track)
49-
self.picam2.start()
59+
h264_encoder = LazyEncoder(
60+
self.picam2,
61+
H264Encoder,
62+
self.media_track,
63+
session=session,
64+
linger_seconds=webrtc_linger_seconds,
65+
)
66+
StreamingHandler.h264_encoder = h264_encoder
67+
else:
68+
StreamingHandler.h264_encoder = None
69+
70+
if mjpeg_linger_seconds < 0:
71+
mjpeg_encoder.acquire()
72+
if WEBRTC_ENABLED and webrtc_linger_seconds < 0:
73+
h264_encoder.acquire()
5074

5175
self._run_server(
5276
bind_address,
@@ -60,4 +84,11 @@ def get_frame(inner_self):
6084
)
6185

6286
def stop(self):
63-
self.picam2.stop_recording()
87+
try:
88+
self.picam2.stop_encoder()
89+
except Exception:
90+
pass
91+
try:
92+
self.picam2.stop()
93+
except Exception:
94+
pass

spyglass/camera/lazy_encoder.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""Reference-counted lazy start/stop wrappers for picamera2.
2+
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.
11+
12+
LazyEncoder supports a ``linger_seconds`` parameter:
13+
14+
* ``< 0`` keeps the encoder running once started; subsequent releases that
15+
drive the ref-count to zero do not stop it. Useful for the MJPEG path
16+
when paired with a startup pre-warm so e.g. timelapse snapshots stay on
17+
the warm path.
18+
* ``0`` stops the encoder immediately when the last consumer releases.
19+
* ``> 0`` schedules a delayed stop; a fresh acquire within the window
20+
cancels the pending stop. Useful to bridge brief reconnects without
21+
paying the cold-start cost on every reconnect.
22+
"""
23+
24+
import threading
25+
26+
27+
class CameraSession:
28+
def __init__(self, picam2):
29+
self._picam2 = picam2
30+
self._refs = 0
31+
self._lock = threading.Lock()
32+
33+
def acquire(self):
34+
with self._lock:
35+
self._refs += 1
36+
if self._refs > 1:
37+
return
38+
try:
39+
self._picam2.start()
40+
except Exception:
41+
self._refs -= 1
42+
raise
43+
44+
def release(self):
45+
with self._lock:
46+
if self._refs == 0:
47+
return
48+
self._refs -= 1
49+
if self._refs == 0:
50+
self._picam2.stop()
51+
52+
53+
class LazyEncoder:
54+
def __init__(
55+
self,
56+
picam2,
57+
encoder_factory,
58+
output,
59+
session=None,
60+
linger_seconds=0,
61+
):
62+
"""
63+
:param picam2: the Picamera2 instance to start/stop the encoder on.
64+
:param encoder_factory: zero-arg callable returning a fresh Encoder.
65+
:param output: the picamera2 Output to attach to the encoder.
66+
:param session: optional CameraSession. If provided, the camera is
67+
started/stopped together with the encoder so the camera only runs
68+
when at least one encoder is active.
69+
:param linger_seconds: behavior when the last consumer releases. ``0``
70+
stops immediately; ``>0`` schedules a stop that is cancelled if a
71+
new consumer acquires within the window; ``<0`` keeps the encoder
72+
running forever after the first start.
73+
"""
74+
self._picam2 = picam2
75+
self._encoder_factory = encoder_factory
76+
self._output = output
77+
self._session = session
78+
self._linger_seconds = linger_seconds
79+
self._encoder = None
80+
self._refs = 0
81+
self._lock = threading.Lock()
82+
self._stop_timer = None
83+
self._stop_token = 0
84+
85+
def acquire(self):
86+
with self._lock:
87+
self._cancel_linger_locked()
88+
self._refs += 1
89+
if self._encoder is not None:
90+
return
91+
session_acquired = False
92+
try:
93+
if self._session is not None:
94+
self._session.acquire()
95+
session_acquired = True
96+
self._encoder = self._encoder_factory()
97+
self._picam2.start_encoder(self._encoder, self._output)
98+
except Exception:
99+
self._refs -= 1
100+
self._encoder = None
101+
if session_acquired and self._session is not None:
102+
self._session.release()
103+
raise
104+
105+
def release(self):
106+
with self._lock:
107+
if self._refs == 0:
108+
return
109+
self._refs -= 1
110+
if self._refs > 0 or self._linger_seconds < 0:
111+
return
112+
if self._linger_seconds == 0:
113+
self._stop_now_locked()
114+
else:
115+
self._schedule_linger_locked()
116+
117+
def _stop_now_locked(self):
118+
encoder = self._encoder
119+
self._encoder = None
120+
try:
121+
self._picam2.stop_encoder(encoder)
122+
finally:
123+
if self._session is not None:
124+
self._session.release()
125+
126+
def _cancel_linger_locked(self):
127+
if self._stop_timer is None:
128+
return
129+
self._stop_timer.cancel()
130+
self._stop_timer = None
131+
self._stop_token += 1
132+
133+
def _schedule_linger_locked(self):
134+
self._stop_token += 1
135+
token = self._stop_token
136+
timer = threading.Timer(
137+
self._linger_seconds, self._linger_callback, args=(token,)
138+
)
139+
timer.daemon = True
140+
self._stop_timer = timer
141+
timer.start()
142+
143+
def _linger_callback(self, token):
144+
with self._lock:
145+
if self._stop_token != token:
146+
return
147+
self._stop_timer = None
148+
if self._refs == 0 and self._encoder is not None:
149+
self._stop_now_locked()
150+
151+
def __enter__(self):
152+
self.acquire()
153+
return self
154+
155+
def __exit__(self, *exc):
156+
self.release()

spyglass/camera/usb.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ def start_and_run_server(
1212
webrtc_url="/webrtc",
1313
orientation_exif=0,
1414
use_sw_encoding=False,
15+
mjpeg_linger_seconds=-1,
16+
webrtc_linger_seconds=5,
1517
):
1618
def get_frame(inner_self):
1719
# TODO: Cuts framerate in 1/n with n streams open, add some kind of buffer

spyglass/cli.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ def main(args=None):
100100
parsed_args.webrtc_url,
101101
parsed_args.orientation_exif,
102102
use_sw_encoding,
103+
parsed_args.mjpeg_linger_seconds,
104+
parsed_args.webrtc_linger_seconds,
103105
)
104106
finally:
105107
cam.stop()
@@ -346,6 +348,45 @@ def get_parser():
346348
action="store_true",
347349
help="List available camera controls and exits.",
348350
)
351+
parser.add_argument(
352+
"--mjpeg-linger-seconds",
353+
type=int,
354+
default=-1,
355+
help="How long the MJPEG encoder (and the camera, when no other "
356+
"encoder is active) keeps running after the last consumer "
357+
"disconnects. Use 0 or a small positive value to free encoder and "
358+
"camera resources while idle, reducing CPU use at the cost of "
359+
"cold-start latency on the next /snapshot or /stream.\n"
360+
" -1 (default) keeps the encoder running once started; spyglass "
361+
"pre-warms it at startup. Preserves the legacy 'always on' "
362+
"behavior and the lowest /snapshot latency (e.g. for timelapse "
363+
"use cases).\n"
364+
" 0 stops the encoder immediately when the last consumer "
365+
"disconnects. Lowest idle CPU.\n"
366+
" > 0 stops the encoder N seconds after the last consumer "
367+
"disconnects; a fresh request within the window cancels the stop. "
368+
"Bridges brief reconnects without paying cold-start latency on "
369+
"each one.",
370+
)
371+
parser.add_argument(
372+
"--webrtc-linger-seconds",
373+
type=int,
374+
default=5,
375+
help="How long the WebRTC (H264) encoder (and the camera, when no "
376+
"other encoder is active) keeps running after the last peer "
377+
"disconnects. Use 0 or a small positive value to free encoder and "
378+
"camera resources while idle, reducing CPU use at the cost of "
379+
"cold-start latency on the next peer connection.\n"
380+
" -1 keeps the encoder running once started; spyglass pre-warms "
381+
"it at startup.\n"
382+
" 0 stops the encoder immediately when the last peer "
383+
"disconnects. Lowest idle CPU; every new peer pays cold-start "
384+
"latency.\n"
385+
" > 0 (default: 5) stops the encoder N seconds after the last "
386+
"peer disconnects; a fresh connection within the window cancels "
387+
"the stop. Bridges brief reconnects without paying cold-start "
388+
"latency on each one.",
389+
)
349390
camera_group = parser.add_mutually_exclusive_group()
350391
camera_group.add_argument(
351392
"-n",

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"):

0 commit comments

Comments
 (0)