use thumbnail and description for trigger types

This commit is contained in:
Josh Hawkins 2025-06-30 08:58:13 -05:00
parent e759557a4f
commit a17e202a9e
7 changed files with 42 additions and 72 deletions

View File

@ -48,6 +48,6 @@ class SubmitPlusBody(BaseModel):
class TriggerEmbeddingBody(BaseModel): class TriggerEmbeddingBody(BaseModel):
type: Literal["text", "image", "both"] type: Literal["thumbnail", "description"]
data: str data: str
threshold: float = Field(default=0.5, ge=0.0, le=1.0) threshold: float = Field(default=0.5, ge=0.0, le=1.0)

View File

@ -1479,9 +1479,9 @@ def create_trigger_embedding(
context: EmbeddingsContext = request.app.embeddings context: EmbeddingsContext = request.app.embeddings
# Generate embedding based on type # Generate embedding based on type
embedding = None embedding = None
if body.type == "text" or body.type == "both": if body.type == "description":
embedding = context.generate_description_embedding(body.data) embedding = context.generate_description_embedding(body.data)
elif body.type == "image": elif body.type == "thumbnail":
try: try:
event: Event = Event.get(Event.id == body.data) event: Event = Event.get(Event.id == body.data)
except DoesNotExist: except DoesNotExist:
@ -1584,9 +1584,9 @@ def update_trigger_embedding(
context: EmbeddingsContext = request.app.embeddings context: EmbeddingsContext = request.app.embeddings
# Generate embedding based on type # Generate embedding based on type
embedding = None embedding = None
if body.type == "text" or body.type == "both": if body.type == "description":
embedding = context.generate_description_embedding(body.data) embedding = context.generate_description_embedding(body.data)
elif body.type == "image": elif body.type == "thumbnail":
try: try:
event: Event = Event.get(Event.id == body.data) event: Event = Event.get(Event.id == body.data)
except DoesNotExist: except DoesNotExist:

View File

@ -26,9 +26,8 @@ class EnrichmentsDeviceEnum(str, Enum):
class TriggerType(str, Enum): class TriggerType(str, Enum):
TEXT = "text" THUMBNAIL = "thumbnail"
IMAGE = "image" DESCRIPTION = "description"
BOTH = "both"
class TriggerAction(str, Enum): class TriggerAction(str, Enum):
@ -126,7 +125,7 @@ class SemanticSearchConfig(FrigateBaseModel):
class TriggerConfig(FrigateBaseModel): class TriggerConfig(FrigateBaseModel):
type: TriggerType = Field(default=TriggerType.TEXT, title="Type of trigger") type: TriggerType = Field(default=TriggerType.DESCRIPTION, title="Type of trigger")
data: str = Field(title="Trigger content (text phrase or image ID)") data: str = Field(title="Trigger content (text phrase or image ID)")
threshold: float = Field( threshold: float = Field(
title="Confidence score required to run the trigger.", title="Confidence score required to run the trigger.",

View File

@ -1,7 +1,9 @@
"""Real time processor to trigger alerts by matching embeddings.""" """Real time processor to trigger alerts by matching embeddings."""
import datetime import datetime
import json
import logging import logging
import os
from typing import Any from typing import Any
import cv2 import cv2
@ -10,6 +12,7 @@ from peewee import DoesNotExist
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import CONFIG_DIR
from frigate.data_processing.types import PostProcessDataEnum from frigate.data_processing.types import PostProcessDataEnum
from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.embeddings.util import ZScoreNormalization from frigate.embeddings.util import ZScoreNormalization
@ -41,6 +44,16 @@ class SemanticTriggerProcessor(PostProcessorApi):
self.trigger_embeddings: list[np.ndarray] = [] self.trigger_embeddings: list[np.ndarray] = []
self.thumb_stats = ZScoreNormalization() self.thumb_stats = ZScoreNormalization()
self.desc_stats = ZScoreNormalization()
# load stats from disk
try:
with open(os.path.join(CONFIG_DIR, ".search_stats.json"), "r") as f:
data = json.loads(f.read())
self.thumb_stats.from_dict(data["thumb_stats"])
self.desc_stats.from_dict(data["desc_stats"])
except FileNotFoundError:
pass
def process_data( def process_data(
self, data: dict[str, Any], data_type: PostProcessDataEnum self, data: dict[str, Any], data_type: PostProcessDataEnum
@ -105,60 +118,22 @@ class SemanticTriggerProcessor(PostProcessorApi):
trigger_embedding = np.frombuffer(trigger["embedding"], dtype=np.float32) trigger_embedding = np.frombuffer(trigger["embedding"], dtype=np.float32)
# Determine which embedding to compare based on trigger type # Determine which embedding to compare based on trigger type
if trigger["type"] == "image" and thumbnail_embedding is not None: if trigger["type"] == "text" and thumbnail_embedding is not None:
data_embedding = thumbnail_embedding data_embedding = thumbnail_embedding
normalized_distance = self.thumb_stats.normalize( normalized_distance = self.thumb_stats.normalize(
[cosine_distance(data_embedding, trigger_embedding)], [cosine_distance(data_embedding, trigger_embedding)],
save_stats=False, save_stats=False,
)[0] )[0]
elif trigger["type"] == "text" and description_embedding is not None: elif trigger["type"] == "thumbnail" and thumbnail_embedding is not None:
data_embedding = description_embedding data_embedding = thumbnail_embedding
normalized_distance = cosine_distance(data_embedding, trigger_embedding) normalized_distance = cosine_distance(data_embedding, trigger_embedding)
elif trigger["type"] == "both": elif trigger["type"] == "description" and description_embedding is not None:
# For "both" type triggers, check both embeddings and use the best match data_embedding = description_embedding
similarities = [] normalized_distance = self.desc_stats.normalize(
similarity_sources = [] # Track which embedding produced each similarity [cosine_distance(data_embedding, trigger_embedding)],
save_stats=False,
)[0]
if thumbnail_embedding is not None:
thumb_distance = cosine_distance(
thumbnail_embedding, trigger_embedding
)
thumb_normalized = self.thumb_stats.normalize(
[thumb_distance], save_stats=False
)[0]
thumb_similarity = 1 - thumb_normalized
similarities.append(thumb_similarity)
similarity_sources.append("thumbnail")
if description_embedding is not None:
desc_distance = cosine_distance(
description_embedding, trigger_embedding
)
desc_similarity = 1 - desc_distance
similarities.append(desc_similarity)
similarity_sources.append("description")
if not similarities:
continue # Skip if no valid embeddings
# Find the best similarity and its source
max_similarity_idx = similarities.index(max(similarities))
similarity = similarities[max_similarity_idx]
selected_source = similarity_sources[max_similarity_idx]
normalized_distance = 1 - similarity
# Debug log showing all similarities and which was selected
if len(similarities) > 1:
logger.debug(
f"Both embeddings available for trigger '{trigger['name']}': "
f"thumbnail={similarities[0]:.4f}, description={similarities[1]:.4f}, "
f"selected={selected_source} with similarity={similarity:.4f}"
)
else:
logger.debug(
f"Single embedding available for trigger '{trigger['name']}': "
f"{selected_source}={similarity:.4f}"
)
else: else:
# Skip trigger if embedding type doesn't match available data # Skip trigger if embedding type doesn't match available data
continue continue
@ -166,7 +141,7 @@ class SemanticTriggerProcessor(PostProcessorApi):
similarity = 1 - normalized_distance similarity = 1 - normalized_distance
logger.debug( logger.debug(
f"Trigger for {trigger['data'] if trigger['type'] == 'text' else 'image/both'} " f"Trigger for {trigger['data'] if trigger['type'] == 'text' or trigger['type'] == 'description' else 'image'} "
f"(camera: {trigger['camera']}): normalized: {normalized_distance:.4f}, " f"(camera: {trigger['camera']}): normalized: {normalized_distance:.4f}, "
f"similarity: {similarity:.4f}, threshold: {trigger['threshold']}" f"similarity: {similarity:.4f}, threshold: {trigger['threshold']}"
) )

View File

@ -663,9 +663,8 @@
"deleteTrigger": "Delete Trigger" "deleteTrigger": "Delete Trigger"
}, },
"type": { "type": {
"image": "Image", "thumbnail": "Thumbnail",
"text": "Text", "description": "Description"
"both": "Both"
}, },
"actions": { "actions": {
"alert": "Mark as Alert", "alert": "Mark as Alert",

View File

@ -86,7 +86,7 @@ export default function CreateTriggerDialog({
!existingTriggerNames.includes(value) || value === trigger?.name, !existingTriggerNames.includes(value) || value === trigger?.name,
t("triggers.dialog.form.name.error.alreadyExists"), t("triggers.dialog.form.name.error.alreadyExists"),
), ),
type: z.enum(["image", "text", "both"]), type: z.enum(["thumbnail", "description"]),
data: z.string().min(1, t("triggers.dialog.form.content.error.required")), data: z.string().min(1, t("triggers.dialog.form.content.error.required")),
threshold: z threshold: z
.number() .number()
@ -102,7 +102,7 @@ export default function CreateTriggerDialog({
mode: "onChange", mode: "onChange",
defaultValues: { defaultValues: {
name: trigger?.name ?? "", name: trigger?.name ?? "",
type: trigger?.type ?? "text", type: trigger?.type ?? "description",
data: trigger?.data ?? "", data: trigger?.data ?? "",
threshold: trigger?.threshold ?? 0.5, threshold: trigger?.threshold ?? 0.5,
actions: trigger?.actions ?? ["alert"], actions: trigger?.actions ?? ["alert"],
@ -129,7 +129,7 @@ export default function CreateTriggerDialog({
if (!show) { if (!show) {
form.reset({ form.reset({
name: "", name: "",
type: "text", type: "description",
data: "", data: "",
threshold: 0.5, threshold: 0.5,
actions: ["alert"], actions: ["alert"],
@ -214,14 +214,11 @@ export default function CreateTriggerDialog({
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="image"> <SelectItem value="thumbnail">
{t("triggers.type.image")} {t("triggers.type.thumbnail")}
</SelectItem> </SelectItem>
<SelectItem value="text"> <SelectItem value="description">
{t("triggers.type.text")} {t("triggers.type.description")}
</SelectItem>
<SelectItem value="both">
{t("triggers.type.both")}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@ -238,7 +235,7 @@ export default function CreateTriggerDialog({
<FormLabel> <FormLabel>
{t("triggers.dialog.form.content.title")} {t("triggers.dialog.form.content.title")}
</FormLabel> </FormLabel>
{form.watch("type") === "image" ? ( {form.watch("type") === "thumbnail" ? (
<FormControl> <FormControl>
<ImagePicker <ImagePicker
selectedImageId={field.value} selectedImageId={field.value}

View File

@ -1,4 +1,4 @@
export type TriggerType = "image" | "text" | "both"; export type TriggerType = "thumbnail" | "description";
export type TriggerAction = "alert" | "notification"; export type TriggerAction = "alert" | "notification";
export type Trigger = { export type Trigger = {