Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
35 changes: 35 additions & 0 deletions resources/spyglass.conf
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,38 @@ CONTROLS=""
#### NOTE: Name of the file to be used to apply tuning filter.
#### If dir not defined, default pycamera2 directories will be used.
# TUNING_FILTER="ov5647_noir.json"

#### MJPEG encoder linger (INTEGER)[default: -1]
#### NOTE: Seconds the MJPEG encoder (and the camera, when no other
#### encoder is active) keeps running after the last consumer
#### disconnects. Use 0 or a small positive value to reduce idle
#### CPU at the cost of cold-start latency on the next /snapshot
#### or /stream.
#### -1 keeps the encoder running once started; spyglass
#### pre-warms it at startup. Preserves the legacy "always
#### on" behavior and the lowest /snapshot latency (e.g.
#### for timelapses).
#### 0 stops the encoder immediately when the last consumer
#### disconnects. Lowest idle CPU.
#### > 0 stops the encoder N seconds after the last consumer
#### disconnects; a fresh request within the window cancels
#### the stop. Bridges brief reconnects without paying
#### cold-start latency on each one.
# MJPEG_LINGER_SECONDS="-1"

#### WebRTC encoder linger (INTEGER)[default: 5]
#### NOTE: Seconds the WebRTC (H264) encoder (and the camera, when no
#### other encoder is active) keeps running after the last peer
#### disconnects. Use 0 or a small positive value to reduce idle
#### CPU at the cost of cold-start latency on the next peer
#### connection.
#### -1 keeps the encoder running once started; spyglass
#### pre-warms it at startup.
#### 0 stops the encoder immediately when the last peer
#### disconnects. Lowest idle CPU; every new peer pays
#### cold-start latency.
#### > 0 (default: 5) stops the encoder N seconds after the
#### last peer disconnects; a fresh connection within the
#### window cancels the stop. Bridges brief reconnects
#### without paying cold-start latency on each one.
# WEBRTC_LINGER_SECONDS="5"
2 changes: 2 additions & 0 deletions scripts/spyglass
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ run_spyglass() {
--orientation_exif "${ORIENTATION_EXIF:-h}" \
--tuning_filter "${TUNING_FILTER:-}"\
--tuning_filter_dir "${TUNING_FILTER_DIR:-}" \
--mjpeg-linger-seconds "${MJPEG_LINGER_SECONDS:--1}" \
--webrtc-linger-seconds "${WEBRTC_LINGER_SECONDS:-5}" \
--controls-string "${CONTROLS:-0=0}" # 0=0 to prevent error on empty string
}

Expand Down
2 changes: 2 additions & 0 deletions spyglass/camera/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ def start_and_run_server(
webrtc_url="/webrtc",
orientation_exif=0,
use_sw_encoding=False,
mjpeg_linger_seconds=-1,
webrtc_linger_seconds=5,
):
pass

Expand Down
39 changes: 35 additions & 4 deletions spyglass/camera/csi.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from picamera2.outputs import FileOutput

from spyglass import WEBRTC_ENABLED, camera
from spyglass.camera.lazy_encoder import CameraSession, LazyEncoder
from spyglass.server.http_server import StreamingHandler


Expand All @@ -18,6 +19,8 @@ def start_and_run_server(
webrtc_url="/webrtc",
orientation_exif=0,
use_sw_encoding=False,
mjpeg_linger_seconds=-1,
webrtc_linger_seconds=5,
):
if _hw_encoder_available and not use_sw_encoding:
from picamera2.encoders import MJPEGEncoder
Expand All @@ -41,12 +44,33 @@ def get_frame(inner_self):
output.condition.wait()
return output.frame

self.picam2.start_encoder(MJPEGEncoder(), FileOutput(output))
session = CameraSession(self.picam2)
mjpeg_encoder = LazyEncoder(
self.picam2,
MJPEGEncoder,
FileOutput(output),
session=session,
linger_seconds=mjpeg_linger_seconds,
)
StreamingHandler.mjpeg_encoder = mjpeg_encoder
if WEBRTC_ENABLED:
from picamera2.encoders import H264Encoder

self.picam2.start_encoder(H264Encoder(), self.media_track)
self.picam2.start()
h264_encoder = LazyEncoder(
self.picam2,
H264Encoder,
self.media_track,
session=session,
linger_seconds=webrtc_linger_seconds,
)
StreamingHandler.h264_encoder = h264_encoder
else:
StreamingHandler.h264_encoder = None

