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
# Optional: Maximum number of cameras to show at one time, showing the most recent (default: show all cameras)
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
# More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets

View File

@ -24,6 +24,12 @@ birdseye:
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
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:
streams:
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(
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
class BirdseyeCameraConfig(BaseModel):

View File

@ -11,6 +11,7 @@ import subprocess as sp
import threading
import traceback
from typing import Any, Optional
import time
import cv2
import numpy as np
@ -791,7 +792,16 @@ class Birdseye:
self.frame_manager = SharedMemoryFrameManager()
self.stop_event = stop_event
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:
self.birdseye_buffer = self.frame_manager.create(
"birdseye",
@ -801,6 +811,16 @@ class Birdseye:
self.converter.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:
frame_bytes = self.birdseye_manager.frame.tobytes()
@ -849,6 +869,27 @@ class Birdseye:
coordinates = self.birdseye_manager.get_camera_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:
self.converter.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)