diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 5f4cff4e7..67d2515cb 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -912,6 +912,8 @@ cameras: trigger_name: # Required: Enable or disable the trigger. (default: shown below) enabled: true + # Optional: A friendly name or descriptive text for the trigger + friendly_name: Unique name or descriptive text # Type of trigger, either `thumbnail` for image-based matching or `description` for text-based matching. (default: none) type: thumbnail # Reference data for matching, either an event ID for `thumbnail` or a text string for `description`. (default: none) diff --git a/docs/docs/configuration/semantic_search.md b/docs/docs/configuration/semantic_search.md index 48a830e8e..9950a3c8a 100644 --- a/docs/docs/configuration/semantic_search.md +++ b/docs/docs/configuration/semantic_search.md @@ -109,11 +109,19 @@ See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_ ## Triggers -Triggers utilize semantic search to automate actions when a tracked object matches a specified image or description. Triggers can be configured so that Frigate executes a specific actions when a tracked object's image or description matches a predefined image or text, based on a similarity threshold. Triggers are managed per camera and can be configured via the Frigate UI in the Settings page under the Triggers tab. +Triggers utilize Semantic Search to automate actions when a tracked object matches a specified image or description. Triggers can be configured so that Frigate executes a specific actions when a tracked object's image or description matches a predefined image or text, based on a similarity threshold. Triggers are managed per camera and can be configured via the Frigate UI in the Settings page under the Triggers tab. + +:::note + +Semantic Search must be enabled to use Triggers. + +::: ### Configuration -Triggers are defined within the `semantic_search` configuration for each camera in your Frigate configuration file or through the UI. Each trigger consists of a `type` (either `thumbnail` or `description`), a `data` field (the reference image event ID or text), a `threshold` for similarity matching, and a list of `actions` to perform when the trigger fires. +Triggers are defined within the `semantic_search` configuration for each camera in your Frigate configuration file or through the UI. Each trigger consists of a `friendly_name`, a `type` (either `thumbnail` or `description`), a `data` field (the reference image event ID or text), a `threshold` for similarity matching, and a list of `actions` to perform when the trigger fires. + +Triggers are best configured through the Frigate UI. #### Managing Triggers in the UI @@ -122,6 +130,7 @@ Triggers are defined within the `semantic_search` configuration for each camera 3. Click **Add Trigger** to create a new trigger or use the pencil icon to edit an existing one. 4. In the **Create Trigger** dialog: - Enter a **Name** for the trigger (e.g., "red_car_alert"). + - Enter a descriptive **Friendly Name** for the trigger (e.g., "Red car on the driveway camera"). - Select the **Type** (`Thumbnail` or `Description`). - For `Thumbnail`, select an image to trigger this action when a similar thumbnail image is detected, based on the threshold. - For `Description`, enter text to trigger this action when a similar tracked object description is detected. @@ -149,6 +158,6 @@ When a trigger fires, the UI highlights the trigger with a blue outline for 3 se #### Why can't I create a trigger on thumbnails for some text, like "person with a blue shirt" and have it trigger when a person with a blue shirt is detected? -TL;DR: Text-to-image triggers aren’t supported because CLIP can confuse similar images and give inconsistent scores, making automation unreliable. +TL;DR: Text-to-image triggers aren’t supported because CLIP can confuse similar images and give inconsistent scores, making automation unreliable. The same word–image pair can give different scores and the score ranges can be too close together to set a clear cutoff. Text-to-image triggers are not supported due to fundamental limitations of CLIP-based similarity search. While CLIP works well for exploratory, manual queries, it is unreliable for automated triggers based on a threshold. Issues include embedding drift (the same text–image pair can yield different cosine distances over time), lack of true semantic grounding (visually similar but incorrect matches), and unstable thresholding (distance distributions are dataset-dependent and often too tightly clustered to separate relevant from irrelevant results). Instead, it is recommended to set up a workflow with thumbnail triggers: first use text search to manually select 3–5 representative reference tracked objects, then configure thumbnail triggers based on that visual similarity. This provides robust automation without the semantic ambiguity of text to image matching. diff --git a/frigate/config/classification.py b/frigate/config/classification.py index 98e0db046..5cc07d28a 100644 --- a/frigate/config/classification.py +++ b/frigate/config/classification.py @@ -138,6 +138,9 @@ class SemanticSearchConfig(FrigateBaseModel): class TriggerConfig(FrigateBaseModel): + friendly_name: Optional[str] = Field( + None, title="Trigger friendly name used in the Frigate UI." + ) enabled: bool = Field(default=True, title="Enable this trigger") type: TriggerType = Field(default=TriggerType.DESCRIPTION, title="Type of trigger") data: str = Field(title="Trigger content (text phrase or image ID)") diff --git a/frigate/data_processing/post/semantic_trigger.py b/frigate/data_processing/post/semantic_trigger.py index baa47ba1c..db1aff751 100644 --- a/frigate/data_processing/post/semantic_trigger.py +++ b/frigate/data_processing/post/semantic_trigger.py @@ -159,7 +159,7 @@ class SemanticTriggerProcessor(PostProcessorApi): # Check if similarity meets threshold if similarity >= trigger["threshold"]: - logger.info( + logger.debug( f"Trigger {trigger['name']} activated with similarity {similarity:.4f}" ) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 7d31180a9..e91f5278c 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -720,6 +720,10 @@ }, "triggers": { "documentTitle": "Triggers", + "semanticSearch": { + "title": "Semantic Search is disabled", + "desc": "Semantic Search must be enabled to use Triggers." + }, "management": { "title": "Trigger Management", "desc": "Manage triggers for {{camera}}. Use the thumbnail type to trigger on similar thumbnails to your selected tracked object, and the description type to trigger on similar descriptions to text you specify." @@ -774,6 +778,11 @@ "title": "Type", "placeholder": "Select trigger type" }, + "friendly_name": { + "title": "Friendly Name", + "placeholder": "Name or describe this trigger", + "description": "An optional friendly name or descriptive text for this trigger." + }, "content": { "title": "Content", "imagePlaceholder": "Select an image", diff --git a/web/src/components/overlay/CreateTriggerDialog.tsx b/web/src/components/overlay/CreateTriggerDialog.tsx index f3a8a22bd..620d3fe62 100644 --- a/web/src/components/overlay/CreateTriggerDialog.tsx +++ b/web/src/components/overlay/CreateTriggerDialog.tsx @@ -60,6 +60,7 @@ type CreateTriggerDialogProps = { data: string, threshold: number, actions: TriggerAction[], + friendly_name: string, ) => void; onEdit: (trigger: Trigger) => void; onCancel: () => void; @@ -102,6 +103,7 @@ export default function CreateTriggerDialog({ !existingTriggerNames.includes(value) || value === trigger?.name, t("triggers.dialog.form.name.error.alreadyExists"), ), + friendly_name: z.string().optional(), type: z.enum(["thumbnail", "description"]), data: z.string().min(1, t("triggers.dialog.form.content.error.required")), threshold: z @@ -117,6 +119,7 @@ export default function CreateTriggerDialog({ defaultValues: { enabled: trigger?.enabled ?? true, name: trigger?.name ?? "", + friendly_name: trigger?.friendly_name ?? "", type: trigger?.type ?? "description", data: trigger?.data ?? "", threshold: trigger?.threshold ?? 0.5, @@ -135,6 +138,7 @@ export default function CreateTriggerDialog({ values.data, values.threshold, values.actions, + values.friendly_name ?? "", ); } }; @@ -144,6 +148,7 @@ export default function CreateTriggerDialog({ form.reset({ enabled: true, name: "", + friendly_name: "", type: "description", data: "", threshold: 0.5, @@ -154,6 +159,7 @@ export default function CreateTriggerDialog({ { enabled: trigger.enabled, name: trigger.name, + friendly_name: trigger.friendly_name ?? "", type: trigger.type, data: trigger.data, threshold: trigger.threshold, @@ -231,6 +237,31 @@ export default function CreateTriggerDialog({ )} /> + ( + + + {t("triggers.dialog.form.friendly_name.title")} + + + + + + {t("triggers.dialog.form.friendly_name.description")} + + + + )} + /> + { if ( !config || @@ -93,6 +107,7 @@ export default function TriggerView({ ).map(([name, trigger]) => ({ enabled: trigger.enabled, name, + friendly_name: trigger.friendly_name, type: trigger.type, data: trigger.data, threshold: trigger.threshold, @@ -139,11 +154,12 @@ export default function TriggerView({ const saveToConfig = useCallback( (trigger: Trigger, isEdit: boolean) => { setIsLoading(true); - const { enabled, name, type, data, threshold, actions } = trigger; + const { enabled, name, type, data, threshold, actions, friendly_name } = + trigger; const embeddingBody: TriggerEmbeddingBody = { type, data, threshold }; const embeddingUrl = isEdit ? `/trigger/embedding/${selectedCamera}/${name}` - : `/trigger/embedding?camera=${selectedCamera}&name=${name}`; + : `/trigger/embedding?camera_name=${selectedCamera}&name=${name}`; const embeddingMethod = isEdit ? axios.put : axios.post; embeddingMethod(embeddingUrl, embeddingBody) @@ -162,6 +178,7 @@ export default function TriggerView({ data, threshold, actions, + friendly_name, }, }, }, @@ -220,9 +237,21 @@ export default function TriggerView({ data: string, threshold: number, actions: TriggerAction[], + friendly_name: string, ) => { setUnsavedChanges(true); - saveToConfig({ enabled, name, type, data, threshold, actions }, false); + saveToConfig( + { + enabled, + name, + type, + data, + threshold, + actions, + friendly_name, + }, + false, + ); }, [saveToConfig, setUnsavedChanges], ); @@ -359,7 +388,7 @@ export default function TriggerView({ // for adding a trigger with event id via explore context menu useSearchEffect("event_id", (eventId: string) => { - if (!config || isLoading) { + if (!config || isLoading || !isSemanticSearchEnabled) { return false; } setShowCreate(true); @@ -386,189 +415,227 @@ export default function TriggerView({
-
-
- - {t("triggers.management.title")} - -

