Add optional idle heartbeat for Birdseye (periodic frame emission when idle)

birdseye: add optional idle heartbeat and FFmpeg tuning envs (default off)

This adds an optional configuration field `birdseye.idle_heartbeat_fps` to
enable a lightweight idle heartbeat mechanism in Birdseye. When set to a value
greater than 0, Birdseye periodically re-sends the last composed frame during
idle periods (no motion or active updates).

This helps downstream consumers such as go2rtc, Alexa, or Scrypted to attach
faster and maintain a low-latency RTSP stream when the system is idle.

Key details:
- Config-based (`birdseye.idle_heartbeat_fps`), default `0` (disabled).
- Uses existing Birdseye rendering pipeline; minimal performance impact.
- Does not alter behavior when unset.

Documentation: added tip section in docs/configuration/restream.md.
This commit is contained in:
Francesco Durighetto 2025-10-12 19:16:41 +02:00 committed by duri
parent 6d5098a0c2
commit 7789260a83
4 changed files with 55 additions and 2 deletions

View File

@ -240,6 +240,8 @@ birdseye:
scaling_factor: 2.0 scaling_factor: 2.0
# Optional: Maximum number of cameras to show at one time, showing the most recent (default: show all cameras) # Optional: Maximum number of cameras to show at one time, showing the most recent (default: show all cameras)
max_cameras: 1 max_cameras: 1
# Optional: Frames-per-second to re-send the last composed Birdseye frame when idle (no motion or active updates). (default: shown below)
idle_heartbeat_fps: 10.0
# Optional: ffmpeg configuration # Optional: ffmpeg configuration
# More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets # More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets

View File

@ -24,6 +24,12 @@ birdseye:
restream: True restream: True
``` ```
**Tip:** To improve connection speed when using Birdseye via RTSP or WebRTC,
you can enable a small idle heartbeat by setting `birdseye.idle_heartbeat_fps`
to a low value (e.g. `12`).
This makes Frigate periodically push the last frame even when no motion
is detected, reducing initial connection latency.
### Securing Restream With Authentication ### Securing Restream With Authentication
The go2rtc restream can be secured with RTSP based username / password authentication. Ex: The go2rtc restream can be secured with RTSP based username / password authentication. Ex:
@ -164,4 +170,4 @@ NOTE: The output will need to be passed with two curly braces `{{output}}`
go2rtc: go2rtc:
streams: streams:
stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {{output}} stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {{output}}
``` ```

View File

@ -55,7 +55,11 @@ class BirdseyeConfig(FrigateBaseModel):
layout: BirdseyeLayoutConfig = Field( layout: BirdseyeLayoutConfig = Field(
default_factory=BirdseyeLayoutConfig, title="Birdseye Layout Config" default_factory=BirdseyeLayoutConfig, title="Birdseye Layout Config"
) )
idle_heartbeat_fps: float = Field(
default=0.0,
ge=0.0,
title="Idle heartbeat FPS (0 disables)",
)
# uses BaseModel because some global attributes are not available at the camera level # uses BaseModel because some global attributes are not available at the camera level
class BirdseyeCameraConfig(BaseModel): class BirdseyeCameraConfig(BaseModel):

View File

@ -11,6 +11,7 @@ import subprocess as sp
import threading import threading
import traceback import traceback
from typing import Any, Optional from typing import Any, Optional
import time
import cv2 import cv2
import numpy as np import numpy as np
@ -791,7 +792,16 @@ class Birdseye:
self.frame_manager = SharedMemoryFrameManager() self.frame_manager = SharedMemoryFrameManager()
self.stop_event = stop_event self.stop_event = stop_event
self.requestor = InterProcessRequestor() self.requestor = InterProcessRequestor()
self._heartbeat_thread = None
# --- Optional idle heartbeat (disabled by default) ---
# If FRIGATE_BIRDSEYE_IDLE_FPS > 0, periodically re-send the last frame
# when no frames have been output recently. This improves client attach times
# without altering default behavior.
self.idle_fps = float(self.config.birdseye.idle_heartbeat_fps or 0.0)
self.idle_fps = max(0.0, self.idle_fps)
self._idle_interval: Optional[float] = (1.0 / self.idle_fps) if self.idle_fps > 0 else None
if config.birdseye.restream: if config.birdseye.restream:
self.birdseye_buffer = self.frame_manager.create( self.birdseye_buffer = self.frame_manager.create(
"birdseye", "birdseye",
@ -801,6 +811,16 @@ class Birdseye:
self.converter.start() self.converter.start()
self.broadcaster.start() self.broadcaster.start()
# Start heartbeat loop only if enabled
if self._idle_interval:
self._heartbeat_thread = threading.Thread(
target=self._idle_heartbeat_loop,
name="birdseye_idle_heartbeat",
daemon=True,
)
self._heartbeat_thread.start()
def __send_new_frame(self) -> None: def __send_new_frame(self) -> None:
frame_bytes = self.birdseye_manager.frame.tobytes() frame_bytes = self.birdseye_manager.frame.tobytes()
@ -849,6 +869,27 @@ class Birdseye:
coordinates = self.birdseye_manager.get_camera_coordinates() coordinates = self.birdseye_manager.get_camera_coordinates()
self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates) self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates)
def _idle_heartbeat_loop(self) -> None:
"""
Periodically re-send the last composed frame when idle.
Active only if FRIGATE_BIRDSEYE_IDLE_FPS > 0.
"""
# Small sleep granularity to check often without busy-spinning.
min_sleep = 0.2
while not self.stop_event.is_set():
try:
if self._idle_interval:
now = datetime.datetime.now().timestamp()
if (now - self.birdseye_manager.last_output_time) >= self._idle_interval:
self.__send_new_frame()
finally:
# Sleep at the smaller of idle interval or a safe minimum
sleep_for = self._idle_interval if self._idle_interval and self._idle_interval < min_sleep else min_sleep
time.sleep(sleep_for)
def stop(self) -> None: def stop(self) -> None:
self.converter.join() self.converter.join()
self.broadcaster.join() self.broadcaster.join()
if self._heartbeat_thread and self._heartbeat_thread.is_alive():
# the thread is daemon=True; join a moment just for cleanliness
self._heartbeat_thread.join(timeout=0.2)