Compare commits

...

11 Commits

Author SHA1 Message Date
Nicolas Mowen
bd08b7d0bb Add card 2025-08-09 09:09:50 -06:00
Nicolas Mowen
48ab00bd86 Add card 2025-08-09 09:09:15 -06:00
Nicolas Mowen
91947edec6 Correctly dump model 2025-08-09 07:20:57 -06:00
Nicolas Mowen
f94902a333 Fix update type 2025-08-09 06:57:35 -06:00
Nicolas Mowen
6183edad91 Fix name 2025-08-09 06:44:52 -06:00
Nicolas Mowen
fd3b69cde7 Quick fix 2025-08-09 06:41:45 -06:00
Nicolas Mowen
d36777d67a Send review notification updates 2025-08-09 06:31:59 -06:00
Nicolas Mowen
48a69c2ffb Save review metadata to DB 2025-08-09 06:29:29 -06:00
Nicolas Mowen
8d719ec9cb Send review update to dispatcher 2025-08-09 06:11:41 -06:00
Nicolas Mowen
33ae42a940 Handle metadata 2025-08-09 06:11:33 -06:00
Nicolas Mowen
29835909a6 Save genai response to metadata model 2025-08-09 06:02:00 -06:00
10 changed files with 106 additions and 32 deletions

View File

@ -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,

View File

@ -369,12 +369,20 @@ 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:
print(f"sending a message with 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}")

View File

@ -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"

View File

@ -10,19 +10,29 @@ from pathlib import Path
import cv2 import cv2
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import CLIPS_DIR 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.genai import GenAIClient
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: FrigateConfig, metrics, client: GenAIClient): def __init__(
self,
config: FrigateConfig,
requestor: InterProcessRequestor,
metrics: DataProcessorMetrics,
client: GenAIClient,
):
super().__init__(config, metrics, None) super().__init__(config, metrics, None)
self.requestor = requestor
self.metrics = metrics
self.tracked_review_items: dict[str, list[tuple[int, bytes]]] = {} self.tracked_review_items: dict[str, list[tuple[int, bytes]]] = {}
self.genai_client = client self.genai_client = client
@ -92,8 +102,9 @@ class ReviewDescriptionProcessor(PostProcessorApi):
# kickoff analysis # kickoff analysis
threading.Thread( threading.Thread(
target=self.run_analysis, target=run_analysis,
args=( args=(
self.requestor,
self.genai_client, self.genai_client,
camera, camera,
final_data, final_data,
@ -102,14 +113,19 @@ class ReviewDescriptionProcessor(PostProcessorApi):
).start() ).start()
self.tracked_review_items.pop(id) self.tracked_review_items.pop(id)
def handle_request(self, request_data):
pass
@staticmethod
def run_analysis( def run_analysis(
self, requestor: InterProcessRequestor,
genai_client: GenAIClient, genai_client: GenAIClient,
camera: str, camera: str,
final_data: dict[str, str], final_data: dict[str, str],
thumbs: list[bytes], thumbs: list[bytes],
) -> None: ) -> None:
genai_client.generate_review_description( metadata = genai_client.generate_review_description(
{ {
"camera": camera, "camera": camera,
"objects": final_data["data"]["objects"], "objects": final_data["data"]["objects"],
@ -120,5 +136,16 @@ class ReviewDescriptionProcessor(PostProcessorApi):
thumbs, thumbs,
) )
def handle_request(self, request_data): if not metadata:
pass 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()},
},
)

View File

@ -209,7 +209,9 @@ class EmbeddingMaintainer(threading.Thread):
if any(c.review.genai.enabled_in_config for c in self.config.cameras.values()): if any(c.review.genai.enabled_in_config for c in self.config.cameras.values()):
self.post_processors.append( self.post_processors.append(
ReviewDescriptionProcessor(self.config, self.metrics, self.genai_client) ReviewDescriptionProcessor(
self.config, self.requestor, self.metrics, self.genai_client
)
) )
if self.config.lpr.enabled: if self.config.lpr.enabled:

View File

@ -3,6 +3,7 @@
import importlib import importlib
import logging import logging
import os import os
import re
from typing import Any, Optional from typing import Any, Optional
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
@ -36,7 +37,7 @@ class GenAIClient:
def generate_review_description( def generate_review_description(
self, review_data: dict[str, Any], thumbnails: list[bytes] self, review_data: dict[str, Any], thumbnails: list[bytes]
) -> None: ) -> ReviewMetadata | None:
"""Generate a description for the review item activity.""" """Generate a description for the review item activity."""
context_prompt = f""" 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. Please analyze the image(s), which are in chronological order, strictly from the perspective of the {review_data["camera"].replace("_", " ")} security camera.
@ -64,12 +65,22 @@ class GenAIClient:
Omit this field entirely if there is no observable security concern. Omit this field entirely if there is no observable security concern.
**IMPORTANT:** **IMPORTANT:**
- Values for each field must be plain strings or integers no nested objects or explanatory text. Values for each field must be plain strings or integers no nested objects or explanatory text.
- The JSON must strictly match this structure:
{ReviewMetadata.model_json_schema()["properties"]}
""" """
logger.info(f"processing {review_data}") response = self._send(context_prompt, thumbnails)
logger.info(f"Got GenAI review: {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:
# rarely LLMs can fail to follow directions on output format
return None
else:
return None
def generate_object_description( def generate_object_description(
self, self,

View File

@ -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,
}, },
} }
) )

View File

@ -69,6 +69,8 @@ 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 hasMismatch = useMemo(() => { const hasMismatch = useMemo(() => {
if (!review || !events) { if (!review || !events) {
return false; return false;
@ -232,6 +234,15 @@ 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="m-2 flex h-full w-[90%] flex-col gap-2 rounded-md bg-card p-2">
AI Analysis
<div className="text-sm text-primary/40">Description</div>
<div className="text-sm smart-capitalize">
{aiAnalysis.scene}
</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">

View File

@ -18,6 +18,9 @@ export type ReviewData = {
sub_labels?: string[]; sub_labels?: string[];
significant_motion_areas: number[]; significant_motion_areas: number[];
zones: string[]; zones: string[];
metadata?: {
scene: string;
};
}; };
export type SegmentedReviewData = export type SegmentedReviewData =

View File

@ -4,7 +4,7 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc"; import react from "@vitejs/plugin-react-swc";
import monacoEditorPlugin from "vite-plugin-monaco-editor"; import monacoEditorPlugin from "vite-plugin-monaco-editor";
const proxyHost = process.env.PROXY_HOST || "localhost:5000"; const proxyHost = process.env.PROXY_HOST || "192.168.50.106:5002";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({