- {t("triggers.management.desc", { - camera: cameraName, - })} -

-
- -
-
-
-
- {triggers.length === 0 ? ( -
-

- {t("triggers.table.noTriggers")} -

-
- ) : ( -
- {triggers.map((trigger) => ( -
+
+ + {t("triggers.management.title")} + +

+ {t("triggers.management.desc", { + camera: cameraName, + })} +

+ + + {t("triggers.semanticSearch.title")} + + + triggers.semanticSearch.desc + +
+ -
-
-

- {trigger.name} -

-
-
- - {t(`triggers.type.${trigger.type}`)} - -
- - -
- {t("triggers.table.lastTriggered")}:{" "} - {trigger_status && - trigger_status.triggers[trigger.name] - ?.last_triggered - ? formatUnixTimestampToDateTime( - trigger_status.triggers[trigger.name] - ?.last_triggered, - { - timezone: config.ui.timezone, - date_format: - config.ui.time_format == "24hour" - ? t( - "time.formattedTimestamp2.24hour", - { - ns: "common", - }, - ) - : t( - "time.formattedTimestamp2.12hour", - { - ns: "common", - }, - ), - time_style: "medium", - date_style: "medium", - }, - ) - : "Never"} - - - - - - {t("details.item.button.viewInExplore", { - ns: "views/explore", - })} - - -
- -
-
- -
- - - - - - -

{t("triggers.table.edit")}

-
-
- - - - - -

{t("triggers.table.deleteTrigger")}

-
-
-
-
-
- ))} -
- )} + {t("readTheDocumentation", { ns: "common" })}{" "} + + +
+ +
-
+ ) : ( + <> +
+
+ + {t("triggers.management.title")} + +

+ {t("triggers.management.desc", { + camera: cameraName, + })} +

+
+ +
+
+
+
+ {triggers.length === 0 ? ( +
+

+ {t("triggers.table.noTriggers")} +

+
+ ) : ( +
+ {triggers.map((trigger) => ( +
+
+
+

+ {trigger.friendly_name || trigger.name} +

+
+
+ + {t(`triggers.type.${trigger.type}`)} + +
+ + +
+ {t("triggers.table.lastTriggered")}:{" "} + {trigger_status && + trigger_status.triggers[trigger.name] + ?.last_triggered + ? formatUnixTimestampToDateTime( + trigger_status.triggers[trigger.name] + ?.last_triggered, + { + timezone: config.ui.timezone, + date_format: + config.ui.time_format == "24hour" + ? t( + "time.formattedTimestamp2.24hour", + { + ns: "common", + }, + ) + : t( + "time.formattedTimestamp2.12hour", + { + ns: "common", + }, + ), + time_style: "medium", + date_style: "medium", + }, + ) + : "Never"} + + + + + + {t("details.item.button.viewInExplore", { + ns: "views/explore", + })} + + +
+ +
+
+ +
+ + + + + + +

{t("triggers.table.edit")}

+
+
+ + + + + +

{t("triggers.table.deleteTrigger")}

+
+
+
+
+
+ ))} +
+ )} +
+
+
+ + )}