Compare commits

...
4 Commits
Author SHA1 Message Date
Nicolas MowenandGitHub 6cd33fcde1 GenAI Chat Prompt Refinements (#23864)
* Prompt refactoring and optimization

* Update spec
2026-07-29 15:11:35 -05:00
Nicolas Mowen 846a38b056 Update to 0.19 2026-07-29 12:11:55 -06:00
Josh HawkinsandGitHub 7ed7ed56cf Miscellaneous fixes (0.18 beta) (#23854)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* fix watchdog process restarts reverting to the boot config

/api/config/set parses a new FrigateConfig and swaps the API and dispatcher onto it, but FrigateApp.config was never rebound, so the watchdog factories rebuilt a crashed process from the config as of startup. Fix is to read through a ConfigHolder that the swap updates.

* fix birdseye camera overrides being clobbered by a global mode change

A global birdseye save published only the global object, leaving the output process to infer which cameras were inheriting by comparing against the previous global mode. That cannot tell an inherited value from an explicit one that happens to match, so it overwrote the override until a restart. Publish the per-camera values the config parse already resolved instead.

* Reject non-finite numbers in GenAI review descriptions

A model returning NaN for confidence or potential_threat_level slipped through the model_construct fallback, which skips validation, and was written into the review segment's JSON data. NaN is not valid JSON, so every subsequent /review request failed with "Out of range float values are not JSON compliant", blanking the review page for any time range containing the poisoned row.

* restore fused DetectionOutput in the OpenVINO SSD model conversion

* fix rgb swap issue for face dataset testing script
2026-07-29 08:39:01 -06:00
860772f9f4 Miscellaneous fixes (0.18 beta) (#23828)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* widen the logger name field in the per-process log level settings

* add details to timestamp error faq

* tweak genai docs

* tweak vector language

* Combine Qwen3.5 and Qwen3.6 listings

* fix openvino yolox detector crashing on every detection

The intermediate (N, 7) array in the yolox branch shadowed the pre-allocated (20, 6) detections buffer, so writing a detection into it raised "could not broadcast input array from shape (6,) into shape (7,)" on the first frame with anything above the confidence threshold. An empty frame also returned a (0, 7) array instead of the (20, 6) buffer.

Regressed in #13794, which renamed the intermediate from dets to detections as part of a cspell cleanup. Broken since 0.15.0.

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-07-28 11:02:15 -06:00
25 changed files with 903 additions and 199 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
default_target: local
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
VERSION = 0.18.0
VERSION = 0.19.0
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
BOARDS= #Initialized empty
+83 -19
View File
@@ -1,10 +1,14 @@
"""Convert the default SSDLite MobileNet v2 model to OpenVINO IR.
Replaces the legacy openvino-dev Model Optimizer conversion. The TensorFlow
frontend converts the Object Detection API frozen graph natively; the four TF
outputs are then repacked into the single [1, 1, 100, 7] DetectionOutput-style
tensor that Frigate's OpenVINO detector expects, and the input is flipped to
BGR to match the legacy reverse_input_channels behavior.
frontend translates the Object Detection API pre and post processors literally,
producing per-class NonMaxSuppression, NonZero ops and map loops with data
dependent shapes that the GPU plugin handles very badly. Both are cut out the
way ssd_v2_support.json used to do it: the preprocessor is an identity at the
native 300x300 input, and the postprocessor becomes a single fused
DetectionOutput. The result is the [1, 1, 100, 7] tensor that Frigate's
OpenVINO detector expects, with the input flipped to BGR to match the legacy
reverse_input_channels behavior.
"""
import numpy as np
@@ -12,31 +16,91 @@ import openvino as ov
from openvino import opset8 as ops
from openvino.preprocess import PrePostProcessor
MODEL_DIR = "/models/ssdlite_mobilenet_v2_coco_2018_05_09"
OUTPUT_PATH = "/models/ssdlite_mobilenet_v2.xml"
INPUT_SHAPE = [1, 300, 300, 3]
# faster_rcnn_box_coder divides the deltas by pipeline.config's y/x/height/width
# scales of 10/10/5/5, which DetectionOutput expresses as per-prior variances.
BOX_VARIANCES = np.float32([0.1, 0.1, 0.2, 0.2])
model = ov.convert_model(
"/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb",
input=[("image_tensor:0", [1, 300, 300, 3])],
f"{MODEL_DIR}/frozen_inference_graph.pb",
input=[("image_tensor:0", INPUT_SHAPE)],
)
# rows of (image_id, class_id, score, xmin, ymin, xmax, ymax)
boxes = model.output("detection_boxes:0").get_node().input_value(0)
classes = model.output("detection_classes:0").get_node().input_value(0)
scores = model.output("detection_scores:0").get_node().input_value(0)
nodes = {op.get_friendly_name(): op for op in model.get_ordered_ops()}
parameter = model.get_parameters()[0]
# (ymin,xmin,ymax,xmax) -> (xmin,ymin,xmax,ymax)
boxes = ops.gather(boxes, [1, 0, 3, 2], 2)
classes = ops.unsqueeze(classes, 2)
scores = ops.unsqueeze(scores, 2)
image_id = ops.multiply(scores, np.float32(0.0))
preprocessor = nodes["Preprocessor/map/TensorArrayStack/TensorArrayGatherV3"]
box_deltas = nodes["Postprocessor/Reshape_1"].output(0)
class_scores = nodes["Postprocessor/convert_scores"].output(0)
anchors_output = nodes["Postprocessor/Reshape"].output(0)
detections = ops.concat([image_id, classes, scores, boxes], 2)
detections = ops.unsqueeze(detections, 1)
# The anchors only depend on the static input shape, so fold them into a
# constant and drop the generator subgraph with the rest of the postprocessor.
probe = ov.Core().compile_model(
ov.Model([anchors_output, preprocessor.output(0)], [parameter], "probe"), "CPU"
)
probe_input = np.random.default_rng(0).integers(0, 255, INPUT_SHAPE, dtype=np.uint8)
anchors, resized = (out.copy() for out in probe([probe_input]).values())
assert np.allclose(resized, probe_input, atol=1e-3), (
"preprocessor is not an identity at 300x300, it cannot be bypassed"
)
image = ops.convert(parameter, "f32")
for consumer in list(preprocessor.output(0).get_target_inputs()):
consumer.replace_source_output(image.output(0))
# (ymin, xmin, ymax, xmax) -> (xmin, ymin, xmax, ymax)
priors = anchors[:, [1, 0, 3, 2]].astype(np.float32).reshape(-1)
variances = np.tile(BOX_VARIANCES, len(anchors))
proposals = ops.constant(np.stack([priors, variances])[np.newaxis])
# (ty, tx, th, tw) -> (dx, dy, dw, dh) for the CENTER_SIZE decode
box_logits = ops.reshape(ops.gather(box_deltas, [1, 0, 3, 2], 1), [1, -1], False)
class_preds = ops.reshape(class_scores, [1, -1], False)
detections = ops.detection_output(
box_logits,
class_preds,
proposals,
{
"background_label_id": 0,
"top_k": 100,
"keep_top_k": [100],
"nms_threshold": 0.6,
"confidence_threshold": 0.3,
"code_type": "caffe.PriorBoxParameter.CENTER_SIZE",
"share_location": True,
"variance_encoded_in_target": False,
"normalized": True,
"clip_before_nms": False,
"clip_after_nms": True,
"decrease_label_id": False,
},
)
detections.output(0).get_tensor().set_names({"detection_out"})
model = ov.Model([detections], model.get_parameters(), "ssdlite_mobilenet_v2")
model = ov.Model([detections], [parameter], "ssdlite_mobilenet_v2")
ppp = PrePostProcessor(model)
ppp.input().tensor().set_layout(ov.Layout("NHWC"))
ppp.input().preprocess().reverse_channels()
model = ppp.build()
ov.save_model(model, "/models/ssdlite_mobilenet_v2.xml", compress_to_fp16=True)
# Fail the build rather than silently ship the dynamically shaped graph again.
op_types = [op.get_type_name() for op in model.get_ordered_ops()]
assert op_types.count("DetectionOutput") == 1, "postprocessor was not fused"
for dynamic_op in ("NonMaxSuppression", "NonZero", "Loop", "TensorIterator"):
assert dynamic_op not in op_types, f"{dynamic_op} left in the graph"
output_shape = model.outputs[0].get_partial_shape()
assert output_shape.is_static and list(output_shape) == [1, 1, 100, 7], (
f"unexpected detector output shape {output_shape}"
)
ov.save_model(model, OUTPUT_PATH, compress_to_fp16=True)
+103 -3
View File
@@ -6,6 +6,7 @@ title: Configuring Generative AI
import ConfigTabs from "@site/src/components/ConfigTabs";
import TabItem from "@theme/TabItem";
import NavPath from "@site/src/components/NavPath";
import FaqItem from "@site/src/components/FaqItem";
## Configuration
@@ -13,6 +14,18 @@ A Generative AI provider can be configured in the global config, which will make
`genai` is a map of named providers. Each key under `genai` is a name you choose, and its value is that provider's settings:
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Enrichments > Generative AI" />.
- Click **Add** and enter a **Provider name**. Any name of letters, numbers, hyphens, and underscores is accepted, but it cannot be changed from the UI after the provider is created.
- Set **Provider** to the service you are using (e.g., `ollama`)
- Set **Base URL**, **API key**, and **Model** as required by that provider
- Set **Roles** to the roles this provider should handle.
</TabItem>
<TabItem value="yaml">
```yaml
genai:
my_provider: # any name you like
@@ -25,6 +38,9 @@ genai:
- chat
```
</TabItem>
</ConfigTabs>
The examples on this page all use `my_provider`, but the name is arbitrary and is only used to reference the provider elsewhere in the config (for example, `semantic_search.model`).
Each provider handles one or more **roles**: `chat`, `descriptions`, and `embeddings`. A provider handles all three by default, and each role may be assigned to exactly one provider. Define a single provider if you want it to do everything, or split the roles across several providers using the `roles` option.
@@ -43,15 +59,20 @@ Running Generative AI models on CPU is not recommended, as high inference times
### Recommended Local Models
You must use a vision-capable model with Frigate. The following models are recommended for local deployment:
You must use a vision-capable model with Frigate. The following models are recommended for local deployment of the `descriptions` and `chat` roles:
| Model | Notes |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `qwen3-vl` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. |
| `qwen3.5` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
| `qwen3.6` | Strong situational understanding, similar to qwen3-vl |
| `qwen3.6` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
| `gemma4` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. |
The `embeddings` role needs a different kind of model. Text queries are matched against the stored image embeddings, so the model must be trained to place images and text into the same vector space. A chat or description model will still return vectors when asked, but those vectors are not trained for retrieval and text searches will return poor matches with no error to indicate why.
| Model | Notes |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `qwen3-vl-embedding` | Multimodal embeddings for [Semantic Search](/configuration/semantic_search#genai-provider). Must be served by llama.cpp started with `--embeddings` and `--mmproj`. |
:::info
Each model is available in multiple parameter sizes (3b, 4b, 8b, etc.). Larger sizes are more capable of complex tasks and understanding of situations, but requires more memory and computational resources. It is recommended to try multiple models and experiment to see which performs best.
@@ -416,3 +437,82 @@ genai:
</TabItem>
</ConfigTabs>
## FAQ
<FaqItem id="how-do-i-debug-genai-issues" question="How do I debug GenAI issues?">
Frigate's Generative AI features are configured and enabled separately. [Review descriptions and summaries](/configuration/genai/genai_review) live under `review.genai`, and [object descriptions](/configuration/genai/genai_objects) live under `objects.genai`. Configuring a provider on this page does not enable either feature, and enabling one does not enable the other. Decide which of the two is not working, then work through the steps below.
1. Confirm a provider is available and holds the `descriptions` role.
- Review descriptions, review summaries, and object descriptions all use the provider that has the `descriptions` role assigned in <NavPath path="Settings > Enrichments > Generative AI > Roles" /> (`genai.<provider>.roles`).
- A provider is contacted the first time one of its roles is actually used. A provider holding the `embeddings` role for semantic search is initialized during startup, while a `descriptions` provider is not initialized until the first description is requested, which may be well after boot.
- In <NavPath path="Settings > Enrichments > Generative AI" />, use **Refresh models** next to the model field. It queries the provider for its model list and is a quick way to verify that the base URL, API key, and network path between Frigate and your provider are correct.
2. Confirm the feature you expect is actually enabled.
- Object descriptions are disabled by default. Turn on <NavPath path="Settings > Global configuration > Objects > GenAI object config > Enable GenAI" /> (`objects.genai.enabled`), either globally or per camera. This is the most common reason custom prompts appear to be ignored while review summaries are still being generated.
- Review descriptions are disabled by default. Turn on <NavPath path="Settings > Global configuration > Review > GenAI config > Enable GenAI descriptions" /> (`review.genai.enabled`). Once enabled, alerts are described by default but detections are not, so a detection-only review item will never get a summary unless **Enable GenAI for detections** (`review.genai.detections`) is also on.
3. If object descriptions are never requested, check the filters that skip generation.
- <NavPath path="Settings > Global configuration > Objects > GenAI object config > GenAI objects" /> (`objects.genai.objects`) limits generation to specific labels, and **Required zones** (`objects.genai.required_zones`) requires the object to have entered one of those zones. If either is set and does not match, Frigate skips the request silently.
- Thumbnails are only collected while an object is moving. Objects that go stationary early contribute fewer frames.
- **Use snapshots** (`objects.genai.use_snapshot`) requires snapshots to be enabled for the camera. If the snapshot cannot be read, Frigate logs `Cannot load snapshot for <id>, file not found` and no description is generated.
- **Send on end** (`objects.genai.send_triggers.tracked_object_end`) is on by default. If you have turned it off in favor of **Early GenAI trigger** (`objects.genai.send_triggers.after_significant_updates`), descriptions are only requested once that number of updates is reached.
4. Enable debug logs to see exactly what Frigate is doing. Restart Frigate after this change. The next step also requires a restart, so turn both on at the same time to avoid restarting twice.
```yaml
logger:
default: info
logs:
# highlight-start
frigate.genai: debug
frigate.data_processing.post.object_descriptions: debug
frigate.data_processing.post.review_descriptions: debug
# highlight-end
```
5. Save the exact images and prompts that were sent to your provider.
- Turn on **Save thumbnails** for the feature you are debugging (`review.genai.debug_save_thumbnails` or `objects.genai.debug_save_thumbnails`). Both features write to `/media/frigate/clips/genai-requests/`, and these files are admin-only.
- Review descriptions write `genai-requests/<review_id>/` containing the numbered frames that were sent, plus `prompt.txt` and `response.txt` with the exact prompt and the raw, unparsed model response.
- Review summary reports write `genai-requests/<start_ts>-<end_ts>/prompt.txt` and `response.txt`. No images are involved, since a report summarizes existing review descriptions.
- Object descriptions write `genai-requests/<event_id>/` containing the numbered thumbnails. The prompt for object descriptions is not written to a file, it is only visible in the debug logs from step 4.
- Look at the saved images before blaming the model. If the object is small, blurry, or out of frame, no prompt will fix the result. For object descriptions, consider turning on **Use snapshots** (`objects.genai.use_snapshot`) to send a higher quality image. For review items, consider setting **Review image source** (`review.genai.image_source`) to `recordings` for 480p frames instead of the lower resolution preview frames.
<ConfigTabs>
<TabItem value="ui">
For review descriptions, navigate to <NavPath path="Settings > Global configuration > Review" /> and set **GenAI config > Save thumbnails** to on.
For object descriptions, navigate to <NavPath path="Settings > Global configuration > Objects" />, expand **GenAI object config**, and set **Save thumbnails** to on.
</TabItem>
<TabItem value="yaml">
```yaml
review:
genai:
enabled: true
# highlight-next-line
debug_save_thumbnails: true
objects:
genai:
enabled: true
# highlight-next-line
debug_save_thumbnails: true
```
</TabItem>
</ConfigTabs>
6. Verify the prompt is what you think it is.
- Object description prompts are the ones you control directly. A camera-level <NavPath path="Settings > Camera configuration > Objects > GenAI object config > Caption prompt" /> (`objects.genai.prompt`) overrides the global one, and an entry in **Object prompts** (`objects.genai.object_prompts`) for a label overrides both for that label. Only `{label}`, `{sub_label}`, and `{camera}` are substituted.
- Review description prompts are built by Frigate and request a structured JSON response, so they are not fully replaceable. The parts you control are <NavPath path="Settings > Global configuration > Review > GenAI config > Activity context prompt" /> (`review.genai.activity_context_prompt`) and **Additional concerns** (`review.genai.additional_concerns`). Keep the activity context prompt general, since overly specific rules will sway the model's threat level scoring.
7. If descriptions are generated but the results are poor or inconsistent, look at the model and the context window.
- Empty fields, missing `shortSummary` values, or `Failed to parse review description` errors usually mean the model is not following the requested JSON schema. Smaller models struggle with structured output. Try a larger parameter size or one of the [recommended models](#recommended-local-models).
- Frigate calculates how many frames to send from the context size the provider reports. If your server reports a different value than it is actually running with, frames will be truncated or the request will fail. Pin the value by adding `context_size` under <NavPath path="Settings > Enrichments > Generative AI > Provider options" /> (`genai.<provider>.provider_options`), and for Ollama also confirm `options.num_ctx` there matches the context you have configured.
- Check **Review Description Speed** and **Object Description Speed** in <NavPath path="System metrics > Enrichments" />. If inference takes tens of seconds, requests will queue behind each other and descriptions will appear to stop. For Ollama, review `OLLAMA_NUM_PARALLEL`, `OLLAMA_MAX_QUEUE`, and `OLLAMA_MAX_LOADED_MODELS` so that concurrent requests from Frigate are handled the way you expect.
</FaqItem>
+4
View File
@@ -113,3 +113,7 @@ Many providers also have a public facing chat interface for their models. Downlo
- OpenAI - [ChatGPT](https://chatgpt.com)
- Gemini - [Google AI Studio](https://aistudio.google.com)
- Ollama - [Open WebUI](https://docs.openwebui.com/)
## Troubleshooting
If descriptions are not being generated, or the generated descriptions are not what you expect, see [How do I debug GenAI issues?](/configuration/genai/genai_config#how-do-i-debug-genai-issues).
@@ -201,3 +201,7 @@ Along with individual review item summaries, Generative AI can also produce a si
Review reports can be requested via the [API](/integrations/api/generate-review-summary-review-summarize-start-start-ts-end-end-ts-post) by sending a POST request to `/api/review/summarize/start/{start_ts}/end/{end_ts}` with Unix timestamps.
For Home Assistant users, there is a built-in service (`frigate.review_summarize`) that makes it easy to request review reports as part of automations or scripts. This allows you to automatically generate daily summaries, vacation reports, or custom time period reports based on your specific needs.
## Troubleshooting
If summaries are not being generated, or the generated summaries are not what you expect, see [How do I debug GenAI issues?](/configuration/genai/genai_config#how-do-i-debug-genai-issues).
+7 -3
View File
@@ -34,11 +34,15 @@ The detect FFmpeg process exited on its own. This message is only the notificati
</FaqItem>
<FaqItem id="non-monotonically-increasing-dts" question="Application provided invalid, non monotonically increasing dts to muxer">
<FaqItem id="non-monotonically-increasing-dts" question="Non-monotonic DTS / non monotonically increasing dts to muxer / Queue input is backward in time">
An FFmpeg message meaning the camera sent packets with out-of-order timestamps. Because recordings are copied without re-encoding, FFmpeg cannot fix them, and the segment muxer often splits early, producing one-second segments and a cache backlog. The usual cause is a camera "Smart Codec" / H.264+ / H.265+ mode or a camera clock that jumps.
These are FFmpeg messages indicating the camera sent packets with out-of-order timestamps, either on the video or the audio stream. Timestamp jitter like this is common with WiFi cameras and restreamed or proxied sources; other causes are a camera "Smart Codec" / H.264+ / H.265+ mode or a camera clock that jumps. A sustained flood of these messages usually precedes the stream stalling and the watchdog restarting FFmpeg.
See [Recordings: segments are only 1 second long](/troubleshooting/recordings#segments-are-only-1-second-long).
In most cases, the fix is to improve the network, reduce system resource usage, or switch to non-WiFi cameras. In general, WiFi cameras are [not recommended](https://ipcamtalk.com/threads/multiple-cameras-high-bandwidth.77100/#post-861110).
On the video stream, this can affect recordings: because they are copied without re-encoding, FFmpeg cannot fix the timestamps, and the segment muxer often splits early, producing one-second segments and a cache backlog. See [Recordings: segments are only 1 second long](/troubleshooting/recordings#segments-are-only-1-second-long).
On the audio stream, the messages can come from the output's audio encoding. If the audio stream is the problem, it may help to have go2rtc transcode it by adding `#audio=aac` to the camera's go2rtc stream to produce clean timestamps for everything consuming the restream.
</FaqItem>
+38
View File
@@ -2872,6 +2872,44 @@ paths:
- frigateUserAuth: []
x-required-role: any
description: '**Access:** Any authenticated user.'
/categorized_object_names:
get:
tags:
- App
summary: Get known object names by object type
description: |-
**Access:** Any authenticated user.
Returns the sub labels and attributes this install can attach,
grouped by object type. Unlike /sub_labels, which reflects what has already been
detected, this reads the config and model files, so it covers recognized face
names, named license plates, custom object classification categories, and the
detector attributes of tracked objects.
operationId: categorized_object_names_categorized_object_names_get
parameters:
- name: object_type
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: Object Type
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateUserAuth: []
x-required-role: any
/audio_labels:
get:
tags:
+38 -1
View File
@@ -31,7 +31,10 @@ from frigate.api.auth import (
get_allowed_cameras_for_filter,
require_role,
)
from frigate.api.config_util import swap_runtime_config
from frigate.api.config_util import (
publish_camera_section_updates,
swap_runtime_config,
)
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.defs.request.app_body import (
AppConfigSetBody,
@@ -68,6 +71,7 @@ from frigate.util.config import (
find_config_file,
redact_credential,
)
from frigate.util.object_names import get_categorized_object_names
from frigate.util.schema import get_config_schema
from frigate.util.services import (
get_nvidia_driver_info,
@@ -963,6 +967,17 @@ def config_set(request: Request, body: AppConfigSetBody):
body.update_topic, settings
)
# a config/cameras/* topic publishes camera copies, a
# global topic the global object. FrigateConfig.parse
# folds some global sections down into every camera,
# and workers read both objects, so any such section
# needs its camera copies sent alongside the global
# publish above.
if body.update_topic == "config/birdseye":
publish_camera_section_updates(
request.app, config, CameraConfigUpdateEnum.birdseye
)
return JSONResponse(
content=(
{
@@ -1299,6 +1314,28 @@ def get_sub_labels(
return JSONResponse(content=sub_labels)
@router.get(
"/categorized_object_names",
dependencies=[Depends(allow_any_authenticated())],
summary="Get known object names by object type",
description="""Returns the sub labels and attributes this install can attach,
grouped by object type. Unlike /sub_labels, which reflects what has already been
detected, this reads the config and model files, so it covers recognized face
names, named license plates, custom object classification categories, and the
detector attributes of tracked objects.""",
)
def categorized_object_names(
request: Request,
object_type: str | None = None,
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
return JSONResponse(
content=get_categorized_object_names(
request.app.frigate_config, allowed_cameras, object_type
)
)
@router.get("/audio_labels", dependencies=[Depends(allow_any_authenticated())])
def get_audio_labels():
labels = load_labels("/audio-labelmap.txt", prefill=521)
+1
View File
@@ -83,6 +83,7 @@ def require_admin_by_default():
"/nvinfo",
"/labels",
"/sub_labels",
"/categorized_object_names",
"/plus/models",
"/recognized_license_plates",
"/timeline",
+37 -2
View File
@@ -50,6 +50,7 @@ from frigate.jobs.vlm_watch import (
stop_vlm_watch_job,
)
from frigate.models import Event
from frigate.util.object_names import get_categorized_object_names
logger = logging.getLogger(__name__)
@@ -539,6 +540,13 @@ async def execute_tool(
if tool_name == "search_objects":
return await _execute_search_objects(request, arguments, allowed_cameras)
if tool_name == "get_categorized_object_names":
return JSONResponse(
content=_execute_get_categorized_object_names(
request, arguments, allowed_cameras
)
)
if tool_name == "find_similar_objects":
result = await _execute_find_similar_objects(
request, arguments, allowed_cameras
@@ -717,6 +725,29 @@ async def _execute_set_camera_state(
return {"success": True, "camera": camera, "feature": feature, "value": value}
def _execute_get_categorized_object_names(
request: Request,
arguments: dict[str, Any],
allowed_cameras: list[str],
) -> dict[str, Any]:
object_type = arguments.get("object_type") or None
names = get_categorized_object_names(
request.app.frigate_config, allowed_cameras, object_type
)
if not names:
return {
"names": {},
"message": (
f"No names configured for '{object_type}'."
if object_type
else "No names configured; search by label or semantic_query."
),
}
return {"names": names}
async def _execute_tool_internal(
tool_name: str,
arguments: dict[str, Any],
@@ -741,6 +772,10 @@ async def _execute_tool_internal(
except (json.JSONDecodeError, AttributeError) as e:
logger.warning(f"Failed to extract tool result: {e}")
return {"error": "Failed to parse tool result"}
elif tool_name == "get_categorized_object_names":
return _execute_get_categorized_object_names(
request, arguments, allowed_cameras
)
elif tool_name == "find_similar_objects":
return await _execute_find_similar_objects(request, arguments, allowed_cameras)
elif tool_name == "set_camera_state":
@@ -773,8 +808,8 @@ async def _execute_tool_internal(
else:
logger.error(
"Tool call failed: unknown tool %r. Expected one of: search_objects, find_similar_objects, "
"get_live_context, start_camera_watch, stop_camera_watch, get_profile_status, get_recap. "
"Arguments received: %s",
"get_categorized_object_names, get_live_context, start_camera_watch, stop_camera_watch, "
"get_profile_status, get_recap. Arguments received: %s",
tool_name,
json.dumps(arguments),
)
+28
View File
@@ -3,6 +3,30 @@
from fastapi import FastAPI
from frigate.config import FrigateConfig
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
def publish_camera_section_updates(
app: FastAPI, config: FrigateConfig, update_type: CameraConfigUpdateEnum
) -> None:
"""Broadcast every camera's re-resolved value for a global section.
Global sections are folded into each camera at parse time and the camera
copies are what workers read, so send them rather than leave a worker to
guess which cameras were inheriting.
"""
for camera_name, camera_config in config.cameras.items():
settings = getattr(camera_config, update_type.name, None)
if settings is None:
continue
app.config_publisher.publish_update(
CameraConfigUpdateTopic(update_type, camera_name), settings
)
def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
@@ -16,6 +40,10 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
camera the user turned off would silently come back on.
"""
app.frigate_config = config
if app.config_holder is not None:
app.config_holder.set(config)
app.genai_manager.update_config(config)
if app.profile_manager is not None:
+3
View File
@@ -35,6 +35,7 @@ from frigate.comms.event_metadata_updater import (
)
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.config.holder import ConfigHolder
from frigate.config.profile_manager import ProfileManager
from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog
from frigate.embeddings import EmbeddingsContext
@@ -74,6 +75,7 @@ def create_fastapi_app(
dispatcher: Dispatcher | None = None,
profile_manager: ProfileManager | None = None,
enforce_default_admin: bool = True,
config_holder: ConfigHolder | None = None,
):
logger.info("Starting FastAPI app")
app = FastAPI(
@@ -162,6 +164,7 @@ def create_fastapi_app(
app.replay_manager = replay_manager
app.dispatcher = dispatcher
app.profile_manager = profile_manager
app.config_holder = config_holder
if frigate_config.auth.enabled:
secret = get_jwt_secret()
+14 -1
View File
@@ -30,6 +30,7 @@ from frigate.comms.ws import WebSocketClient
from frigate.comms.zmq_proxy import ZmqProxy
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.config.config import FrigateConfig
from frigate.config.holder import ConfigHolder
from frigate.config.profile_manager import ProfileManager
from frigate.const import (
CACHE_DIR,
@@ -122,7 +123,18 @@ class FrigateApp:
self.processes: dict[str, int] = {}
self.embeddings: EmbeddingsContext | None = None
self.profile_manager: ProfileManager | None = None
self.config = config
self.config_holder = ConfigHolder(config)
@property
def config(self) -> FrigateConfig:
"""The current config, not the one Frigate booted with.
Read through the holder so the deferred watchdog factories below build
a replacement process from the config as it is now. There is no setter
on purpose: a plain attribute would let a caller pin this back to a
single object and reintroduce the staleness.
"""
return self.config_holder.config
def ensure_dirs(self) -> None:
dirs = [
@@ -645,6 +657,7 @@ class FrigateApp:
self.replay_manager,
self.dispatcher,
self.profile_manager,
config_holder=self.config_holder,
),
host="127.0.0.1",
port=5001,
+34
View File
@@ -0,0 +1,34 @@
"""Shared handle on the config object that is current for this instance."""
from .config import FrigateConfig
__all__ = ["ConfigHolder"]
class ConfigHolder:
"""Indirection for the most recently parsed config.
/api/config/set re-parses yaml into a brand new FrigateConfig instead of
mutating the old one, so any reference captured during startup goes stale
the first time a user saves. Anything that has to build something after
startup, most importantly the watchdog factories that rebuild a crashed
process, must read through a holder rather than close over a config
object, or the rebuilt process comes back with the config as it was at
boot and silently discards every change made since.
There is deliberately no setter on the read side: the swap runs in exactly
one place (frigate.api.config_util.swap_runtime_config) and everyone else
only reads.
"""
def __init__(self, config: FrigateConfig) -> None:
self._config = config
@property
def config(self) -> FrigateConfig:
"""The config as of the most recent successful save."""
return self._config
def set(self, config: FrigateConfig) -> None:
"""Install a freshly parsed config as the current one."""
self._config = config
+3 -3
View File
@@ -226,12 +226,12 @@ class OvDetector(DetectionApi):
conf_mask = (image_pred[:, 4] * class_conf.squeeze() >= 0.3).squeeze()
# Detections ordered as (x1, y1, x2, y2, obj_conf, class_conf, class_pred)
detections = np.concatenate(
predictions = np.concatenate(
(image_pred[:, :5], class_conf, class_pred), axis=1
)
detections = detections[conf_mask]
predictions = predictions[conf_mask]
ordered = detections[detections[:, 5].argsort()[::-1]][:20]
ordered = predictions[predictions[:, 5].argsort()[::-1]][:20]
for i, object_detected in enumerate(ordered):
detections[i] = self.process_yolo(
+10
View File
@@ -23,6 +23,7 @@ from frigate.genai.prompts import (
build_review_summary_prompt,
)
from frigate.models import Event
from frigate.util.builtin import has_non_finite_number
logger = logging.getLogger(__name__)
@@ -164,6 +165,15 @@ class GenAIClient:
except json.JSONDecodeError as je:
logger.error("Failed to parse review description JSON: %s", je)
return None
# model_construct skips validation, so non-finite numbers that
# the validated path would have rejected have to be caught here
if has_non_finite_number(raw):
logger.error(
"Discarding review description containing non-finite numbers."
)
return None
# observations and confidence are required on the model; fill an empty default
# if the response omitted it so attribute access stays safe.
raw.setdefault("observations", [])
+68 -120
View File
@@ -262,6 +262,10 @@ def get_tool_definitions(
`attribute` parameter is exposed for filtering by their labels. When the
embeddings model only understands English (JinaV1), the `semantic_query`
description instructs the model to write the query in English.
Descriptions here stay mechanical: which tool to reach for, and how the
filters relate to each other, is stated once in the system prompt so the
guidance is not paid for twice on every request.
"""
search_objects_properties: dict[str, Any] = {
"camera": {
@@ -270,26 +274,13 @@ def get_tool_definitions(
},
"label": {
"type": "string",
"description": (
"Generic object class to filter by — one of the tracked detector "
"labels such as 'person', 'package', 'car', 'dog', 'bird'. Use "
"this for broad queries like 'show me all cars today'. Combine "
"with semantic_query when the user also describes appearance or "
"behavior (e.g. label='person', semantic_query='riding a lawn "
"mower')."
),
"description": "Tracked object class to filter by.",
},
"sub_label": {
"type": "string",
"description": (
"Filter by a DISCRETE NAMED entity recognized in the detection. "
"Use this for: a known person's name ('John'), a delivery "
"company ('Amazon', 'UPS'), a recognized animal species or "
"breed ('blue jay', 'cardinal', 'golden retriever'), or a "
"license plate string. When filtering by a specific name, set "
"only sub_label and leave label unset. Do NOT use sub_label "
"for descriptions of appearance, clothing, or actions — those "
"belong in semantic_query."
"Name recognized in the detection: a person, delivery company, "
"animal species or breed, or license plate."
),
},
"after": {
@@ -313,20 +304,11 @@ def get_tool_definitions(
}
if attribute_classifications:
model_outline = "; ".join(
f"{m['name']} (applies to {', '.join(m['objects']) or 'any object'})"
for m in attribute_classifications
)
search_objects_properties["attribute"] = {
"type": "string",
"description": (
"Filter by a classification attribute label produced by a "
"configured attribute classification model. Use this INSTEAD "
"of semantic_query when the user's request matches one of "
"these classifications. Configured models: "
f"{model_outline}. "
"Set the value to the attribute label that matches the user's "
"phrasing (case-sensitive)."
"Attribute label produced by a configured classification model "
"(case-sensitive)."
),
}
@@ -334,29 +316,12 @@ def get_tool_definitions(
search_objects_properties["semantic_query"] = {
"type": "string",
"description": (
"Optional natural-language description of a PHYSICAL "
"CHARACTERISTIC, APPEARANCE, or ACTIVITY the user mentioned, "
"used to semantically narrow results. Only set this when the "
"user describes something beyond what label and sub_label can "
"express on their own.\n"
"USE for descriptive phrases like: 'riding a lawn mower', "
"'wearing a red jacket', 'carrying a package', 'walking a "
"dog', 'on a bicycle', 'holding an umbrella'.\n"
"DO NOT USE for:\n"
"- specific named people, pets, or delivery companies → use sub_label\n"
"- animal species or breed names like 'blue jay', 'cardinal', "
"'golden retriever' → use sub_label\n"
"- license plate strings → use sub_label\n"
"- generic object queries like 'all cars today' or 'every "
"person' → use label alone with no semantic_query\n"
"When set, combine with label/time/camera/zone filters as "
"usual (e.g. label='person', semantic_query='riding a lawn "
"mower', after='2024-05-01T00:00:00Z')."
"Description of an appearance or activity, used to semantically "
"narrow results."
+ (
" The configured embeddings model only understands "
"English, so always write semantic_query in English, "
"translating the user's description if they phrased it "
"in another language."
" The configured embeddings model only understands English, so "
"always write this in English, translating the user's "
"description if they phrased it in another language."
if embeddings_language == "english"
else ""
)
@@ -364,26 +329,10 @@ def get_tool_definitions(
}
search_objects_description = (
"Search the historical record of detected objects in Frigate. "
"Use this ONLY for questions about the PAST — e.g. 'did anyone come by today?', "
"'when was the last car?', 'show me detections from yesterday'. "
"Do NOT use this for monitoring or alerting requests about future events — "
"use start_camera_watch instead for those. "
"An 'object' in Frigate represents a tracked detection (e.g., a person, package, car).\n\n"
"Choose filters based on what the user is asking for:\n"
"- Generic class query ('show me all cars today'): set `label` only.\n"
"- Specific NAMED entity (known person, delivery company, animal "
"species/breed like 'blue jay' or 'golden retriever', license "
"plate): set `sub_label` only and leave `label` unset.\n"
"Search the historical record of tracked detections. Use this ONLY for "
"questions about the PAST, e.g. 'did anyone come by today?', 'when was the "
"last car?'. For alerting on future events use start_camera_watch instead."
)
if semantic_search_enabled:
search_objects_description += (
"- Physical CHARACTERISTIC, APPEARANCE, or ACTIVITY that is not a "
"discrete name ('person riding a lawn mower', 'someone in a red "
"jacket', 'person carrying a package'): set `semantic_query` with "
"the descriptive phrase, optionally alongside `label` for the "
"object class. Do NOT put descriptive phrases in sub_label."
)
return [
{
@@ -398,20 +347,34 @@ def get_tool_definitions(
"required": [],
},
},
{
"type": "function",
"function": {
"name": "get_categorized_object_names",
"description": (
"Every name that can be attached as a sub_label, grouped by object "
"type: recognized faces, named license plates, classification "
"categories, and delivery logos."
),
"parameters": {
"type": "object",
"properties": {
"object_type": {
"type": "string",
"description": "Optional object label (e.g. 'person', 'car'). Omit for all.",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "find_similar_objects",
"description": (
"Find tracked objects that are visually and semantically similar "
"to a specific past event. Use this when the user references a "
"particular object they have seen and wants to find other "
"sightings of the same or similar one ('that green car', 'the "
"person in the red jacket', 'the package that was delivered'). "
"Prefer this over search_objects whenever the user's intent is "
"'find more like this specific one.' Use search_objects first "
"only if you need to locate the anchor event. Requires semantic "
"search to be enabled."
"Find tracked objects visually and semantically similar to a "
"specific past event. Requires semantic search to be enabled."
),
"parameters": {
"type": "object",
@@ -473,9 +436,8 @@ def get_tool_definitions(
"function": {
"name": "set_camera_state",
"description": (
"Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). "
"Use camera='*' to apply to all cameras at once. "
"Only call this tool when the user explicitly asks to change a camera setting. "
"Change a camera's feature state, e.g. turn detection on or off. "
"Only call this when the user explicitly asks to change a setting. "
"Requires admin privileges."
),
"parameters": {
@@ -517,7 +479,7 @@ def get_tool_definitions(
},
"value": {
"type": "string",
"description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.",
"description": "The value to set, as accepted by the chosen feature.",
},
},
"required": ["camera", "feature", "value"],
@@ -529,11 +491,9 @@ def get_tool_definitions(
"function": {
"name": "get_live_context",
"description": (
"Get the current live image and detection information for a single camera: objects being tracked, "
"zones, timestamps. Use this to understand what is visible in the live view. "
"Call this when answering questions about what is happening right now on a specific camera. "
"Operates on one camera at a time; call the tool again for each additional camera. "
"Wildcards and empty values are not accepted."
"Current live image and detections (tracked objects, zones, "
"timestamps) for one camera. Use this for questions about what is "
"happening right now. Call it again for each additional camera."
),
"parameters": {
"type": "object",
@@ -541,8 +501,8 @@ def get_tool_definitions(
"camera": {
"type": "string",
"description": (
"Exact name of a single camera to get live context for. "
"Wildcards (e.g. '*', 'all') and empty strings are not accepted."
"Exact name of a single camera. Wildcards (e.g. '*', "
"'all') and empty strings are not accepted."
),
},
},
@@ -555,10 +515,9 @@ def get_tool_definitions(
"function": {
"name": "start_camera_watch",
"description": (
"Start a continuous VLM watch job that monitors a camera and sends a notification "
"when a specified condition is met. Use this when the user wants to be alerted about "
"a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. "
"Only one watch job can run at a time. Returns a job ID."
"Start a continuous watch job that monitors a camera and notifies "
"the user when a condition is met, e.g. 'tell me when guests "
"arrive'. Only one watch job can run at a time. Returns a job ID."
),
"parameters": {
"type": "object",
@@ -598,10 +557,7 @@ def get_tool_definitions(
"type": "function",
"function": {
"name": "stop_camera_watch",
"description": (
"Cancel the currently running VLM watch job. Use this when the user wants to "
"stop a previously started watch, e.g. 'stop watching the front door'."
),
"description": "Cancel the currently running watch job.",
"parameters": {
"type": "object",
"properties": {},
@@ -614,11 +570,9 @@ def get_tool_definitions(
"function": {
"name": "get_profile_status",
"description": (
"Get the current profile status including the active profile and "
"timestamps of when each profile was last activated. Use this to "
"determine time periods for recap requests — e.g. when the user asks "
"'what happened while I was away?', call this first to find the relevant "
"time window based on profile activation history."
"Get the active profile and when each profile was last activated. "
"Call this before get_recap to derive the time window for requests "
"like 'what happened while I was away?'."
),
"parameters": {
"type": "object",
@@ -632,11 +586,9 @@ def get_tool_definitions(
"function": {
"name": "get_recap",
"description": (
"Get a recap of all activity (alerts and detections) for a given time period. "
"Use this after calling get_profile_status to retrieve what happened during "
"a specific window — e.g. 'what happened while I was away?'. Returns a "
"chronological list of activity with camera, objects, zones, and GenAI-generated "
"descriptions when available. Summarize the results for the user."
"Get all activity (alerts and detections) for a time period, as a "
"chronological list with camera, objects, zones, and descriptions "
"when available. Summarize the results for the user."
),
"parameters": {
"type": "object",
@@ -723,14 +675,13 @@ def build_chat_system_prompt(
)
speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}."
semantic_search_section = ""
filter_routing_section = (
"\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n"
"- Generic class ('show me all cars today'): set `label` only.\n"
"- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset. Call get_categorized_object_names first and use the exact spelling it returns; a guessed spelling matches nothing. If the name is absent, say it is not configured rather than searching for it."
)
if semantic_search_enabled:
semantic_search_section = (
"\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n"
"- Generic class ('show me all cars today'): set `label` only.\n"
"- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'cardinal', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset.\n"
"- Physical characteristic, appearance, or activity that is NOT a discrete name ('find me people riding a lawn mower', 'someone in a red jacket', 'a person carrying a package'): set `semantic_query` with the descriptive phrase, optionally combined with `label` for the object class. Never put descriptive phrases in `sub_label`."
)
filter_routing_section += "\n- Physical characteristic, appearance, or activity that is NOT a discrete name ('riding a lawn mower', 'someone in a red jacket'): set `semantic_query` with the descriptive phrase, optionally combined with `label`. Never put descriptive phrases in `sub_label`."
attribute_classification_section = ""
if attribute_classifications:
@@ -739,9 +690,9 @@ def build_chat_system_prompt(
for m in attribute_classifications
)
attribute_classification_section = (
"\n\nAttribute classification models are configured for the following object types:\n"
"\n\nConfigured attribute classification models:\n"
f"{model_lines}\n"
"When the user's request matches one of these classifications, set the search_objects `attribute` field to the matching label rather than using `semantic_query`. Reserve `semantic_query` for descriptive phrases that fall outside the configured attribute labels."
"When the user's request matches one of these classifications, set the search_objects `attribute` field to the matching label (case-sensitive) rather than using `semantic_query`. Reserve `semantic_query` for descriptive phrases outside the configured attribute labels."
)
return f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
@@ -750,9 +701,6 @@ Current server local date and time: {current_date_str} at {current_time_str}
Do not start your response with phrases like "I will check...", "Let me see...", or "Let me look...". Answer directly.
Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields.
When users ask about "today", "yesterday", "this week", etc., use the current date above as reference.
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
Always be accurate with time calculations based on the current date provided.
Always present times in the server's local timezone. When tool results include start_time_local and end_time_local, quote those strings exactly; never convert or invent timestamps, and fall back to UTC or ISO format only when a result has no local time fields. Resolve relative dates like "today" or "this week" against the current date above, and pass dates to tools in ISO 8601 (e.g. {current_date_str}T00:00:00Z for the start of today).
When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:<id>], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{semantic_search_section}{attribute_classification_section}{cameras_section}{speed_units_section}"""
When the user refers to a specific object they have seen ("that green car", "the person in the red jacket", "a package left today"), prefer find_similar_objects over search_objects, using search_objects only to locate the anchor event and passing its id along. Keep search_objects for generic queries like "show me all cars today". If a user message begins with [attached_event:<id>], treat that id as the anchor for any similarity or "tell me more" request in the same message.{filter_routing_section}{attribute_classification_section}{cameras_section}{speed_units_section}"""
+3 -6
View File
@@ -178,13 +178,10 @@ class OutputProcess(FrigateProcess):
)
if update_topic is not None and birdseye_config is not None:
previous_global_mode = self.config.birdseye.mode
# only the global-only fields are applied here; the per-camera
# enabled and mode arrive on config/cameras/<name>/birdseye,
# already resolved against yaml by the config parse
self.config.birdseye = birdseye_config
for camera_config in self.config.cameras.values():
if camera_config.birdseye.mode == previous_global_mode:
camera_config.birdseye.mode = birdseye_config.mode
logger.debug("Applied dynamic birdseye config update")
# check if there is an updated config
@@ -13,6 +13,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdatePublisher,
CameraConfigUpdateTopic,
)
from frigate.config.holder import ConfigHolder
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@@ -373,6 +374,128 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_global_birdseye_save_fans_out_resolved_camera_configs(
self, mock_find_config
):
"""A global birdseye save must also publish the per-camera values.
Global birdseye only seeds enabled and mode; the camera copies are what
the output process actually reads. Sending just the global object makes
a worker guess which cameras were inheriting, and the only available
guess (mode still equals the previous global) wrongly claims a camera
whose explicit yaml mode happens to match.
"""
self.minimal_config["birdseye"] = {"enabled": True, "mode": "motion"}
# explicit override that matches the global value being replaced
self.minimal_config["cameras"]["front_door"]["birdseye"] = {"mode": "motion"}
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, mock_publisher = self._create_app_with_publisher()
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"birdseye": {"mode": "continuous"}},
"update_topic": "config/birdseye",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
# the global object still goes out on its own topic
mock_publisher.publisher.publish.assert_called_once()
topic, settings = mock_publisher.publisher.publish.call_args[0]
self.assertEqual(topic, "config/birdseye")
self.assertEqual(settings.mode.value, "continuous")
published = {
call[0][0].camera: call[0][1]
for call in mock_publisher.publish_update.call_args_list
}
self.assertEqual(set(published), {"front_door", "back_yard"})
for call in mock_publisher.publish_update.call_args_list:
self.assertEqual(
call[0][0].update_type, CameraConfigUpdateEnum.birdseye
)
# the override survives, the inheriting camera follows global
self.assertEqual(published["front_door"].mode.value, "motion")
self.assertEqual(published["back_yard"].mode.value, "continuous")
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_save_updates_the_config_holder(self, mock_find_config):
"""A save must move the holder onto the freshly parsed config.
FrigateApp reads the holder when the watchdog rebuilds a crashed
process; if the save leaves it on the boot config, that process comes
back having lost every change made since Frigate started.
"""
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.api.fastapi_app import create_fastapi_app
config_path = self._write_config_file()
mock_find_config.return_value = config_path
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
mock_publisher.publisher = MagicMock()
boot_config = FrigateConfig(**self.minimal_config)
holder = ConfigHolder(boot_config)
try:
app = create_fastapi_app(
boot_config,
self.db,
None,
None,
None,
None,
None,
None,
mock_publisher,
None,
enforce_default_admin=False,
config_holder=holder,
)
async def mock_get_current_user(request: Request):
return {"username": "admin", "role": "admin"}
async def mock_get_allowed_cameras_for_filter(request: Request):
return list(self.minimal_config.get("cameras", {}).keys())
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"birdseye": {"inactivity_threshold": 5}},
"update_topic": "config/birdseye",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
self.assertIsNot(holder.config, boot_config)
self.assertIs(holder.config, app.frigate_config)
self.assertEqual(holder.config.birdseye.inactivity_threshold, 5)
finally:
os.unlink(config_path)
if __name__ == "__main__":
unittest.main()
+31
View File
@@ -4,6 +4,7 @@ import unittest
from unittest.mock import MagicMock
from frigate.api.config_util import swap_runtime_config
from frigate.config.holder import ConfigHolder
class TestSwapRuntimeConfig(unittest.TestCase):
@@ -12,6 +13,7 @@ class TestSwapRuntimeConfig(unittest.TestCase):
def _make_app(self) -> MagicMock:
app = MagicMock()
app.dispatcher.comms = [MagicMock(), MagicMock()]
app.config_holder = ConfigHolder(MagicMock(name="boot_config"))
return app
def test_rebinds_all_references(self) -> None:
@@ -37,11 +39,40 @@ class TestSwapRuntimeConfig(unittest.TestCase):
# the swap rebuilds cameras from yaml, so overrides must be re-layered
app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
def test_updates_the_config_holder(self) -> None:
app = self._make_app()
holder = app.config_holder
config = MagicMock(name="new_config")
swap_runtime_config(app, config)
self.assertIs(holder.config, config)
def test_deferred_factory_builds_from_the_swapped_config(self) -> None:
"""A watchdog-style factory must not rebuild from the boot config.
The factories in FrigateApp are lambdas evaluated when a process is
restarted, long after a user may have saved. Reading through the
holder is what keeps a rebuilt process from reverting every change
made since Frigate started.
"""
app = self._make_app()
holder = app.config_holder
boot_config = holder.config
factory = lambda: holder.config # noqa: E731
self.assertIs(factory(), boot_config)
config = MagicMock(name="new_config")
swap_runtime_config(app, config)
self.assertIs(factory(), config)
def test_tolerates_missing_optional_collaborators(self) -> None:
app = MagicMock()
app.profile_manager = None
app.stats_emitter = None
app.dispatcher = None
app.config_holder = None
config = MagicMock(name="new_config")
# must not raise when the optional collaborators are absent
+12
View File
@@ -472,6 +472,18 @@ def sanitize_float(value):
return value
def has_non_finite_number(value: Any) -> bool:
"""Return True if any number in a parsed JSON value is NaN or infinite."""
if isinstance(value, float):
return not math.isfinite(value)
if isinstance(value, dict):
return any(has_non_finite_number(v) for v in value.values())
if isinstance(value, list):
return any(has_non_finite_number(v) for v in value)
return False
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
return 1 - cosine_distance(a, b)
+209
View File
@@ -0,0 +1,209 @@
"""Aggregation of the known sub label names an object can be tagged with."""
import logging
import os
from pathvalidate import sanitize_filename
from frigate.config import FrigateConfig
from frigate.config.classification import ObjectClassificationType
from frigate.const import CLIPS_DIR, FACE_DIR, MODEL_CACHE_DIR
from frigate.util.builtin import load_labels
logger = logging.getLogger(__name__)
# subdirectory of FACE_DIR holding unassigned training images, not a face name
FACE_TRAIN_DIR = "train"
# category used by classification models for "no match", never attached to an object
CLASSIFICATION_NONE_CATEGORY = "none"
def get_categorized_object_names(
config: FrigateConfig,
allowed_cameras: list[str],
object_type: str | None = None,
) -> dict[str, list[str]]:
"""Collect every sub label name this install can attach, by object type.
Unlike the database-backed /sub_labels endpoint, this reads the config and
model files, so it also covers names that are configured but have not been
detected yet. Names come from the detector's logo attributes (limited to
objects the allowed cameras actually track), LPR known plate names,
registered face names, and custom object classification categories.
Structural attributes such as `face` and `license_plate` are excluded: they
describe a part of an object rather than naming it, and are never attached
as a sub label.
Args:
config: The running Frigate config
allowed_cameras: Cameras the requesting user may see
object_type: Optional object label to restrict the result to
Returns:
Mapping of object label to its known sub label names, sorted and
deduplicated. Object types with no known names are omitted.
"""
tracked_objects = _get_tracked_objects(config, allowed_cameras)
names: dict[str, set[str]] = {}
logos = set(config.model.all_attribute_logos)
# 1. detector logo attributes, only for objects that are actually tracked
for label, label_attributes in config.model.attributes_map.items():
if label not in tracked_objects:
continue
label_logos = logos.intersection(label_attributes)
if label_logos:
names.setdefault(label, set()).update(label_logos)
# 2. LPR known plate names, for objects that can carry a plate
if config.lpr.known_plates and _lpr_enabled(config, allowed_cameras):
known_plates = set(config.lpr.known_plates)
for label in _objects_with_attribute(config, tracked_objects, "license_plate"):
names.setdefault(label, set()).update(known_plates)
# 3. registered face names, for objects that can carry a face
if _face_recognition_enabled(config, allowed_cameras):
face_names = _get_face_names()
if face_names:
for label in _objects_with_attribute(config, tracked_objects, "face"):
names.setdefault(label, set()).update(face_names)
# 4. custom object classification categories
for model_key, model_config in config.classification.custom.items():
if not model_config.enabled or model_config.object_config is None:
continue
if (
model_config.object_config.classification_type
!= ObjectClassificationType.sub_label
):
continue
categories = _get_classification_categories(model_key)
if not categories:
continue
for label in model_config.object_config.objects:
names.setdefault(label, set()).update(categories)
return {
label: sorted(label_names)
for label, label_names in sorted(names.items())
if label_names and (object_type is None or label == object_type)
}
def _get_tracked_objects(config: FrigateConfig, allowed_cameras: list[str]) -> set[str]:
"""Get the union of objects tracked by the cameras the user can see."""
tracked: set[str] = set()
for camera_name in allowed_cameras:
camera_config = config.cameras.get(camera_name)
if camera_config is None:
continue
tracked.update(camera_config.objects.track)
return tracked
def _objects_with_attribute(
config: FrigateConfig, tracked_objects: set[str], attribute: str
) -> set[str]:
"""Get the tracked objects that a given attribute can be recognized on.
The attribute may also be tracked as an object in its own right, as
`license_plate` is on a dedicated LPR camera, in which case the name is
attached to that object directly.
"""
objects = {
label
for label, label_attributes in config.model.attributes_map.items()
if attribute in label_attributes and label in tracked_objects
}
if attribute in tracked_objects:
objects.add(attribute)
return objects
def _lpr_enabled(config: FrigateConfig, allowed_cameras: list[str]) -> bool:
return any(
config.cameras[camera_name].lpr.enabled
for camera_name in allowed_cameras
if camera_name in config.cameras
)
def _face_recognition_enabled(
config: FrigateConfig, allowed_cameras: list[str]
) -> bool:
return any(
config.cameras[camera_name].face_recognition.enabled
for camera_name in allowed_cameras
if camera_name in config.cameras
)
def _get_face_names() -> set[str]:
"""Get the names of every registered face collection."""
if not os.path.exists(FACE_DIR):
return set()
try:
entries = os.listdir(FACE_DIR)
except OSError:
logger.debug("Failed to read face directory %s", FACE_DIR)
return set()
return {
name
for name in entries
if name != FACE_TRAIN_DIR and os.path.isdir(os.path.join(FACE_DIR, name))
}
def _get_classification_categories(model_key: str) -> set[str]:
"""Get the categories a custom classification model can output.
The trained labelmap is authoritative, but it only exists once the model
has been trained, so fall back to the dataset directories that will become
the labelmap on the next training run.
"""
safe_key = sanitize_filename(model_key)
categories: set[str] = set()
labelmap_path = os.path.join(MODEL_CACHE_DIR, safe_key, "labelmap.txt")
if os.path.exists(labelmap_path):
try:
labelmap = load_labels(labelmap_path, prefill=0, indexed=False)
except OSError:
logger.debug("Failed to read labelmap %s", labelmap_path)
labelmap = {}
categories.update(label for label in labelmap.values() if label)
dataset_dir = os.path.join(CLIPS_DIR, safe_key, "dataset")
if os.path.exists(dataset_dir):
try:
entries = os.listdir(dataset_dir)
except OSError:
logger.debug("Failed to read dataset directory %s", dataset_dir)
entries = []
categories.update(
name for name in entries if os.path.isdir(os.path.join(dataset_dir, name))
)
categories.discard(CLASSIFICATION_NONE_CATEGORY)
return categories
+30 -38
View File
@@ -36,8 +36,8 @@ from __future__ import annotations
import argparse
import os
import sys
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Iterable
import cv2
import numpy as np
@@ -53,21 +53,31 @@ ARCFACE_INPUT_SIZE = 112
# ---------------------------------------------------------------------------
def _bgr_to_rgb(frame: np.ndarray) -> np.ndarray:
"""Mirror BaseEmbedding._bgr_to_rgb."""
if isinstance(frame, np.ndarray) and frame.ndim == 3:
return np.ascontiguousarray(frame[:, :, ::-1])
return frame
def _process_image_frigate(image: np.ndarray) -> Image.Image:
"""Mirror BaseEmbedding._process_image for an ndarray input.
NOTE: Frigate passes the output of `cv2.imread` (BGR) directly in. PIL's
`Image.fromarray` does NOT reorder channels, so the embedder effectively
receives a BGR-ordered tensor. We replicate that faithfully here. (Tested
— swapping to RGB produces near-identical embeddings; this model is
robust to channel order.)
`Image.fromarray` does not reorder channels, so whatever order it is
handed is what reaches the model. Callers swap to RGB first, exactly as
ArcfaceEmbedding._preprocess_inputs does.
"""
return Image.fromarray(image)
def arcface_preprocess(image_bgr: np.ndarray) -> np.ndarray:
"""Mirror ArcfaceEmbedding._preprocess_inputs."""
pil = _process_image_frigate(image_bgr)
"""Mirror ArcfaceEmbedding._preprocess_inputs.
Face crops arrive BGR from cv2 and #23712 added the swap to RGB before
embedding, so this script has to do it too.
"""
pil = _process_image_frigate(_bgr_to_rgb(image_bgr))
width, height = pil.size
if width != ARCFACE_INPUT_SIZE or height != ARCFACE_INPUT_SIZE:
@@ -138,9 +148,7 @@ class LandmarkAligner:
M[0, 2] += tX - eyesCenter[0]
M[1, 2] += tY - eyesCenter[1]
aligned = cv2.warpAffine(
image, M, (out_w, out_h), flags=cv2.INTER_CUBIC
)
aligned = cv2.warpAffine(image, M, (out_w, out_h), flags=cv2.INTER_CUBIC)
info = dict(
angle=float(angle),
eye_dist_px=dist,
@@ -433,9 +441,7 @@ def vector_outlier_test(
if neg
else np.array([])
)
baseline_conf_neg = np.array(
[similarity_to_confidence(c) for c in baseline_neg]
)
baseline_conf_neg = np.array([similarity_to_confidence(c) for c in baseline_neg])
print(
f"\nBaseline (trim_mean only, {len(pos)} images):"
@@ -465,9 +471,7 @@ def vector_outlier_test(
mean, keep = iterative_mean(all_embs, T)
pos_sims = np.array([cosine(p.embedding, mean) for p in pos])
neg_sims = (
np.array([cosine(n.embedding, mean) for n in neg])
if neg
else np.array([])
np.array([cosine(n.embedding, mean) for n in neg]) if neg else np.array([])
)
neg_conf = np.array([similarity_to_confidence(c) for c in neg_sims])
margin = pos_sims.min() - (neg_sims.max() if len(neg_sims) else 0)
@@ -483,9 +487,7 @@ def vector_outlier_test(
# Show which images get dropped at the shipped threshold + neighbors
for T_show in (0.25, 0.30, 0.33):
_, keep = iterative_mean(all_embs, T_show)
print(
f"\nAt T={T_show}, the {int((~keep).sum())} dropped positives are:"
)
print(f"\nAt T={T_show}, the {int((~keep).sum())} dropped positives are:")
final_mean = stats.trim_mean(all_embs[keep], base_trim, axis=0)
m_n = final_mean / (np.linalg.norm(final_mean) + 1e-9)
for i, (p, k) in enumerate(zip(pos, keep)):
@@ -501,9 +503,7 @@ def vector_outlier_test(
)
def degenerate_embedding_test(
pos: list[FaceSample], neg: list[FaceSample]
) -> None:
def degenerate_embedding_test(pos: list[FaceSample], neg: list[FaceSample]) -> None:
"""Detect whether negatives and low-quality positives share a degenerate
'tiny/noisy face' region of the embedding space.
@@ -533,8 +533,7 @@ def degenerate_embedding_test(
f"(how tightly negatives cluster together)"
)
print(
f" pos<->pos mean cos : {np.nanmean(pp):.3f} "
f"(how tightly positives cluster)"
f" pos<->pos mean cos : {np.nanmean(pp):.3f} (how tightly positives cluster)"
)
print(
f" pos<->neg mean cos : {pn.mean():.3f} "
@@ -558,11 +557,7 @@ def degenerate_embedding_test(
neg_scores = np.array([cosine(n.embedding, clean_mean) for n in neg])
neg_confs = np.array([similarity_to_confidence(c) for c in neg_scores])
pos_scores = np.array(
[
cosine(pos[i].embedding, clean_mean)
for i in range(len(pos))
if keep[i]
]
[cosine(pos[i].embedding, clean_mean) for i in range(len(pos)) if keep[i]]
)
print(
f"\n mean_intra >= {thresh}: keeping {int(keep.sum())}/{len(pos)} positives"
@@ -585,9 +580,7 @@ def degenerate_embedding_test(
)
def contamination_analysis(
pos: list[FaceSample], neg: list[FaceSample]
) -> None:
def contamination_analysis(pos: list[FaceSample], neg: list[FaceSample]) -> None:
"""Check whether the positive collection contains a second identity.
Two signals:
@@ -617,10 +610,7 @@ def contamination_analysis(
"\nPositives closer to a negative than to their own class avg"
"\n(these are candidates for mislabeled images):"
)
print(
f"\n{'max_neg':>7} {'mean_neg':>8} {'mean_intra':>10} "
f"{'delta':>6} name"
)
print(f"\n{'max_neg':>7} {'mean_neg':>8} {'mean_intra':>10} {'delta':>6} name")
rows = list(zip(pos_names, max_to_neg, mean_to_neg, mean_intra))
rows.sort(key=lambda r: -(r[1] - r[3]))
for nm, mxn, mnn, mi in rows[:15]:
@@ -704,7 +694,9 @@ def main() -> int:
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
ap.add_argument("--positive", required=True, help="Training folder for one identity")
ap.add_argument(
"--positive", required=True, help="Training folder for one identity"
)
ap.add_argument(
"--negative",
default=None,
@@ -14,6 +14,7 @@ const logger: SectionConfigOverrides = {
additionalProperties: {
"ui:options": {
enumI18nPrefix: "logger.logLevel",
additionalPropertyKeySize: "lg",
additionalPropertyKeyLabel:
"configForm.additionalProperties.loggerNameLabel",
additionalPropertyKeyPlaceholder:
@@ -15,6 +15,14 @@ import { useTranslation } from "react-i18next";
import { LuTrash2 } from "react-icons/lu";
import type { ConfigFormContext } from "@/types/configForm";
const KEY_SIZE_CLASSES = {
sm: { key: "md:col-span-2", value: "md:col-span-9" },
md: { key: "md:col-span-4", value: "md:col-span-7" },
lg: { key: "md:col-span-7", value: "md:col-span-4" },
} as const;
type AdditionalPropertyKeySize = keyof typeof KEY_SIZE_CLASSES;
export function WrapIfAdditionalTemplate<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
@@ -58,6 +66,14 @@ export function WrapIfAdditionalTemplate<
: undefined;
const preventKeyRename = uiOptions.preventKeyRename === true;
const keySize =
typeof uiOptions.additionalPropertyKeySize === "string" &&
uiOptions.additionalPropertyKeySize in KEY_SIZE_CLASSES
? (uiOptions.additionalPropertyKeySize as AdditionalPropertyKeySize)
: "sm";
const keySpanClass = KEY_SIZE_CLASSES[keySize].key;
const valueSpanClass = KEY_SIZE_CLASSES[keySize].value;
const formContext = registry?.formContext as ConfigFormContext | undefined;
// optionally, lock the key once it's been saved
@@ -126,7 +142,7 @@ export function WrapIfAdditionalTemplate<
style={style}
>
{!keyIsReadonly && (
<div className="col-span-12 space-y-2 md:col-span-2">
<div className={cn("col-span-12 space-y-2", keySpanClass)}>
{displayLabel && <Label htmlFor={keyId}>{keyLabel}</Label>}
{keyLocked ? (
<div
@@ -158,7 +174,7 @@ export function WrapIfAdditionalTemplate<
<div
className={cn(
"col-span-12 space-y-2",
!keyIsReadonly && "md:col-span-9",
!keyIsReadonly && valueSpanClass,
)}
>
{!keyIsReadonly && displayLabel && (