if mjpeg_linger_seconds < 0:
mjpeg_encoder.acquire()
if WEBRTC_ENABLED and webrtc_linger_seconds < 0:
h264_encoder.acquire()

self._run_server(
bind_address,
Expand All @@ -60,4 +84,11 @@ def get_frame(inner_self):
)

def stop(self):
self.picam2.stop_recording()
try:
self.picam2.stop_encoder()
except Exception:
pass
try:
self.picam2.stop()
except Exception:
pass
158 changes: 158 additions & 0 deletions spyglass/camera/lazy_encoder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Reference-counted lazy start/stop wrappers for picamera2.

CameraSession wraps Picamera2.start()/stop(): the camera only runs while
at least one consumer (encoder) holds a reference.

LazyEncoder wraps Picamera2.start_encoder()/stop_encoder(): the encoder
only runs while at least one consumer (HTTP stream / snapshot / WebRTC
peer connection) holds a reference. Each LazyEncoder also holds a
reference on the CameraSession while running, so the camera itself
turns off when no encoders are active.

LazyEncoder supports a ``linger_seconds`` parameter:

* ``< 0`` keeps the encoder running once started; subsequent releases that
drive the ref-count to zero do not stop it. Useful for the MJPEG path
when paired with a startup pre-warm so e.g. timelapse snapshots stay on
the warm path.
* ``0`` stops the encoder immediately when the last consumer releases.
* ``> 0`` schedules a delayed stop; a fresh acquire within the window
cancels the pending stop. Useful to bridge brief reconnects without
paying the cold-start cost on every reconnect.
"""

import threading


class CameraSession:
def __init__(self, picam2):
self._picam2 = picam2
self._refs = 0
self._lock = threading.Lock()

def acquire(self):
with self._lock:
self._refs += 1
if self._refs > 1:
return
try:
self._picam2.start()
except Exception:
self._refs -= 1
raise

def release(self):
with self._lock:
if self._refs == 0:
return
self._refs -= 1
if self._refs == 0:
self._picam2.stop()


class LazyEncoder:
def __init__(
self,
picam2,
encoder_factory,
output,
session=None,
linger_seconds=0,
):
"""
:param picam2: the Picamera2 instance to start/stop the encoder on.
:param encoder_factory: zero-arg callable returning a fresh Encoder.
:param output: the picamera2 Output to attach to the encoder.
:param session: optional CameraSession. If provided, the camera is
started/stopped together with the encoder so the camera only runs
when at least one encoder is active.
:param linger_seconds: behavior when the last consumer releases. ``0``
stops immediately; ``>0`` schedules a stop that is cancelled if a
new consumer acquires within the window; ``<0`` keeps the encoder
running forever after the first start.
"""
self._picam2 = picam2
self._encoder_factory = encoder_factory
self._output = output
self._session = session
self._linger_seconds = linger_seconds
self._encoder = None
self._refs = 0
self._lock = threading.Lock()
self._stop_timer = None
self._stop_token = 0

def acquire(self):
with self._lock:
self._cancel_linger_locked()
self._refs += 1
if self._encoder is not None:
return
session_acquired = False
try:
if self._session is not None:
self._session.acquire()
session_acquired = True
self._encoder = self._encoder_factory()
self._picam2.start_encoder(self._encoder, self._output)
except Exception:
self._refs -= 1
self._encoder = None
if session_acquired and self._session is not None:
self._session.release()
raise

def release(self):
with self._lock:
if self._refs == 0:
return
self._refs -= 1
if self._refs != 0 or self._encoder is None:
return
if self._linger_seconds < 0:
return
if self._linger_seconds == 0:
self._stop_now_locked()
else:
self._schedule_linger_locked()

def _stop_now_locked(self):
encoder = self._encoder
self._encoder = None
try:
self._picam2.stop_encoder(encoder)
finally:
if self._session is not None:
self._session.release()

def _cancel_linger_locked(self):
if self._stop_timer is None:
return
self._stop_timer.cancel()
self._stop_timer = None
self._stop_token += 1

def _schedule_linger_locked(self):
self._stop_token += 1
token = self._stop_token
timer = threading.Timer(
self._linger_seconds, self._linger_callback, args=(token,)
)
timer.daemon = True
self._stop_timer = timer
timer.start()

def _linger_callback(self, token):
with self._lock:
if self._stop_token != token:
return
self._stop_timer = None
if self._refs == 0 and self._encoder is not None:
self._stop_now_locked()

def __enter__(self):
self.acquire()
return self

def __exit__(self, *exc):
self.release()
2 changes: 2 additions & 0 deletions spyglass/camera/usb.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ def start_and_run_server(
webrtc_url="/webrtc",
orientation_exif=0,
use_sw_encoding=False,
mjpeg_linger_seconds=-1,
webrtc_linger_seconds=5,
):
def get_frame(inner_self):
# TODO: Cuts framerate in 1/n with n streams open, add some kind of buffer
Expand Down
41 changes: 41 additions & 0 deletions spyglass/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ def main(args=None):
parsed_args.webrtc_url,
parsed_args.orientation_exif,
use_sw_encoding,
parsed_args.mjpeg_linger_seconds,
parsed_args.webrtc_linger_seconds,
)
finally:
cam.stop()
Expand Down Expand Up @@ -346,6 +348,45 @@ def get_parser():
action="store_true",
help="List available camera controls and exits.",
)
parser.add_argument(
"--mjpeg-linger-seconds",
type=int,
default=-1,
help="How long the MJPEG encoder (and the camera, when no other "
"encoder is active) keeps running after the last consumer "
"disconnects. Use 0 or a small positive value to free encoder and "
"camera resources while idle, reducing CPU use at the cost of "
"cold-start latency on the next /snapshot or /stream.\n"
" -1 (default) keeps the encoder running once started; spyglass "
"pre-warms it at startup. Preserves the legacy 'always on' "
"behavior and the lowest /snapshot latency (e.g. for timelapse "
"use cases).\n"
" 0 stops the encoder immediately when the last consumer "
"disconnects. Lowest idle CPU.\n"
" > 0 stops the encoder N seconds after the last consumer "
"disconnects; a fresh request within the window cancels the stop. "
"Bridges brief reconnects without paying cold-start latency on "
"each one.",
)
parser.add_argument(
"--webrtc-linger-seconds",
type=int,
default=5,
help="How long the WebRTC (H264) encoder (and the camera, when no "
"other encoder is active) keeps running after the last peer "
"disconnects. Use 0 or a small positive value to free encoder and "
"camera resources while idle, reducing CPU use at the cost of "
"cold-start latency on the next peer connection.\n"
" -1 keeps the encoder running once started; spyglass pre-warms "
"it at startup.\n"
" 0 stops the encoder immediately when the last peer "
"disconnects. Lowest idle CPU; every new peer pays cold-start "
"latency.\n"
" > 0 (default: 5) stops the encoder N seconds after the last "
"peer disconnects; a fresh connection within the window cancels "
"the stop. Bridges brief reconnects without paying cold-start "
"latency on each one.",
)
camera_group = parser.add_mutually_exclusive_group()
camera_group.add_argument(
"-n",
Expand Down
12 changes: 12 additions & 0 deletions spyglass/server/jpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@


def start_streaming(handler: "StreamingHandler"):
encoder = getattr(handler, "mjpeg_encoder", None)
if encoder is not None:
encoder.acquire()
try:
send_default_headers(handler)
handler.send_header("Content-Type", "multipart/x-mixed-replace; boundary=FRAME")
Expand All @@ -30,9 +33,15 @@ def start_streaming(handler: "StreamingHandler"):
logger.warning(
"Removed streaming client %s: %s", handler.client_address, str(e)
)
finally:
if encoder is not None:
encoder.release()


def send_snapshot(handler: "StreamingHandler"):
encoder = getattr(handler, "mjpeg_encoder", None)
if encoder is not None:
encoder.acquire()
try:
send_default_headers(handler)
frame = handler.get_frame()
Expand All @@ -45,6 +54,9 @@ def send_snapshot(handler: "StreamingHandler"):
handler.wfile.write(frame[2:])
except Exception as e:
logger.warning("Removed client %s: %s", handler.client_address, str(e))
finally:
if encoder is not None:
encoder.release()


def send_default_headers(handler: "StreamingHandler"):
Expand Down
Loading