mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-13 17:13:01 +03:00
move genai review frame extraction off the embeddings loop
- Run frame gathering for finished review items on a thread so per-frame ffmpeg calls don't stall the embeddings maintainer - Share one path between live and regenerated review descriptions - Stop padding review start and end times in place, which widened the stored review item - Lock timestamp expiry in EventsPerSecond since update() and eps() now run on different threads
This commit is contained in:
@@ -179,53 +179,71 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
image_source = camera_config.review.genai.image_source
|
threading.Thread(
|
||||||
frame_mode = camera_config.review.genai.frame_mode
|
target=self._describe_finished_review,
|
||||||
|
name=f"review_description_{id}",
|
||||||
|
daemon=True,
|
||||||
|
args=(
|
||||||
|
camera_config,
|
||||||
|
final_data,
|
||||||
|
camera_config.review.genai.image_source,
|
||||||
|
),
|
||||||
|
).start()
|
||||||
|
|
||||||
if image_source == ImageSourceEnum.recordings:
|
def _describe_finished_review(
|
||||||
buffer_extension = get_recording_buffer_extension(
|
self,
|
||||||
final_data["end_time"] - final_data["start_time"]
|
camera_config: CameraConfig,
|
||||||
)
|
final_data: dict[str, Any],
|
||||||
final_data["start_time"] -= buffer_extension
|
image_source: ImageSourceEnum,
|
||||||
final_data["end_time"] += buffer_extension
|
preview_fallback: bool = True,
|
||||||
|
named_objects: list[tuple[str, str]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Gather frames for a finished review item and start its description.
|
||||||
|
|
||||||
frames = self.get_recording_frames(
|
Runs off the maintainer loop since recording frames shell out to ffmpeg
|
||||||
camera,
|
once per frame.
|
||||||
final_data["start_time"],
|
"""
|
||||||
final_data["end_time"],
|
camera = final_data["camera"]
|
||||||
height=480, # Use 480p for good balance between quality and token usage
|
id = final_data["id"]
|
||||||
frame_mode=frame_mode,
|
genai_config = camera_config.review.genai
|
||||||
)
|
frames: list[tuple[bytes, float]] = []
|
||||||
|
|
||||||
if not frames:
|
if image_source == ImageSourceEnum.recordings:
|
||||||
# Fallback to preview frames if no recordings available
|
buffer_extension = get_recording_buffer_extension(
|
||||||
logger.warning(
|
final_data["end_time"] - final_data["start_time"]
|
||||||
f"No recording frames found for {camera}, falling back to preview frames"
|
)
|
||||||
)
|
frames = self.get_recording_frames(
|
||||||
frames = self.get_preview_frames_as_bytes(
|
camera,
|
||||||
camera,
|
final_data["start_time"] - buffer_extension,
|
||||||
final_data["start_time"],
|
final_data["end_time"] + buffer_extension,
|
||||||
final_data["end_time"],
|
height=480, # Use 480p for good balance between quality and token usage
|
||||||
final_data["thumb_path"],
|
frame_mode=genai_config.frame_mode,
|
||||||
id,
|
)
|
||||||
camera_config.review.genai.debug_save_thumbnails,
|
|
||||||
frame_mode,
|
if frames:
|
||||||
)
|
if genai_config.debug_save_thumbnails:
|
||||||
elif camera_config.review.genai.debug_save_thumbnails:
|
|
||||||
self.save_debug_recording_frames(id, frames)
|
self.save_debug_recording_frames(id, frames)
|
||||||
|
elif not preview_fallback:
|
||||||
|
logger.error("No recording frames are available for review item %s", id)
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
# Use preview frames
|
logger.warning(
|
||||||
frames = self.get_preview_frames_as_bytes(
|
"No recording frames found for %s, falling back to preview frames",
|
||||||
camera,
|
camera,
|
||||||
final_data["start_time"],
|
|
||||||
final_data["end_time"],
|
|
||||||
final_data["thumb_path"],
|
|
||||||
id,
|
|
||||||
camera_config.review.genai.debug_save_thumbnails,
|
|
||||||
frame_mode,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.start_analysis(camera_config, final_data, frames)
|
if not frames:
|
||||||
|
frames = self.get_preview_frames_as_bytes(
|
||||||
|
camera,
|
||||||
|
final_data["start_time"],
|
||||||
|
final_data["end_time"],
|
||||||
|
final_data["thumb_path"],
|
||||||
|
id,
|
||||||
|
genai_config.debug_save_thumbnails,
|
||||||
|
genai_config.frame_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.start_analysis(camera_config, final_data, frames, named_objects)
|
||||||
|
|
||||||
def handle_request(self, topic: str, request_data: dict[str, Any]) -> str | None:
|
def handle_request(self, topic: str, request_data: dict[str, Any]) -> str | None:
|
||||||
if topic == EmbeddingsRequestEnum.regenerate_review_description.value:
|
if topic == EmbeddingsRequestEnum.regenerate_review_description.value:
|
||||||
@@ -419,31 +437,12 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
logger.error("Review item %s has not ended yet", review_id)
|
logger.error("Review item %s has not ended yet", review_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
buffer_extension = get_recording_buffer_extension(
|
self._describe_finished_review(
|
||||||
final_data["end_time"] - final_data["start_time"]
|
|
||||||
)
|
|
||||||
frames = self.get_recording_frames(
|
|
||||||
str(review.camera),
|
|
||||||
final_data["start_time"] - buffer_extension,
|
|
||||||
final_data["end_time"] + buffer_extension,
|
|
||||||
height=480,
|
|
||||||
frame_mode=camera_config.review.genai.frame_mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not frames:
|
|
||||||
logger.error(
|
|
||||||
"No recording frames are available for review item %s", review_id
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if camera_config.review.genai.debug_save_thumbnails:
|
|
||||||
self.save_debug_recording_frames(review_id, frames)
|
|
||||||
|
|
||||||
self.start_analysis(
|
|
||||||
camera_config,
|
camera_config,
|
||||||
final_data,
|
final_data,
|
||||||
frames,
|
ImageSourceEnum.recordings,
|
||||||
get_named_objects(
|
preview_fallback=False,
|
||||||
|
named_objects=get_named_objects(
|
||||||
final_data["data"].get("detections") or [],
|
final_data["data"].get("detections") or [],
|
||||||
self.config.all_attributes,
|
self.config.all_attributes,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ per-camera gating those processors apply once they exist.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ for mod in _MOCK_MODULES:
|
|||||||
if mod not in sys.modules:
|
if mod not in sys.modules:
|
||||||
sys.modules[mod] = MagicMock()
|
sys.modules[mod] = MagicMock()
|
||||||
|
|
||||||
|
from frigate.config.camera.review import ImageSourceEnum # noqa: E402
|
||||||
|
|
||||||
# imported from the maintainer to avoid tripping the circular import between
|
# imported from the maintainer to avoid tripping the circular import between
|
||||||
# the maintainer and the processor modules
|
# the maintainer and the processor modules
|
||||||
from frigate.embeddings.maintainer import ( # noqa: E402
|
from frigate.embeddings.maintainer import ( # noqa: E402
|
||||||
@@ -211,3 +214,58 @@ class TestObjectDescriptionCameraGating(unittest.TestCase):
|
|||||||
|
|
||||||
mock_create_thumbnail.assert_called_once()
|
mock_create_thumbnail.assert_called_once()
|
||||||
self.assertEqual(len(processor.tracked_events["1234.5-abcdef"]), 1)
|
self.assertEqual(len(processor.tracked_events["1234.5-abcdef"]), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewDescriptionOffLoop(unittest.TestCase):
|
||||||
|
"""Frame extraction for a finished review must not block process_data."""
|
||||||
|
|
||||||
|
def test_recording_frames_extracted_off_the_caller_thread(self):
|
||||||
|
camera = MagicMock()
|
||||||
|
camera.review.genai.enabled = True
|
||||||
|
camera.review.genai.alerts = True
|
||||||
|
camera.review.genai.image_source = ImageSourceEnum.recordings
|
||||||
|
camera.review.genai.debug_save_thumbnails = False
|
||||||
|
config = MagicMock()
|
||||||
|
config.cameras = {"front": camera}
|
||||||
|
processor = ReviewDescriptionProcessor(
|
||||||
|
config, MagicMock(), MagicMock(), MagicMock()
|
||||||
|
)
|
||||||
|
|
||||||
|
release = threading.Event()
|
||||||
|
extracted = threading.Event()
|
||||||
|
analyzed = threading.Event()
|
||||||
|
|
||||||
|
def slow_frames(*args, **kwargs):
|
||||||
|
release.wait(5)
|
||||||
|
extracted.set()
|
||||||
|
return [(b"jpg", 1.0)]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(processor, "get_recording_frames", side_effect=slow_frames),
|
||||||
|
patch.object(
|
||||||
|
processor, "start_analysis", side_effect=lambda *a, **k: analyzed.set()
|
||||||
|
) as start_analysis,
|
||||||
|
):
|
||||||
|
processor.process_data(
|
||||||
|
{
|
||||||
|
"type": "end",
|
||||||
|
"after": {
|
||||||
|
"id": "1234.5-abcdef",
|
||||||
|
"camera": "front",
|
||||||
|
"severity": "alert",
|
||||||
|
"start_time": 100.0,
|
||||||
|
"end_time": 110.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PostProcessDataEnum.review,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(extracted.is_set())
|
||||||
|
release.set()
|
||||||
|
self.assertTrue(extracted.wait(5))
|
||||||
|
|
||||||
|
self.assertTrue(analyzed.wait(5))
|
||||||
|
start_analysis.assert_called_once()
|
||||||
|
final_data = start_analysis.call_args.args[1]
|
||||||
|
self.assertEqual(final_data["start_time"], 100.0)
|
||||||
|
self.assertEqual(final_data["end_time"], 110.0)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import queue
|
|||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import struct
|
import struct
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -42,6 +43,7 @@ class EventsPerSecond:
|
|||||||
self._max_events = max_events
|
self._max_events = max_events
|
||||||
self._last_n_seconds = last_n_seconds
|
self._last_n_seconds = last_n_seconds
|
||||||
self._timestamps: deque[float] = deque(maxlen=max_events)
|
self._timestamps: deque[float] = deque(maxlen=max_events)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
self._start = time.monotonic()
|
self._start = time.monotonic()
|
||||||
@@ -71,8 +73,11 @@ class EventsPerSecond:
|
|||||||
# remove aged out timestamps
|
# remove aged out timestamps
|
||||||
def expire_timestamps(self, now: float) -> None:
|
def expire_timestamps(self, now: float) -> None:
|
||||||
threshold = now - self._last_n_seconds
|
threshold = now - self._last_n_seconds
|
||||||
while self._timestamps and self._timestamps[0] < threshold:
|
|
||||||
self._timestamps.popleft()
|
# update() and eps() can run on different threads
|
||||||
|
with self._lock:
|
||||||
|
while self._timestamps and self._timestamps[0] < threshold:
|
||||||
|
self._timestamps.popleft()
|
||||||
|
|
||||||
|
|
||||||
class InferenceSpeed:
|
class InferenceSpeed:
|
||||||
|
|||||||
Reference in New Issue
Block a user