mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-29 15:19:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6184eaf0ef |
@@ -1,14 +1,10 @@
|
||||
"""Convert the default SSDLite MobileNet v2 model to OpenVINO IR.
|
||||
|
||||
Replaces the legacy openvino-dev Model Optimizer conversion. The TensorFlow
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
@@ -16,91 +12,31 @@ 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(
|
||||
f"{MODEL_DIR}/frozen_inference_graph.pb",
|
||||
input=[("image_tensor:0", INPUT_SHAPE)],
|
||||
"/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb",
|
||||
input=[("image_tensor:0", [1, 300, 300, 3])],
|
||||
)
|
||||
|
||||
nodes = {op.get_friendly_name(): op for op in model.get_ordered_ops()}
|
||||
parameter = model.get_parameters()[0]
|
||||
# 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)
|
||||
|
||||
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)
|
||||
# (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))
|
||||
|
||||
# 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 = ops.concat([image_id, classes, scores, boxes], 2)
|
||||
detections = ops.unsqueeze(detections, 1)
|
||||
detections.output(0).get_tensor().set_names({"detection_out"})
|
||||
|
||||
model = ov.Model([detections], [parameter], "ssdlite_mobilenet_v2")
|
||||
model = ov.Model([detections], model.get_parameters(), "ssdlite_mobilenet_v2")
|
||||
|
||||
ppp = PrePostProcessor(model)
|
||||
ppp.input().tensor().set_layout(ov.Layout("NHWC"))
|
||||
ppp.input().preprocess().reverse_channels()
|
||||
model = ppp.build()
|
||||
|
||||
# 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)
|
||||
ov.save_model(model, "/models/ssdlite_mobilenet_v2.xml", compress_to_fp16=True)
|
||||
|
||||
@@ -14,5 +14,5 @@ nvidia-cusparse-cu12==12.5.8.93; platform_machine == 'x86_64'
|
||||
nvidia-nccl-cu12==2.26.2.post1; platform_machine == 'x86_64'
|
||||
nvidia-nvjitlink-cu12==12.8.93; platform_machine == 'x86_64'
|
||||
onnx==1.16.*; platform_machine == 'x86_64'
|
||||
onnxruntime-gpu==1.24.*; platform_machine == 'x86_64'
|
||||
onnxruntime-gpu==1.28.*; platform_machine == 'x86_64'
|
||||
protobuf==3.20.3; platform_machine == 'x86_64'
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
|
||||
@@ -14,18 +13,6 @@ 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
|
||||
@@ -38,9 +25,6 @@ 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.
|
||||
@@ -59,20 +43,15 @@ 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 of the `descriptions` and `chat` roles:
|
||||
You must use a vision-capable model with Frigate. The following models are recommended for local deployment:
|
||||
|
||||
| Model | Notes |
|
||||
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `qwen3-vl` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. |
|
||||
| `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. |
|
||||
| `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 |
|
||||
| `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.
|
||||
@@ -437,82 +416,3 @@ 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>
|
||||
|
||||
@@ -113,7 +113,3 @@ 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,7 +201,3 @@ 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).
|
||||
|
||||
@@ -34,15 +34,11 @@ The detect FFmpeg process exited on its own. This message is only the notificati
|
||||
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem id="non-monotonically-increasing-dts" question="Non-monotonic DTS / non monotonically increasing dts to muxer / Queue input is backward in time">
|
||||
<FaqItem id="non-monotonically-increasing-dts" question="Application provided invalid, non monotonically increasing dts to muxer">
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
See [Recordings: segments are only 1 second long](/troubleshooting/recordings#segments-are-only-1-second-long).
|
||||
|
||||
</FaqItem>
|
||||
|
||||
|
||||
+1
-15
@@ -31,10 +31,7 @@ from frigate.api.auth import (
|
||||
get_allowed_cameras_for_filter,
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.config_util import (
|
||||
publish_camera_section_updates,
|
||||
swap_runtime_config,
|
||||
)
|
||||
from frigate.api.config_util import swap_runtime_config
|
||||
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
||||
from frigate.api.defs.request.app_body import (
|
||||
AppConfigSetBody,
|
||||
@@ -966,17 +963,6 @@ 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=(
|
||||
{
|
||||
|
||||
@@ -3,30 +3,6 @@
|
||||
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:
|
||||
@@ -40,10 +16,6 @@ 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:
|
||||
|
||||
@@ -35,7 +35,6 @@ 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
|
||||
@@ -75,7 +74,6 @@ 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(
|
||||
@@ -164,7 +162,6 @@ 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()
|
||||
|
||||
+1
-14
@@ -30,7 +30,6 @@ 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,
|
||||
@@ -123,18 +122,7 @@ class FrigateApp:
|
||||
self.processes: dict[str, int] = {}
|
||||
self.embeddings: EmbeddingsContext | None = None
|
||||
self.profile_manager: ProfileManager | None = None
|
||||
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
|
||||
self.config = config
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
dirs = [
|
||||
@@ -657,7 +645,6 @@ class FrigateApp:
|
||||
self.replay_manager,
|
||||
self.dispatcher,
|
||||
self.profile_manager,
|
||||
config_holder=self.config_holder,
|
||||
),
|
||||
host="127.0.0.1",
|
||||
port=5001,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""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
|
||||
@@ -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)
|
||||
predictions = np.concatenate(
|
||||
detections = np.concatenate(
|
||||
(image_pred[:, :5], class_conf, class_pred), axis=1
|
||||
)
|
||||
predictions = predictions[conf_mask]
|
||||
detections = detections[conf_mask]
|
||||
|
||||
ordered = predictions[predictions[:, 5].argsort()[::-1]][:20]
|
||||
ordered = detections[detections[:, 5].argsort()[::-1]][:20]
|
||||
|
||||
for i, object_detected in enumerate(ordered):
|
||||
detections[i] = self.process_yolo(
|
||||
|
||||
@@ -23,7 +23,6 @@ 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__)
|
||||
|
||||
@@ -165,15 +164,6 @@ 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", [])
|
||||
|
||||
@@ -178,10 +178,13 @@ class OutputProcess(FrigateProcess):
|
||||
)
|
||||
|
||||
if update_topic is not None and birdseye_config is not None:
|
||||
# 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
|
||||
previous_global_mode = self.config.birdseye.mode
|
||||
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,7 +13,6 @@ 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
|
||||
|
||||
@@ -374,128 +373,6 @@ 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()
|
||||
|
||||
@@ -4,7 +4,6 @@ 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):
|
||||
@@ -13,7 +12,6 @@ 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:
|
||||
@@ -39,40 +37,11 @@ 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
|
||||
|
||||
@@ -472,18 +472,6 @@ 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)
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ const logger: SectionConfigOverrides = {
|
||||
additionalProperties: {
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "logger.logLevel",
|
||||
additionalPropertyKeySize: "lg",
|
||||
additionalPropertyKeyLabel:
|
||||
"configForm.additionalProperties.loggerNameLabel",
|
||||
additionalPropertyKeyPlaceholder:
|
||||
|
||||
@@ -15,14 +15,6 @@ 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,
|
||||
@@ -66,14 +58,6 @@ 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
|
||||
@@ -142,7 +126,7 @@ export function WrapIfAdditionalTemplate<
|
||||
style={style}
|
||||
>
|
||||
{!keyIsReadonly && (
|
||||
<div className={cn("col-span-12 space-y-2", keySpanClass)}>
|
||||
<div className="col-span-12 space-y-2 md:col-span-2">
|
||||
{displayLabel && <Label htmlFor={keyId}>{keyLabel}</Label>}
|
||||
{keyLocked ? (
|
||||
<div
|
||||
@@ -174,7 +158,7 @@ export function WrapIfAdditionalTemplate<
|
||||
<div
|
||||
className={cn(
|
||||
"col-span-12 space-y-2",
|
||||
!keyIsReadonly && valueSpanClass,
|
||||
!keyIsReadonly && "md:col-span-9",
|
||||
)}
|
||||
>
|
||||
{!keyIsReadonly && displayLabel && (
|
||||
|
||||
Reference in New Issue
Block a user