diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 67d2515cb..e65ba244c 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -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 diff --git a/docs/docs/configuration/restream.md b/docs/docs/configuration/restream.md index 43c421dd3..4930fb1e8 100644 --- a/docs/docs/configuration/restream.md +++ b/docs/docs/configuration/restream.md @@ -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. `1–2`). + 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}} -``` +``` \ No newline at end of file diff --git a/frigate/config/camera/birdseye.py b/frigate/config/camera/birdseye.py index b7e8a7117..e968956b9 100644 --- a/frigate/config/camera/birdseye.py +++ b/frigate/config/camera/birdseye.py @@ -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): diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index 0939b5ce4..770078b69 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -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)