mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-08-13 13:47:36 +02:00
Review Item GenAI metadata (#19442)
* Rename existing function * Keep track of thumbnial updates * Tinkering with genai prompt * Adjust input format * Create model for review description output * testing prompt changes * Prompt improvements and image saving * Add config for review items genai * Use genai review config * Actual config usage * Adjust debug image saving * Fix * Fix review creation * Adjust prompt * Prompt adjustment * Run genai in thread * Fix detections block * Adjust prompt * Prompt changes * Save genai response to metadata model * Handle metadata * Send review update to dispatcher * Save review metadata to DB * Send review notification updates * Quick fix * Fix name * Fix update type * Correctly dump model * Add card * Add card * Remove message * Cleanup typing and UI * Adjust prompt * Formatting * Add log * Formatting * Add inference speed and keep alive
This commit is contained in:
parent
52295fcac4
commit
f8ca91643e
@ -26,6 +26,7 @@ from frigate.const import (
|
|||||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||||
UPDATE_EVENT_DESCRIPTION,
|
UPDATE_EVENT_DESCRIPTION,
|
||||||
UPDATE_MODEL_STATE,
|
UPDATE_MODEL_STATE,
|
||||||
|
UPDATE_REVIEW_DESCRIPTION,
|
||||||
UPSERT_REVIEW_SEGMENT,
|
UPSERT_REVIEW_SEGMENT,
|
||||||
)
|
)
|
||||||
from frigate.models import Event, Previews, Recordings, ReviewSegment
|
from frigate.models import Event, Previews, Recordings, ReviewSegment
|
||||||
@ -149,6 +150,14 @@ class Dispatcher:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def handle_update_review_description() -> None:
|
||||||
|
final_data = payload["after"]
|
||||||
|
ReviewSegment.insert(final_data).on_conflict(
|
||||||
|
conflict_target=[ReviewSegment.id],
|
||||||
|
update=final_data,
|
||||||
|
).execute()
|
||||||
|
self.publish("reviews", json.dumps(payload))
|
||||||
|
|
||||||
def handle_update_model_state() -> None:
|
def handle_update_model_state() -> None:
|
||||||
if payload:
|
if payload:
|
||||||
model = payload["model"]
|
model = payload["model"]
|
||||||
@ -232,6 +241,7 @@ class Dispatcher:
|
|||||||
CLEAR_ONGOING_REVIEW_SEGMENTS: handle_clear_ongoing_review_segments,
|
CLEAR_ONGOING_REVIEW_SEGMENTS: handle_clear_ongoing_review_segments,
|
||||||
UPDATE_CAMERA_ACTIVITY: handle_update_camera_activity,
|
UPDATE_CAMERA_ACTIVITY: handle_update_camera_activity,
|
||||||
UPDATE_EVENT_DESCRIPTION: handle_update_event_description,
|
UPDATE_EVENT_DESCRIPTION: handle_update_event_description,
|
||||||
|
UPDATE_REVIEW_DESCRIPTION: handle_update_review_description,
|
||||||
UPDATE_MODEL_STATE: handle_update_model_state,
|
UPDATE_MODEL_STATE: handle_update_model_state,
|
||||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
|
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
|
||||||
UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout,
|
UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout,
|
||||||
|
@ -369,12 +369,22 @@ class WebPushClient(Communicator):
|
|||||||
sorted_objects.update(payload["after"]["data"]["sub_labels"])
|
sorted_objects.update(payload["after"]["data"]["sub_labels"])
|
||||||
|
|
||||||
title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {titlecase(', '.join(payload['after']['data']['zones']).replace('_', ' '))}"
|
title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {titlecase(', '.join(payload['after']['data']['zones']).replace('_', ' '))}"
|
||||||
message = f"Detected on {titlecase(camera.replace('_', ' '))}"
|
|
||||||
image = f"{payload['after']['thumb_path'].replace('/media/frigate', '')}"
|
image = f"{payload['after']['thumb_path'].replace('/media/frigate', '')}"
|
||||||
|
ended = state == "end" or state == "genai"
|
||||||
|
|
||||||
|
if state == "genai" and payload["after"]["data"]["metadata"]:
|
||||||
|
message = payload["after"]["data"]["metadata"]["scene"]
|
||||||
|
else:
|
||||||
|
message = f"Detected on {titlecase(camera.replace('_', ' '))}"
|
||||||
|
|
||||||
|
if ended:
|
||||||
|
logger.debug(
|
||||||
|
f"Sending a notification with state {state} and message {message}"
|
||||||
|
)
|
||||||
|
|
||||||
# if event is ongoing open to live view otherwise open to recordings view
|
# if event is ongoing open to live view otherwise open to recordings view
|
||||||
direct_url = f"/review?id={reviewId}" if state == "end" else f"/#{camera}"
|
direct_url = f"/review?id={reviewId}" if ended else f"/#{camera}"
|
||||||
ttl = 3600 if state == "end" else 0
|
ttl = 3600 if ended else 0
|
||||||
|
|
||||||
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
|
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
|
||||||
|
|
||||||
|
@ -62,6 +62,18 @@ class DetectionsConfig(FrigateBaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class GenAIReviewConfig(FrigateBaseModel):
|
||||||
|
alerts: bool = Field(default=False, title="Enable GenAI for alerts.")
|
||||||
|
detections: bool = Field(default=False, title="Enable GenAI for detections.")
|
||||||
|
debug_save_thumbnails: bool = Field(
|
||||||
|
default=False,
|
||||||
|
title="Save thumbnails sent to generative AI for debugging purposes.",
|
||||||
|
)
|
||||||
|
enabled_in_config: Optional[bool] = Field(
|
||||||
|
default=None, title="Keep track of original state of generative AI."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReviewConfig(FrigateBaseModel):
|
class ReviewConfig(FrigateBaseModel):
|
||||||
"""Configure reviews"""
|
"""Configure reviews"""
|
||||||
|
|
||||||
@ -71,3 +83,6 @@ class ReviewConfig(FrigateBaseModel):
|
|||||||
detections: DetectionsConfig = Field(
|
detections: DetectionsConfig = Field(
|
||||||
default_factory=DetectionsConfig, title="Review detections config."
|
default_factory=DetectionsConfig, title="Review detections config."
|
||||||
)
|
)
|
||||||
|
genai: GenAIReviewConfig = Field(
|
||||||
|
default_factory=GenAIReviewConfig, title="Review description genai config."
|
||||||
|
)
|
||||||
|
@ -610,6 +610,10 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
camera_config.objects.genai.enabled_in_config = (
|
camera_config.objects.genai.enabled_in_config = (
|
||||||
camera_config.objects.genai.enabled
|
camera_config.objects.genai.enabled
|
||||||
)
|
)
|
||||||
|
camera_config.review.genai.enabled_in_config = (
|
||||||
|
camera_config.review.genai.alerts
|
||||||
|
or camera_config.review.genai.detections
|
||||||
|
)
|
||||||
|
|
||||||
# Add default filters
|
# Add default filters
|
||||||
object_keys = camera_config.objects.track
|
object_keys = camera_config.objects.track
|
||||||
|
@ -111,6 +111,7 @@ UPSERT_REVIEW_SEGMENT = "upsert_review_segment"
|
|||||||
CLEAR_ONGOING_REVIEW_SEGMENTS = "clear_ongoing_review_segments"
|
CLEAR_ONGOING_REVIEW_SEGMENTS = "clear_ongoing_review_segments"
|
||||||
UPDATE_CAMERA_ACTIVITY = "update_camera_activity"
|
UPDATE_CAMERA_ACTIVITY = "update_camera_activity"
|
||||||
UPDATE_EVENT_DESCRIPTION = "update_event_description"
|
UPDATE_EVENT_DESCRIPTION = "update_event_description"
|
||||||
|
UPDATE_REVIEW_DESCRIPTION = "update_review_description"
|
||||||
UPDATE_MODEL_STATE = "update_model_state"
|
UPDATE_MODEL_STATE = "update_model_state"
|
||||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress"
|
UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress"
|
||||||
UPDATE_BIRDSEYE_LAYOUT = "update_birdseye_layout"
|
UPDATE_BIRDSEYE_LAYOUT = "update_birdseye_layout"
|
||||||
|
@ -1,25 +1,162 @@
|
|||||||
"""Post processor for review items to get descriptions."""
|
"""Post processor for review items to get descriptions."""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
import os
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.const import CLIPS_DIR, UPDATE_REVIEW_DESCRIPTION
|
||||||
from frigate.data_processing.types import PostProcessDataEnum
|
from frigate.data_processing.types import PostProcessDataEnum
|
||||||
|
from frigate.genai import GenAIClient
|
||||||
|
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
|
||||||
|
|
||||||
from ..post.api import PostProcessorApi
|
from ..post.api import PostProcessorApi
|
||||||
|
from ..types import DataProcessorMetrics
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ReviewDescriptionProcessor(PostProcessorApi):
|
class ReviewDescriptionProcessor(PostProcessorApi):
|
||||||
def __init__(self, config, metrics):
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: FrigateConfig,
|
||||||
|
requestor: InterProcessRequestor,
|
||||||
|
metrics: DataProcessorMetrics,
|
||||||
|
client: GenAIClient,
|
||||||
|
):
|
||||||
super().__init__(config, metrics, None)
|
super().__init__(config, metrics, None)
|
||||||
self.tracked_review_items: dict[str, list[Any]] = {}
|
self.requestor = requestor
|
||||||
|
self.metrics = metrics
|
||||||
|
self.tracked_review_items: dict[str, list[tuple[int, bytes]]] = {}
|
||||||
|
self.genai_client = client
|
||||||
|
self.review_desc_speed = InferenceSpeed(self.metrics.review_desc_speed)
|
||||||
|
self.review_descs_dps = EventsPerSecond()
|
||||||
|
self.review_descs_dps.start()
|
||||||
|
|
||||||
def process_data(self, data, data_type):
|
def process_data(self, data, data_type):
|
||||||
|
self.metrics.review_desc_dps.value = self.review_descs_dps.eps()
|
||||||
|
|
||||||
if data_type != PostProcessDataEnum.review:
|
if data_type != PostProcessDataEnum.review:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"processor is looking at {data}")
|
id = data["after"]["id"]
|
||||||
|
|
||||||
|
if data["type"] == "new" or data["type"] == "update":
|
||||||
|
if id not in self.tracked_review_items:
|
||||||
|
self.tracked_review_items[id] = []
|
||||||
|
|
||||||
|
thumb_time = data["after"]["data"]["thumb_time"]
|
||||||
|
thumb_path = data["after"]["thumb_path"]
|
||||||
|
|
||||||
|
if thumb_time and thumb_path:
|
||||||
|
if (
|
||||||
|
len(self.tracked_review_items[id]) > 0
|
||||||
|
and self.tracked_review_items[id][0] == thumb_time
|
||||||
|
):
|
||||||
|
# we have already processed this thumbnail
|
||||||
|
return
|
||||||
|
|
||||||
|
thumb_data = cv2.imread(thumb_path)
|
||||||
|
ret, jpg = cv2.imencode(
|
||||||
|
".jpg", thumb_data, [int(cv2.IMWRITE_JPEG_QUALITY), 100]
|
||||||
|
)
|
||||||
|
|
||||||
|
if ret:
|
||||||
|
self.tracked_review_items[id].append((thumb_time, jpg.tobytes()))
|
||||||
|
|
||||||
|
if self.config.cameras[
|
||||||
|
data["after"]["camera"]
|
||||||
|
].review.genai.debug_save_thumbnails:
|
||||||
|
id = data["after"]["id"]
|
||||||
|
Path(os.path.join(CLIPS_DIR, f"genai-requests/{id}")).mkdir(
|
||||||
|
parents=True, exist_ok=True
|
||||||
|
)
|
||||||
|
shutil.copy(
|
||||||
|
thumb_path,
|
||||||
|
os.path.join(
|
||||||
|
CLIPS_DIR,
|
||||||
|
f"genai-requests/{id}/{thumb_time}.webp",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
if id not in self.tracked_review_items:
|
||||||
|
return
|
||||||
|
|
||||||
|
final_data = data["after"]
|
||||||
|
camera = final_data["camera"]
|
||||||
|
|
||||||
|
if (
|
||||||
|
final_data["severity"] == "alert"
|
||||||
|
and not self.config.cameras[camera].review.genai.alerts
|
||||||
|
):
|
||||||
|
self.tracked_review_items.pop(id)
|
||||||
|
return
|
||||||
|
elif (
|
||||||
|
final_data["severity"] == "detection"
|
||||||
|
and not self.config.cameras[camera].review.genai.detections
|
||||||
|
):
|
||||||
|
self.tracked_review_items.pop(id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# kickoff analysis
|
||||||
|
self.review_descs_dps.update()
|
||||||
|
threading.Thread(
|
||||||
|
target=run_analysis,
|
||||||
|
args=(
|
||||||
|
self.requestor,
|
||||||
|
self.genai_client,
|
||||||
|
self.review_desc_speed,
|
||||||
|
camera,
|
||||||
|
final_data,
|
||||||
|
copy.copy([r[1] for r in self.tracked_review_items[id]]),
|
||||||
|
),
|
||||||
|
).start()
|
||||||
|
self.tracked_review_items.pop(id)
|
||||||
|
|
||||||
def handle_request(self, request_data):
|
def handle_request(self, request_data):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def run_analysis(
|
||||||
|
requestor: InterProcessRequestor,
|
||||||
|
genai_client: GenAIClient,
|
||||||
|
review_inference_speed: InferenceSpeed,
|
||||||
|
camera: str,
|
||||||
|
final_data: dict[str, str],
|
||||||
|
thumbs: list[bytes],
|
||||||
|
) -> None:
|
||||||
|
start = datetime.datetime.now().timestamp()
|
||||||
|
metadata = genai_client.generate_review_description(
|
||||||
|
{
|
||||||
|
"camera": camera,
|
||||||
|
"objects": final_data["data"]["objects"],
|
||||||
|
"recognized_objects": final_data["data"]["sub_labels"],
|
||||||
|
"zones": final_data["data"]["zones"],
|
||||||
|
"timestamp": datetime.datetime.fromtimestamp(final_data["end_time"]),
|
||||||
|
},
|
||||||
|
thumbs,
|
||||||
|
)
|
||||||
|
review_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||||
|
|
||||||
|
if not metadata:
|
||||||
|
return None
|
||||||
|
|
||||||
|
prev_data = copy.deepcopy(final_data)
|
||||||
|
final_data["data"]["metadata"] = metadata.model_dump()
|
||||||
|
requestor.send_data(
|
||||||
|
UPDATE_REVIEW_DESCRIPTION,
|
||||||
|
{
|
||||||
|
"type": "genai",
|
||||||
|
"before": {k: v for k, v in prev_data.items()},
|
||||||
|
"after": {k: v for k, v in final_data.items()},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
16
frigate/data_processing/post/types.py
Normal file
16
frigate/data_processing/post/types.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewMetadata(BaseModel):
|
||||||
|
scene: str = Field(
|
||||||
|
description="A comprehensive description of the setting and entities, including relevant context and plausible inferences if supported by visual evidence."
|
||||||
|
)
|
||||||
|
confidence: float = Field(
|
||||||
|
description="A float between 0 and 1 representing your overall confidence in this analysis."
|
||||||
|
)
|
||||||
|
potential_threat_level: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
ge=1,
|
||||||
|
le=3,
|
||||||
|
description="An integer representing the potential threat level (1-3). 1: Minor anomaly. 2: Moderate concern. 3: High threat. Only include this field if a clear security concern is observable; otherwise, omit it.",
|
||||||
|
)
|
@ -20,6 +20,8 @@ class DataProcessorMetrics:
|
|||||||
alpr_pps: Synchronized
|
alpr_pps: Synchronized
|
||||||
yolov9_lpr_speed: Synchronized
|
yolov9_lpr_speed: Synchronized
|
||||||
yolov9_lpr_pps: Synchronized
|
yolov9_lpr_pps: Synchronized
|
||||||
|
review_desc_speed: Synchronized
|
||||||
|
review_desc_dps: Synchronized
|
||||||
classification_speeds: dict[str, Synchronized]
|
classification_speeds: dict[str, Synchronized]
|
||||||
classification_cps: dict[str, Synchronized]
|
classification_cps: dict[str, Synchronized]
|
||||||
|
|
||||||
@ -34,6 +36,8 @@ class DataProcessorMetrics:
|
|||||||
self.alpr_pps = manager.Value("d", 0.0)
|
self.alpr_pps = manager.Value("d", 0.0)
|
||||||
self.yolov9_lpr_speed = manager.Value("d", 0.0)
|
self.yolov9_lpr_speed = manager.Value("d", 0.0)
|
||||||
self.yolov9_lpr_pps = manager.Value("d", 0.0)
|
self.yolov9_lpr_pps = manager.Value("d", 0.0)
|
||||||
|
self.review_desc_speed = manager.Value("d", 0.0)
|
||||||
|
self.review_desc_dps = manager.Value("d", 0.0)
|
||||||
self.classification_speeds = manager.dict()
|
self.classification_speeds = manager.dict()
|
||||||
self.classification_cps = manager.dict()
|
self.classification_cps = manager.dict()
|
||||||
|
|
||||||
|
@ -151,6 +151,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
|
|
||||||
self.detected_license_plates: dict[str, dict[str, Any]] = {}
|
self.detected_license_plates: dict[str, dict[str, Any]] = {}
|
||||||
|
self.genai_client = get_genai_client(config)
|
||||||
|
|
||||||
# model runners to share between realtime and post processors
|
# model runners to share between realtime and post processors
|
||||||
if self.config.lpr.enabled:
|
if self.config.lpr.enabled:
|
||||||
@ -206,6 +207,13 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
# post processors
|
# post processors
|
||||||
self.post_processors: list[PostProcessorApi] = []
|
self.post_processors: list[PostProcessorApi] = []
|
||||||
|
|
||||||
|
if any(c.review.genai.enabled_in_config for c in self.config.cameras.values()):
|
||||||
|
self.post_processors.append(
|
||||||
|
ReviewDescriptionProcessor(
|
||||||
|
self.config, self.requestor, self.metrics, self.genai_client
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if self.config.lpr.enabled:
|
if self.config.lpr.enabled:
|
||||||
self.post_processors.append(
|
self.post_processors.append(
|
||||||
LicensePlatePostProcessor(
|
LicensePlatePostProcessor(
|
||||||
@ -240,7 +248,6 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.tracked_events: dict[str, list[Any]] = {}
|
self.tracked_events: dict[str, list[Any]] = {}
|
||||||
self.early_request_sent: dict[str, bool] = {}
|
self.early_request_sent: dict[str, bool] = {}
|
||||||
self.genai_client = get_genai_client(config)
|
|
||||||
|
|
||||||
# recordings data
|
# recordings data
|
||||||
self.recordings_available_through: dict[str, float] = {}
|
self.recordings_available_through: dict[str, float] = {}
|
||||||
@ -688,7 +695,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
"""Embed the description for an event."""
|
"""Embed the description for an event."""
|
||||||
camera_config = self.config.cameras[event.camera]
|
camera_config = self.config.cameras[event.camera]
|
||||||
|
|
||||||
description = self.genai_client.generate_description(
|
description = self.genai_client.generate_object_description(
|
||||||
camera_config, thumbnails, event
|
camera_config, thumbnails, event
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -3,11 +3,13 @@
|
|||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Optional
|
import re
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from frigate.config import CameraConfig, FrigateConfig, GenAIConfig, GenAIProviderEnum
|
from frigate.config import CameraConfig, FrigateConfig, GenAIConfig, GenAIProviderEnum
|
||||||
|
from frigate.data_processing.post.types import ReviewMetadata
|
||||||
from frigate.models import Event
|
from frigate.models import Event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -33,7 +35,60 @@ class GenAIClient:
|
|||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.provider = self._init_provider()
|
self.provider = self._init_provider()
|
||||||
|
|
||||||
def generate_description(
|
def generate_review_description(
|
||||||
|
self, review_data: dict[str, Any], thumbnails: list[bytes]
|
||||||
|
) -> ReviewMetadata | None:
|
||||||
|
"""Generate a description for the review item activity."""
|
||||||
|
context_prompt = f"""
|
||||||
|
Please analyze the image(s), which are in chronological order, strictly from the perspective of the {review_data["camera"].replace("_", " ")} security camera.
|
||||||
|
|
||||||
|
Your task is to provide a **neutral, factual, and objective description** of the scene, while also:
|
||||||
|
- Clearly stating **what is happening** based on observable actions and movements.
|
||||||
|
- Including **reasonable, evidence-based inferences** about the likely activity or context, but only if directly supported by visible details.
|
||||||
|
|
||||||
|
When forming your description:
|
||||||
|
- **Facts first**: Describe the physical setting, people, and objects exactly as seen.
|
||||||
|
- **Then context**: Briefly note plausible purposes or activities (e.g., “appears to be delivering a package” if carrying a box to a door).
|
||||||
|
- Clearly separate certain facts (“A person is holding a ladder”) from reasonable inferences (“likely performing maintenance”).
|
||||||
|
- Do not speculate beyond what is visible, and do not imply hostility, criminal intent, or other strong judgments unless there is unambiguous visual evidence.
|
||||||
|
|
||||||
|
Here is information already known:
|
||||||
|
- Activity occurred at {review_data["timestamp"].strftime("%I:%M %p")}
|
||||||
|
- Detected objects: {review_data["objects"]}
|
||||||
|
- Recognized objects: {review_data["recognized_objects"]}
|
||||||
|
- Zones involved: {review_data["zones"]}
|
||||||
|
|
||||||
|
Your response **MUST** be a flat JSON object with:
|
||||||
|
- `scene` (string): A full description including setting, entities, actions, and any plausible supported inferences.
|
||||||
|
- `confidence` (float): A number 0–1 for overall confidence in the analysis.
|
||||||
|
- `potential_threat_level` (integer, optional): Include only if there is a clear, observable security concern:
|
||||||
|
- 1 = Unusual but not overtly threatening
|
||||||
|
- 2 = Suspicious or potentially harmful
|
||||||
|
- 3 = Clear and immediate threat
|
||||||
|
Omit this field if no concern is evident.
|
||||||
|
|
||||||
|
**IMPORTANT:**
|
||||||
|
- Values must be plain strings, floats, or integers — no nested objects, no extra commentary.
|
||||||
|
"""
|
||||||
|
response = self._send(context_prompt, thumbnails)
|
||||||
|
|
||||||
|
if response:
|
||||||
|
clean_json = re.sub(
|
||||||
|
r"\n?```$", "", re.sub(r"^```[a-zA-Z0-9]*\n?", "", response)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return ReviewMetadata.model_validate_json(clean_json)
|
||||||
|
except Exception as e:
|
||||||
|
# rarely LLMs can fail to follow directions on output format
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to parse review description as the response did not match expected format. {e}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def generate_object_description(
|
||||||
self,
|
self,
|
||||||
camera_config: CameraConfig,
|
camera_config: CameraConfig,
|
||||||
thumbnails: list[bytes],
|
thumbnails: list[bytes],
|
||||||
|
@ -48,6 +48,7 @@ class OllamaClient(GenAIClient):
|
|||||||
self.genai_config.model,
|
self.genai_config.model,
|
||||||
prompt,
|
prompt,
|
||||||
images=images,
|
images=images,
|
||||||
|
options={"keep_alive": "1h"},
|
||||||
)
|
)
|
||||||
return result["response"].strip()
|
return result["response"].strip()
|
||||||
except (TimeoutException, ResponseError) as e:
|
except (TimeoutException, ResponseError) as e:
|
||||||
|
@ -142,6 +142,7 @@ class PendingReviewSegment:
|
|||||||
"zones": self.zones,
|
"zones": self.zones,
|
||||||
"audio": list(self.audio),
|
"audio": list(self.audio),
|
||||||
"thumb_time": self.thumb_time,
|
"thumb_time": self.thumb_time,
|
||||||
|
"metadata": None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -356,6 +356,14 @@ def stats_snapshot(
|
|||||||
embeddings_metrics.yolov9_lpr_pps.value, 2
|
embeddings_metrics.yolov9_lpr_pps.value, 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if embeddings_metrics.review_desc_dps.value > 0.0:
|
||||||
|
stats["embeddings"]["review_description_speed"] = round(
|
||||||
|
embeddings_metrics.review_desc_speed.value * 1000, 2
|
||||||
|
)
|
||||||
|
stats["embeddings"]["review_descriptions"] = round(
|
||||||
|
embeddings_metrics.review_desc_dps.value, 2
|
||||||
|
)
|
||||||
|
|
||||||
for key in embeddings_metrics.classification_speeds.keys():
|
for key in embeddings_metrics.classification_speeds.keys():
|
||||||
stats["embeddings"][f"{key}_classification_speed"] = round(
|
stats["embeddings"][f"{key}_classification_speed"] = round(
|
||||||
embeddings_metrics.classification_speeds[key].value * 1000, 2
|
embeddings_metrics.classification_speeds[key].value * 1000, 2
|
||||||
|
@ -11,7 +11,11 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||||
import { getIconForLabel } from "@/utils/iconUtil";
|
import { getIconForLabel } from "@/utils/iconUtil";
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import { ReviewDetailPaneType, ReviewSegment } from "@/types/review";
|
import {
|
||||||
|
ReviewDetailPaneType,
|
||||||
|
ReviewSegment,
|
||||||
|
ThreatLevel,
|
||||||
|
} from "@/types/review";
|
||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -69,6 +73,25 @@ export default function ReviewDetailDialog({
|
|||||||
review ? ["event_ids", { ids: review.data.detections.join(",") }] : null,
|
review ? ["event_ids", { ids: review.data.detections.join(",") }] : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const aiAnalysis = useMemo(() => review?.data?.metadata, [review]);
|
||||||
|
|
||||||
|
const aiThreatLevel = useMemo(() => {
|
||||||
|
if (!aiAnalysis?.potential_threat_level) {
|
||||||
|
return "None";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (aiAnalysis.potential_threat_level) {
|
||||||
|
case ThreatLevel.UNUSUAL:
|
||||||
|
return "Unusual Activity";
|
||||||
|
case ThreatLevel.SUSPICIOUS:
|
||||||
|
return "Suspicious Activity";
|
||||||
|
case ThreatLevel.DANGER:
|
||||||
|
return "Danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Unknown";
|
||||||
|
}, [aiAnalysis]);
|
||||||
|
|
||||||
const hasMismatch = useMemo(() => {
|
const hasMismatch = useMemo(() => {
|
||||||
if (!review || !events) {
|
if (!review || !events) {
|
||||||
return false;
|
return false;
|
||||||
@ -232,6 +255,22 @@ export default function ReviewDetailDialog({
|
|||||||
)}
|
)}
|
||||||
{pane == "overview" && (
|
{pane == "overview" && (
|
||||||
<div className="flex flex-col gap-5 md:mt-3">
|
<div className="flex flex-col gap-5 md:mt-3">
|
||||||
|
{aiAnalysis != undefined && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"m-2 flex h-full w-full flex-col gap-2 rounded-md bg-card p-2",
|
||||||
|
isDesktop && "w-[90%]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
AI Analysis
|
||||||
|
<div className="text-sm text-primary/40">Description</div>
|
||||||
|
<div className="text-sm">{aiAnalysis.scene}</div>
|
||||||
|
<div className="text-sm text-primary/40">Score</div>
|
||||||
|
<div className="text-sm">{aiAnalysis.confidence * 100}%</div>
|
||||||
|
<div className="text-sm text-primary/40">Threat Level</div>
|
||||||
|
<div className="text-sm">{aiThreatLevel}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex w-full flex-row">
|
<div className="flex w-full flex-row">
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
@ -18,6 +18,11 @@ export type ReviewData = {
|
|||||||
sub_labels?: string[];
|
sub_labels?: string[];
|
||||||
significant_motion_areas: number[];
|
significant_motion_areas: number[];
|
||||||
zones: string[];
|
zones: string[];
|
||||||
|
metadata?: {
|
||||||
|
scene: string;
|
||||||
|
confidence: number;
|
||||||
|
potential_threat_level?: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SegmentedReviewData =
|
export type SegmentedReviewData =
|
||||||
@ -73,3 +78,9 @@ export type ConsolidatedSegmentData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TimelineZoomDirection = "in" | "out" | null;
|
export type TimelineZoomDirection = "in" | "out" | null;
|
||||||
|
|
||||||
|
export enum ThreatLevel {
|
||||||
|
UNUSUAL = 1,
|
||||||
|
SUSPICIOUS = 2,
|
||||||
|
DANGER = 3,
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user