mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-10 21:01:10 +03:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6afcb83f7 | ||
|
|
5e418d067b | ||
|
|
2599795ab0 | ||
|
|
344efb6bc1 | ||
|
|
8e55da67b0 | ||
|
|
62d90e8de8 | ||
|
|
599e0acad7 | ||
|
|
e5382db70e | ||
|
|
4e13c3c9a0 | ||
|
|
c62c31361f | ||
|
|
144513d3d6 | ||
|
|
22c3dfa5a5 | ||
|
|
4e68c4723f | ||
|
|
dbce2d5a43 | ||
|
|
c00ea6a481 | ||
|
|
a4c0aad206 | ||
|
|
6aa2a010ce | ||
|
|
5746f16472 | ||
|
|
24ab9460f5 | ||
|
|
9eb2c841a5 | ||
|
|
e73a14db5d | ||
|
|
4883e20898 | ||
|
|
33c00a27e4 | ||
|
|
3b14ec0c87 | ||
|
|
4f2a297745 | ||
|
|
b848c90f02 | ||
|
|
f1cc0e49d4 | ||
|
|
7ed7ed56cf | ||
|
|
860772f9f4 |
@@ -8,6 +8,7 @@ amdgpu
|
||||
analyzeduration
|
||||
Annke
|
||||
apexcharts
|
||||
Aqara
|
||||
arange
|
||||
argmax
|
||||
argmin
|
||||
@@ -64,6 +65,7 @@ dsize
|
||||
dtype
|
||||
ECONNRESET
|
||||
edgetpu
|
||||
Eufy
|
||||
facenet
|
||||
fastapi
|
||||
faststart
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -79,7 +79,5 @@ sherpa-onnx==1.12.*
|
||||
faster-whisper==1.1.*
|
||||
librosa==0.11.*
|
||||
soundfile==0.13.*
|
||||
# DeGirum detector
|
||||
degirum == 0.16.*
|
||||
# Memory profiling
|
||||
memray == 1.15.*
|
||||
|
||||
@@ -1269,78 +1269,3 @@ axengine:
|
||||
input_dtype: int
|
||||
input_pixel_format: bgr
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
degirumAiServer:
|
||||
title: DeGirum AI Server
|
||||
models:
|
||||
- key: ai-server-inference
|
||||
label: AI Server Inference
|
||||
recommended: true
|
||||
download: |-
|
||||
Launch a DeGirum AI server as a Docker container, then point the detector at it. Add this to your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
degirum_detector:
|
||||
container_name: degirum
|
||||
image: degirum/aiserver:latest
|
||||
privileged: true
|
||||
ports:
|
||||
- "8778:8778"
|
||||
```
|
||||
|
||||
Set `location` to the server's service name, container name, or `host:port`.
|
||||
ui: |
|
||||
Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| **Location** | `degirum` |
|
||||
| **Zoo** | `degirum/public` |
|
||||
| **Token** | your AI Hub token (optional for the public zoo) |
|
||||
yaml: |
|
||||
degirum_detector:
|
||||
type: degirum
|
||||
location: degirum
|
||||
zoo: degirum/public
|
||||
token: dg_example_token
|
||||
degirumLocal:
|
||||
title: DeGirum Local
|
||||
models:
|
||||
- key: local-inference
|
||||
label: Local Inference
|
||||
recommended: true
|
||||
download: Run hardware directly inside the Frigate container with `@local`, removing the AI server hop. The matching device runtime (e.g. the Hailo runtime) must be installed in the container; confirm it with `degirum sys-info`.
|
||||
ui: |
|
||||
Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| **Location** | `@local` |
|
||||
| **Zoo** | `degirum/public` |
|
||||
| **Token** | your AI Hub token (optional for the public zoo) |
|
||||
yaml: |
|
||||
degirum_detector:
|
||||
type: degirum
|
||||
location: @local
|
||||
zoo: degirum/public
|
||||
token: dg_example_token
|
||||
degirumCloud:
|
||||
title: DeGirum AI Hub Cloud
|
||||
models:
|
||||
- key: ai-hub-cloud-inference
|
||||
label: AI Hub Cloud Inference
|
||||
recommended: true
|
||||
download: Run inferences on DeGirum's [AI Hub](https://hub.degirum.com) cloud with `@cloud`. Sign up, create an access token, and set it as `token`. Network latency may require lowering your detection fps.
|
||||
ui: |
|
||||
Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| **Location** | `@cloud` |
|
||||
| **Zoo** | `degirum/public` |
|
||||
| **Token** | your AI Hub token (optional for the public zoo) |
|
||||
yaml: |
|
||||
degirum_detector:
|
||||
type: degirum
|
||||
location: @cloud
|
||||
zoo: degirum/public
|
||||
token: dg_example_token
|
||||
|
||||
@@ -981,7 +981,9 @@ cameras:
|
||||
# Optional: Adjust sort order of cameras in the UI. Larger numbers come later (default: shown below)
|
||||
# By default the cameras are sorted alphabetically.
|
||||
order: 0
|
||||
# Optional: Whether or not to show the camera in the Frigate UI (default: shown below)
|
||||
# Optional: Whether or not to show the camera on the default All Cameras live dashboard.
|
||||
# The camera is still available everywhere else, including camera groups and settings
|
||||
# (default: shown below)
|
||||
dashboard: True
|
||||
# Optional: Whether this camera is visible in review (the review page and its camera
|
||||
# filter, motion review, and the history view) (default: shown below)
|
||||
|
||||
@@ -293,6 +293,10 @@ networking:
|
||||
|
||||
This setting is for advanced users. For the majority of use cases it's recommended to change the `ports` section of your Docker compose file or use the Docker `run` `--publish` option instead, e.g. `-p 443:8971`. Changing Frigate's ports may break some integrations.
|
||||
|
||||
The internal and external ports must be different port numbers, and Frigate will refuse to start otherwise. Requests arriving on the internal port are treated as authenticated admins, so pointing both at the same port would remove authentication from the external one.
|
||||
|
||||
Nginx binds these ports when it starts, so port changes only take effect after Frigate restarts.
|
||||
|
||||
:::
|
||||
|
||||
### Customizing the Nginx configuration
|
||||
|
||||
@@ -256,7 +256,7 @@ The only field that is valid at the camera level is `enabled`.
|
||||
|
||||
#### Live transcription
|
||||
|
||||
The single camera Live view in the Frigate UI supports live transcription of audio for streams defined with the `audio` role. Use the Enable/Disable Live Audio Transcription button/switch to toggle transcription processing. When speech is heard, the UI will display a black box over the top of the camera stream with text. The MQTT topic `frigate/<camera_name>/audio/transcription` will also be updated in real-time with transcribed text.
|
||||
The single camera Live view in the Frigate UI supports live transcription of audio for streams defined with the `audio` role. Use the Enable/Disable Live Audio Transcription button/switch to toggle transcription processing, or toggle it outside of the UI with the [`frigate/<camera_name>/audio_transcription/set`](/integrations/mqtt#frigatecamera_nameaudio_transcriptionset) MQTT topic or the HTTP API. When speech is heard, the UI will display a black box over the top of the camera stream with text. The MQTT topic `frigate/<camera_name>/audio/transcription` will also be updated in real-time with transcribed text.
|
||||
|
||||
Results can be error-prone due to a number of factors, including:
|
||||
|
||||
|
||||
@@ -50,6 +50,31 @@ Connect each stream to get a live preview, an estimated bandwidth figure, and a
|
||||
|
||||
Other features, including [hardware acceleration](hardware_acceleration_video.md), [two way talk](/configuration/live#two-way-talk), and audio transcoding, is configured after the camera has been added. For camera model specific quirks, see the [camera specific](camera_specific.md) docs.
|
||||
|
||||
## Deleting a camera
|
||||
|
||||
Click **Delete Camera** in <NavPath path="Settings > Global configuration > Camera management" />, choose the camera, and confirm. Deleting a camera requires the `admin` role and cannot be undone.
|
||||
|
||||
:::warning
|
||||
|
||||
Deleting a camera permanently removes its recordings, tracked objects, and configuration. If you only want to stop processing a camera, set its state to **Off** or **Disabled** in <NavPath path="Settings > Global configuration > Camera management" /> instead. See [camera state](/configuration/live#camera-state).
|
||||
|
||||
:::
|
||||
|
||||
Deleting a camera removes:
|
||||
|
||||
- The camera's section of your config file, along with its entries in any [role](authentication.md#user-roles) camera list. A custom role left with no cameras is removed as well.
|
||||
- Every database record for the camera: tracked objects, review items, recordings, previews, timeline entries, the saved region grid, and [triggers](semantic_search.md#triggers).
|
||||
- Every media file for the camera: recordings, snapshots, thumbnails, and preview clips.
|
||||
|
||||
[Exports](/usage/exports) are kept by default, so saved footage survives the deletion of the camera it came from. Turn on **Also delete exports for this camera** in the confirmation step to remove those too.
|
||||
|
||||
The camera's processes are stopped and the change takes effect immediately, so no restart is required. If the resulting config cannot be parsed, Frigate restores the previous config and reports an error instead of leaving Frigate in a broken state.
|
||||
|
||||
Two things are not cleaned up for you:
|
||||
|
||||
- **go2rtc streams.** Frigate makes a best effort to stop a running [go2rtc](go2rtc.md) stream named after the camera, but stream entries in your config file remain and are recreated on the next restart. Remove them in <NavPath path="Settings > System > go2rtc streams" /> or in your config file.
|
||||
- **Camera groups.** A deleted camera stays listed in any [camera group](#setting-up-camera-groups) that referenced it. The group skips the missing camera, so this is harmless, but you can edit the group to drop the stale entry.
|
||||
|
||||
## Setting Up Camera Inputs
|
||||
|
||||
Several inputs can be configured for each camera and the role of each input can be mixed and matched based on your needs. This allows you to use a lower resolution stream for object detection, but create recordings from a higher resolution stream, or vice versa.
|
||||
|
||||
@@ -11,7 +11,7 @@ Object classification allows you to train a custom MobileNetV2 classification mo
|
||||
|
||||
:::info
|
||||
|
||||
Training a custom object classification model requires a one-time internet connection to download MobileNetV2 base weights. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
Training a custom object classification model requires an internet connection to download MobileNetV2 base weights. By default these weights are not cached in `/config/`, so they are downloaded again after the container is recreated. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ State classification allows you to train a custom MobileNetV2 classification mod
|
||||
|
||||
:::info
|
||||
|
||||
Training a custom state classification model requires a one-time internet connection to download MobileNetV2 base weights. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
Training a custom state classification model requires an internet connection to download MobileNetV2 base weights. By default these weights are not cached in `/config/`, so they are downloaded again after the container is recreated. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -67,4 +67,6 @@ If your stream won't play, has no audio, uses excessive CPU, or otherwise misbeh
|
||||
|
||||
## Homekit Configuration
|
||||
|
||||
To add camera streams to Homekit Frigate must be configured in docker to use `host` networking mode. Once that is done, you can use the go2rtc WebUI (accessed via port 1984, which is disabled by default) to export a camera to Homekit. Any changes made will automatically be saved to `/config/go2rtc_homekit.yml`.
|
||||
To export camera streams to HomeKit, Frigate must be configured in docker to use `host` networking mode. HomeKit settings are stored in `/config/go2rtc_homekit.yml` rather than in your Frigate config, and are edited through the go2rtc config editor at `http://<frigate_host>:1984/editor.html`. Pairings are saved back to that file automatically.
|
||||
|
||||
See the [HomeKit integration docs](/integrations/homekit) for the full setup, including the video and audio requirements HomeKit places on the stream.
|
||||
|
||||
@@ -334,7 +334,7 @@ When your browser runs into problems playing back your camera streams, it will l
|
||||
|
||||
- **stalled**
|
||||
- What it means: Playback has stalled because the player has fallen too far behind live (extended buffering or no data arriving).
|
||||
- What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in the UI pane of Frigate's settings.
|
||||
- What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in <NavPath path="Settings > UI" /> .
|
||||
|
||||
- Possible console messages from the player code:
|
||||
- `Buffer time (10 seconds) exceeded, browser may not be playing media correctly.`
|
||||
|
||||
@@ -6,6 +6,7 @@ title: Notifications
|
||||
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";
|
||||
|
||||
# Notifications
|
||||
|
||||
@@ -21,7 +22,7 @@ Push notifications require internet access from the Frigate server to the browse
|
||||
|
||||
In order to use notifications the following requirements must be met:
|
||||
|
||||
- Frigate must be accessed via a secure `https` connection ([see the authorization docs](/configuration/authentication)).
|
||||
- Frigate must be accessed via a secure `https` connection while signed in as a Frigate user ([see the authorization docs](/configuration/authentication)).
|
||||
- A supported browser must be used. Currently Chrome, Firefox, and Safari are known to be supported.
|
||||
- In order for notifications to be usable externally, Frigate must be accessible externally.
|
||||
- For iOS devices, some users have also indicated that the Notifications switch needs to be enabled in iOS Settings --> Apps --> Safari --> Advanced --> Features.
|
||||
@@ -85,7 +86,13 @@ cameras:
|
||||
|
||||
### Registration
|
||||
|
||||
Once notifications are enabled, press the `Register for Notifications` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent.
|
||||
Once notifications are enabled, press the `Register This Device` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent.
|
||||
|
||||
:::warning
|
||||
|
||||
Each registration is attached to the Frigate user account you are signed in as, so you must register over a secure connection to the authenticated port (`8971`). Reverse proxies and tunnels should point at port `8971`.
|
||||
|
||||
:::
|
||||
|
||||
## Supported Notifications
|
||||
|
||||
@@ -104,3 +111,62 @@ Different platforms handle notifications differently, some settings changes may
|
||||
### Android
|
||||
|
||||
Most Android phones have battery optimization settings. To get reliable Notification delivery the browser (Chrome, Firefox) should have battery optimizations disabled. If Frigate is running as a PWA then the Frigate app should have battery optimizations disabled as well.
|
||||
|
||||
## Notifications FAQ
|
||||
|
||||
<FaqItem id="how-do-i-debug-notifications-issues" question="How do I debug notifications issues?">
|
||||
|
||||
Push notifications involve Frigate, your browser, and your browser vendor's push service, so it helps to work from the server outward.
|
||||
|
||||
1. Enable debug logs for the push client by adding `frigate.comms.webpush: debug` to your `logger` configuration. Restart Frigate after this change.
|
||||
|
||||
```yaml
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
# highlight-next-line
|
||||
frigate.comms.webpush: debug
|
||||
```
|
||||
|
||||
These logs show exactly where a notification stopped, including:
|
||||
- `Email must be provided for push notifications to be sent` means the global `email` field is empty and nothing will ever be sent.
|
||||
- `Sending test notification` and `Sending push notification for <camera>, review ID <id>` mean Frigate handed the message off to the push service.
|
||||
- `Skipping notification for <camera> - in global cooldown period` (or `camera-specific cooldown period`) means your [cooldown](#configuration) values suppressed it.
|
||||
- `Notifications for <camera> are currently suspended` means notifications were suspended from <NavPath path="Settings > Notifications" /> or MQTT.
|
||||
- `Notification endpoint expired for <user>, received 410` means that device's subscription is no longer valid and it must be re-registered.
|
||||
- `Failed to send notification to <user> :: <status>` means the push service rejected the message. A `401` or `403` usually points at a VAPID or `email` problem, and a `5xx` is a problem on the push service's end.
|
||||
- If you see no messages at all when an alert occurs, the notification was never queued. Confirm an actual **alert** was created (notifications are not sent for detections), and that notifications are enabled both globally and for that camera.
|
||||
|
||||
2. Verify the basics that most reports come down to:
|
||||
- Frigate must be reached over `https` with a certificate your device trusts. Browsers silently refuse to register a service worker otherwise, and a self-signed certificate that is not installed as trusted on the device will fail.
|
||||
- On iOS, notifications only work when Frigate has been installed to the Home Screen via **Share > Add to Home Screen** and opened from that icon. Safari and Chrome tabs cannot receive web push on iOS.
|
||||
- Each device must be registered individually, and Frigate must be restarted after registering before anything can be sent, including test notifications.
|
||||
- The Frigate server needs outbound internet access to the browser vendor's push service. See [Network Requirements](/frigate/network_requirements#push-notifications).
|
||||
|
||||
3. Test from the UI. Use the `Send a test notification` button in <NavPath path="Settings > Notifications" />. If the log shows `Sending test notification` but nothing arrives on the device, the problem is between the push service and your device rather than in Frigate.
|
||||
|
||||
4. Check the browser side on the device that is not receiving notifications:
|
||||
- Confirm the site's notification permission is set to **Allow** in your browser or OS settings, and that a focus/do not disturb mode is not hiding them.
|
||||
- In desktop browsers, open Developer Tools > Application > Service Workers and confirm `notifications-worker.js` is registered and activated. Unregistering it and registering the device again will rebuild a broken subscription.
|
||||
- Check the browser console and your reverse proxy logs for failures loading `/notifications-worker.js` or errors on `/api/notifications/register`.
|
||||
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem id="why-did-notifications-stop-arriving-after-working-for-a-while" question="Why did notifications stop arriving after working for a while?">
|
||||
|
||||
Push subscriptions are issued by the browser vendor and can be revoked, most often after a browser update, after clearing site data, or when a device has been offline for an extended period. When this happens the device still appears registered in Frigate, but the push service rejects the message. The debug logs will show `Notification endpoint expired` with a `404` or `410` status.
|
||||
|
||||
Unregister and re-register the affected device from <NavPath path="Settings > Notifications" />, then restart Frigate.
|
||||
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem id="why-am-i-not-getting-notifications-for-one-specific-camera" question="Why am I not getting notifications for one specific camera?">
|
||||
|
||||
Work through these in order:
|
||||
|
||||
- Notifications are only sent for **alerts**. If the camera is producing detections instead, adjust the camera's `review > alerts > labels` so the objects you care about are classified as alerts.
|
||||
- Confirm notifications are enabled for that camera in <NavPath path="Settings > Camera configuration > Notifications" />.
|
||||
- Check the camera's `cooldown` value, and remember that the global cooldown applies across all cameras. A busy camera can consume the global cooldown and suppress a quieter one.
|
||||
- If [authentication](/configuration/authentication) is enabled with roles, users only receive notifications for the cameras their role grants access to.
|
||||
|
||||
</FaqItem>
|
||||
|
||||
@@ -24,7 +24,6 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
- [Coral EdgeTPU](#edge-tpu-detector): The Google Coral EdgeTPU is available in USB, Mini PCIe, and m.2 formats allowing for a wide range of compatibility with devices.
|
||||
- [Hailo](#hailo-8): The Hailo8 and Hailo8L AI Acceleration module is available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices.
|
||||
- <CommunityBadge /> [MemryX](#memryx-mx3): The MX3 Acceleration module is available in m.2 format, offering broad compatibility across various platforms.
|
||||
- <CommunityBadge /> [DeGirum](#degirum): Service for using hardware devices in the cloud or locally. Hardware and models provided on the cloud on [their website](https://hub.degirum.com).
|
||||
|
||||
**AMD**
|
||||
|
||||
@@ -298,6 +297,14 @@ detectors:
|
||||
|
||||
:::
|
||||
|
||||
### Intel NPU host requirements {#intel-npu-requirements}
|
||||
|
||||
The NPU firmware is loaded by the host kernel and is not part of the Frigate image. Everything else the NPU needs is bundled in the container, so host NPU libraries should never be mounted in.
|
||||
|
||||
Frigate bundles a specific version of Intel's [linux-npu-driver](https://github.com/intel/linux-npu-driver/releases), and the host firmware must come from that release or a newer one. Firmware older than the bundled driver may fail with `MAPPED_INFERENCE_VERSION is NOT compatible with the ELF`, where `Expected` is the version the firmware supports and `received` is the version the bundled compiler produced. Distributions often package older firmware than the driver Frigate ships, so check the build date on the host with `sudo dmesg | grep -i vpu` and update it there if needed.
|
||||
|
||||
Intel NPUs cannot be used under Home Assistant OS, which does not include the NPU firmware.
|
||||
|
||||
### Configuration {#configuration-openvino}
|
||||
|
||||
<ModelConfigDropdown detectorTitle="OpenVINO" models={objectDetectorsModels.openvino.models} />
|
||||
@@ -755,87 +762,6 @@ Explanation of the parameters:
|
||||
- **example**: Specifying `output_name = "frigate-{quant}-{input_basename}-{soc}-v{tk_version}"` could result in a model called `frigate-i8-my_model-rk3588-v2.3.0.rknn`.
|
||||
- `config`: Configuration passed to `rknn-toolkit2` for model conversion. For an explanation of all available parameters have a look at section "2.2. Model configuration" of [this manual](https://github.com/MarcA711/rknn-toolkit2/releases/download/v2.3.2/03_Rockchip_RKNPU_API_Reference_RKNN_Toolkit2_V2.3.2_EN.pdf).
|
||||
|
||||
## DeGirum
|
||||
|
||||
DeGirum is a detector that can use any type of hardware listed on [their website](https://hub.degirum.com). DeGirum can be used with local hardware through a DeGirum AI Server, or through the use of `@local`. You can also connect directly to DeGirum's AI Hub to run inferences. **Please Note:** This detector _cannot_ be used for commercial purposes.
|
||||
|
||||
### Configuration {#configuration-degirum}
|
||||
|
||||
#### AI Server Inference
|
||||
|
||||
Before starting with the config file for this section, you must first launch an AI server. DeGirum has an AI server ready to use as a docker container. Add this to your `docker-compose.yml` to get started:
|
||||
|
||||
```yaml
|
||||
degirum_detector:
|
||||
container_name: degirum
|
||||
image: degirum/aiserver:latest
|
||||
privileged: true
|
||||
ports:
|
||||
- "8778:8778"
|
||||
```
|
||||
|
||||
All supported hardware will automatically be found on your AI server host as long as relevant runtimes and drivers are properly installed on your machine. Refer to [DeGirum's docs site](https://docs.degirum.com/pysdk/runtimes-and-drivers) if you have any trouble.
|
||||
|
||||
Once completed, configure the detector as follows:
|
||||
|
||||
<ModelConfigDropdown detectorTitle="DeGirum" models={objectDetectorsModels.degirumAiServer.models} />
|
||||
|
||||
Setting up a model in the `config.yml` is similar to setting up an AI server.
|
||||
You can set it to:
|
||||
|
||||
- A model listed on the [AI Hub](https://hub.degirum.com), given that the correct zoo name is listed in your detector
|
||||
- If this is what you choose to do, the correct model will be downloaded onto your machine before running.
|
||||
- A local directory acting as a zoo. See DeGirum's docs site [for more information](https://docs.degirum.com/pysdk/user-guide-pysdk/organizing-models#model-zoo-directory-structure).
|
||||
- A path to some model.json.
|
||||
|
||||
```yaml
|
||||
model:
|
||||
path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1 # directory to model .json and file
|
||||
width: 300 # width is in the model name as the first number in the "int"x"int" section
|
||||
height: 300 # height is in the model name as the second number in the "int"x"int" section
|
||||
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
|
||||
```
|
||||
|
||||
#### Local Inference
|
||||
|
||||
It is also possible to eliminate the need for an AI server and run the hardware directly. The benefit of this approach is that you eliminate any bottlenecks that occur when transferring prediction results from the AI server docker container to the frigate one. However, the method of implementing local inference is different for every device and hardware combination, so it's usually more trouble than it's worth. A general guideline to achieve this would be:
|
||||
|
||||
1. Ensuring that the frigate docker container has the runtime you want to use. So for instance, running `@local` for Hailo means making sure the container you're using has the Hailo runtime installed.
|
||||
2. To double check the runtime is detected by the DeGirum detector, make sure the `degirum sys-info` command properly shows whatever runtimes you mean to install.
|
||||
3. Create a DeGirum detector in your configuration.
|
||||
|
||||
<ModelConfigDropdown detectorTitle="DeGirum" models={objectDetectorsModels.degirumLocal.models} />
|
||||
|
||||
Once `degirum_detector` is setup, you can choose a model through 'model' section in the `config.yml` file.
|
||||
|
||||
```yaml
|
||||
model:
|
||||
path: mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
|
||||
width: 300 # width is in the model name as the first number in the "int"x"int" section
|
||||
height: 300 # height is in the model name as the second number in the "int"x"int" section
|
||||
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
|
||||
```
|
||||
|
||||
#### AI Hub Cloud Inference
|
||||
|
||||
If you do not possess whatever hardware you want to run, there's also the option to run cloud inferences. Do note that your detection fps might need to be lowered as network latency does significantly slow down this method of detection. For use with Frigate, we highly recommend using a local AI server as described above. To set up cloud inferences,
|
||||
|
||||
1. Sign up at [DeGirum's AI Hub](https://hub.degirum.com).
|
||||
2. Get an access token.
|
||||
3. Create a DeGirum detector in your configuration.
|
||||
|
||||
<ModelConfigDropdown detectorTitle="DeGirum" models={objectDetectorsModels.degirumCloud.models} />
|
||||
|
||||
Once `degirum_detector` is setup, you can choose a model through 'model' section in the `config.yml` file.
|
||||
|
||||
```yaml
|
||||
model:
|
||||
path: mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
|
||||
width: 300 # width is in the model name as the first number in the "int"x"int" section
|
||||
height: 300 # height is in the model name as the second number in the "int"x"int" section
|
||||
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
|
||||
```
|
||||
|
||||
## AXERA
|
||||
|
||||
Hardware accelerated object detection is supported on the following SoCs:
|
||||
|
||||
@@ -126,7 +126,7 @@ Only the fields you explicitly set in a profile override are applied. All other
|
||||
|
||||
## Activating Profiles
|
||||
|
||||
Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integrations/mqtt#frigateprofileset), or the Home Assistant integration.
|
||||
Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integrations/mqtt#frigateprofileset), the [HTTP API](../integrations/api/camera-set-camera-camera-name-set-feature-sub-command-put.api.mdx), or the Home Assistant integration.
|
||||
|
||||
In the Frigate UI, open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
|
||||
|
||||
|
||||
@@ -34,6 +34,12 @@ The following models are downloaded automatically the first time their associate
|
||||
| [Custom classification](/configuration/custom_classification/state_classification) (training) | MobileNetV2 ImageNet base weights (via Keras) | Google storage |
|
||||
| [Audio transcription](/configuration/advanced/system) | Whisper or Sherpa-ONNX streaming model | HuggingFace / OpenAI |
|
||||
|
||||
:::note
|
||||
|
||||
The MobileNetV2 base weights are the one exception to the `/config/model_cache/` rule. They are also the only entry that is not downloaded when the feature is enabled: Frigate fetches them when a training run actually starts.
|
||||
|
||||
:::
|
||||
|
||||
### Hardware-Specific Detector Models
|
||||
|
||||
If you are using one of the following hardware detectors and have not provided your own model file, a default model will be downloaded on first startup:
|
||||
@@ -75,7 +81,7 @@ If your Frigate instance has restricted internet access, you can point model dow
|
||||
| `HF_ENDPOINT` | `https://huggingface.co` | Semantic search, Sherpa-ONNX, AXEngine models |
|
||||
| `GITHUB_ENDPOINT` | `https://github.com` | Face recognition, LPR, RKNN models |
|
||||
| `GITHUB_RAW_ENDPOINT` | `https://raw.githubusercontent.com` | Bird classification |
|
||||
| `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` | Google storage (Keras default) | Custom classification training |
|
||||
| `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` | Unset (Keras uses its own default) | Custom classification training |
|
||||
|
||||
## Optional Cloud Services
|
||||
|
||||
@@ -147,9 +153,23 @@ When running as a Home Assistant App, the go2rtc startup script queries the loca
|
||||
To run Frigate in an air-gapped or offline environment:
|
||||
|
||||
1. **Pre-download models**: Start Frigate with internet access once with all desired features enabled. Models will be cached in `/config/model_cache/`.
|
||||
2. **Disable version check**: Set `telemetry.version_check: false` in your configuration.
|
||||
3. **Block outbound model requests**: Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests.
|
||||
4. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers.
|
||||
5. **Use local model mirrors**: If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, and `GITHUB_RAW_ENDPOINT` environment variables to point to local mirrors.
|
||||
2. **Pre-download the training base weights**: If you plan to train custom classification models, set `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` before training, then run one training job while online. Without this variable the base weights are cached outside `/config/` and are lost whenever the container is recreated, so a later training run will fail offline. If the machine never has internet access, copy the weights in manually as described below.
|
||||
3. **Disable version check**: Set `telemetry.version_check: false` in your configuration.
|
||||
4. **Block outbound model requests**: Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests.
|
||||
5. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers.
|
||||
6. **Use local model mirrors**: If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, `GITHUB_RAW_ENDPOINT`, and `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` environment variables to point to local mirrors.
|
||||
|
||||
After these steps, Frigate will operate with no outbound internet connections.
|
||||
|
||||
### Manually Copying the Training Base Weights
|
||||
|
||||
On a machine with internet access, download the weights:
|
||||
|
||||
```bash
|
||||
curl -L -o mobilenet_v2_weights.h5 \
|
||||
"https://storage.googleapis.com/tensorflow/keras-applications/mobilenet_v2/mobilenet_v2_weights_tf_dim_ordering_tf_kernels_0.35_224_no_top.h5"
|
||||
```
|
||||
|
||||
Copy the file into your Frigate config volume as `/config/model_cache/MobileNet/mobilenet_v2_weights.h5`, keeping that exact filename, then set the environment variable `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` in your Docker compose file to the URL above and restart Frigate.
|
||||
|
||||
The variable must be set even though the URL is never contacted. If it is unset, Frigate ignores the copied file and asks Keras to download the weights instead.
|
||||
|
||||
@@ -3,35 +3,100 @@ id: homekit
|
||||
title: HomeKit
|
||||
---
|
||||
|
||||
Frigate cameras can be integrated with Apple HomeKit through go2rtc. This allows you to view your camera streams directly in the Apple Home app on your iOS, iPadOS, macOS, and tvOS devices.
|
||||
Frigate cameras can be exported to Apple HomeKit through go2rtc. Each exported camera appears as an accessory in the Apple Home app on your iOS, iPadOS, macOS, and tvOS devices.
|
||||
|
||||
## Overview
|
||||
|
||||
HomeKit integration is handled entirely through go2rtc, which is embedded in Frigate. go2rtc provides the necessary HomeKit Accessory Protocol (HAP) server to expose your cameras to HomeKit.
|
||||
Exporting cameras is handled entirely through go2rtc, which is embedded in Frigate. go2rtc provides the necessary HomeKit Accessory Protocol (HAP) server, so your camera is published to HomeKit as an accessory in its own right.
|
||||
|
||||
## Setup
|
||||
:::note
|
||||
|
||||
All HomeKit configuration and pairing should be done through the **go2rtc WebUI**.
|
||||
This is the opposite of importing a HomeKit camera. go2rtc can also pair with an existing HomeKit camera (Aqara, Eve, Eufy, and similar) and use it as a stream source, which is what the `add` page of the go2rtc WebUI is for. That page discovers HomeKit accessories on your network and will not list your Frigate cameras. It is not used for exporting.
|
||||
|
||||
### Accessing the go2rtc WebUI
|
||||
|
||||
The go2rtc WebUI is available at:
|
||||
|
||||
```
|
||||
http://<frigate_host>:1984
|
||||
```
|
||||
|
||||
Replace `<frigate_host>` with the IP address or hostname of your Frigate server.
|
||||
|
||||
### Pairing Cameras
|
||||
|
||||
1. Navigate to the go2rtc WebUI at `http://<frigate_host>:1984`
|
||||
2. Use the `add` section to add a new camera to HomeKit
|
||||
3. Follow the on-screen instructions to generate pairing codes for your cameras
|
||||
:::
|
||||
|
||||
## Requirements
|
||||
|
||||
- Frigate must be accessible on your local network using host network_mode
|
||||
- Your iOS device must be on the same network as Frigate
|
||||
- Port 1984 must be accessible for the go2rtc WebUI
|
||||
- For detailed go2rtc configuration options, refer to the [go2rtc documentation](https://github.com/AlexxIT/go2rtc)
|
||||
- Frigate must be running with `network_mode: host` so that HomeKit can discover your cameras over mDNS
|
||||
- Your Apple device must be on the same network as Frigate
|
||||
- Port 1984 must be accessible so you can reach the go2rtc WebUI
|
||||
|
||||
HomeKit also places strict limits on the stream itself. go2rtc passes your stream through without resizing or re-encoding it, so the stream you export must already meet these requirements:
|
||||
|
||||
- **Video:** H.264 at 1920x1080, 1280x720, or 320x240
|
||||
- **Audio:** Opus, mono, 16 kHz
|
||||
|
||||
A camera's full resolution stream usually does not qualify. See [Exporting a compatible stream](#exporting-a-compatible-stream) below.
|
||||
|
||||
## Configuration
|
||||
|
||||
HomeKit settings are stored in `/config/go2rtc_homekit.yml`. This is a separate file from your Frigate config, because go2rtc needs to write your pairings back to it when you pair a device.
|
||||
|
||||
Edit it using the go2rtc config editor, which writes to that file directly:
|
||||
|
||||
```
|
||||
http://<frigate_host>:1984/editor.html
|
||||
```
|
||||
|
||||
Replace `<frigate_host>` with the IP address or hostname of your Frigate server. The editor will be empty until you add a HomeKit section, since this file holds only your HomeKit settings and not the rest of your go2rtc config.
|
||||
|
||||
:::warning
|
||||
|
||||
Do not put the `homekit:` section in the `go2rtc:` section of your Frigate config.
|
||||
|
||||
Frigate regenerates that config on every startup, so go2rtc cannot save your pairings to it. Pairing will appear to succeed and then fail after the next restart with `PairVerify with unknown client_id`. If the section exists in both places, your saved pairings are erased on every restart.
|
||||
|
||||
:::
|
||||
|
||||
Add an entry for each camera you want to export. The key must match the name of a go2rtc stream, and the pin must be 8 digits. This is the number the Home app calls the setup code:
|
||||
|
||||
```yaml
|
||||
homekit:
|
||||
front_door:
|
||||
name: Front Door
|
||||
pin: "12345678"
|
||||
```
|
||||
|
||||
If the key does not match a go2rtc stream, go2rtc logs `[homekit] missing stream:` at startup and the camera will not appear in the Home app.
|
||||
|
||||
:::note
|
||||
|
||||
go2rtc derives each accessory's HomeKit identity from this key, so renaming it later means the camera appears as a new accessory and has to be paired again. Settle on the name before you pair.
|
||||
|
||||
:::
|
||||
|
||||
Frigate keeps only the `homekit:` section of this file when it starts, so do not store streams or other go2rtc settings in it.
|
||||
|
||||
### Exporting a compatible stream
|
||||
|
||||
If a camera's stream does not meet the requirements listed above, define a scaled restream in your Frigate config and point HomeKit at that stream instead of the original:
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
front_door:
|
||||
- rtsp://user:password@192.168.1.50:554/stream
|
||||
front_door_homekit:
|
||||
- "ffmpeg:front_door#video=h264#width=1280#height=720#audio=opus/16000"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# /config/go2rtc_homekit.yml
|
||||
homekit:
|
||||
front_door_homekit:
|
||||
name: Front Door
|
||||
pin: "12345678"
|
||||
```
|
||||
|
||||
Add `#hardware=cuda`, `#hardware=vaapi`, or the appropriate value for your system to transcode using your GPU. Note that NVENC cannot encode H.264 wider than 4096 pixels, so very wide streams must be scaled down as shown above rather than only re-encoded.
|
||||
|
||||
## Pairing Cameras
|
||||
|
||||
1. Restart Frigate after adding the `homekit:` section
|
||||
2. In the Apple Home app, choose **Add Accessory**, then **More options** to enter a code manually
|
||||
3. Select your camera and enter the pin you configured as the setup code
|
||||
4. Confirm that a `pairings:` list now appears under the camera in `/config/go2rtc_homekit.yml`
|
||||
|
||||
Pairings are saved back to that file automatically. If step 4 shows no `pairings:` list, check the Frigate log for `[homekit] can't save`, which means the `homekit:` section is missing from `/config/go2rtc_homekit.yml`.
|
||||
|
||||
For detailed go2rtc configuration options, refer to the [go2rtc documentation](https://github.com/AlexxIT/go2rtc).
|
||||
|
||||
@@ -308,6 +308,8 @@ Publishes the current health status of each role that is enabled (`audio`, `dete
|
||||
- `offline`: Stream is offline and is being restarted
|
||||
- `disabled`: Camera is currently turned off (either at runtime via the `enabled/set` topic, or persistently via the configuration file). See [Camera state](/configuration/live#camera-state) for the distinction.
|
||||
|
||||
These reflect the state of Frigate's process for that role, not the camera's reachability, so an unreachable camera alternates between `offline` and `online` as the watchdog restarts ffmpeg. Wait for the status to hold steady (for example with Home Assistant's `for:`) rather than acting on a single message.
|
||||
|
||||
### `frigate/<camera_name>/<object_name>`
|
||||
|
||||
Publishes the count of objects for the camera for use as a sensor in Home Assistant.
|
||||
@@ -390,6 +392,18 @@ Topic to turn audio detection for a camera on and off. Expected values are `ON`
|
||||
|
||||
Topic with current state of audio detection for a camera. Published values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/audio_transcription/set`
|
||||
|
||||
Topic to turn [live audio transcription](/configuration/audio_detectors#live-transcription) for a camera on and off. Expected values are `ON` and `OFF`. Transcribed text is published to `frigate/<camera_name>/audio/transcription`.
|
||||
|
||||
`ON` is ignored unless audio transcription is enabled in the config for the camera. Unlike the other camera toggles, this one is not persisted across Frigate restarts.
|
||||
|
||||
**NOTE:** Requires audio detection and transcription to be enabled
|
||||
|
||||
### `frigate/<camera_name>/audio_transcription/state`
|
||||
|
||||
Topic with current state of live audio transcription for a camera. Published values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/recordings/set`
|
||||
|
||||
Topic to turn recordings for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ The per-clip variation is typically quite low and is mostly an artifact of keyfr
|
||||
|
||||
Debug Replay lets you re-run Frigate's detection pipeline against a section of recorded video without manually configuring a dummy camera. It automatically extracts the recording, creates a temporary camera with the same detection settings as the original, and loops the clip through the pipeline so you can observe detections in real time.
|
||||
|
||||
The replay camera behaves like a live camera feed rather than History's video player: it loops the clip continuously as Frigate analyzes it and has no playback controls, so you cannot pause, scrub, or step through it frame by frame.
|
||||
The replay camera behaves like a live camera feed rather than History's video player: it loops the clip continuously as Frigate analyzes it and has no playback controls, so you cannot pause, scrub, or step through it frame by frame. The Debug Replay camera does not save recordings or snapshots or surface anything in Explore, but it otherwise behaves like a regular camera, including running enrichments such as Face Recognition, LPR, and custom classification.
|
||||
|
||||
Debug Replay isn't intended to be a one-stop pane for all Frigate diagnostics or a comprehensive debugging environment for every Frigate feature. It merely makes it easier to spin up a "dummy camera" and perform some common adjustments in real time. You'll still need to use the normal tools (logs, an MQTT client, etc) to debug your feature.
|
||||
|
||||
|
||||
@@ -65,9 +65,17 @@ This is because Frigate does not run in host mode so localhost points to the Fri
|
||||
|
||||
### How do I know if my camera is offline
|
||||
|
||||
A camera being offline can be detected via MQTT or /api/stats, the camera_fps for any offline camera will be 0.
|
||||
Frigate publishes a per-role health status to [`frigate/<camera_name>/status/<role>`](/integrations/mqtt#frigatecamera_namestatusrole), where `<role>` is each enabled role on the camera (`detect`, `record`, and `audio`). The published value is one of:
|
||||
|
||||
Also, Home Assistant will mark any offline camera as being unavailable when the camera is offline.
|
||||
- `online`: Frigate's process for that role is running normally
|
||||
- `offline`: the process is down and Frigate is restarting it
|
||||
- `disabled`: the camera is turned off, either at runtime or in the configuration file
|
||||
|
||||
These reflect the state of Frigate's process for that role, not the camera's reachability, so an unreachable camera alternates between `offline` and `online` as the watchdog restarts ffmpeg. Wait for the status to hold steady (for example with Home Assistant's `for:`) rather than acting on a single message.
|
||||
|
||||
Because the status is per role, a camera whose substream is fine but whose recording stream has dropped will report `online` for `detect` and `offline` for `record`. The status is republished whenever it changes.
|
||||
|
||||
You can also detect an offline camera through `/api/stats`, where `camera_fps` will be 0.
|
||||
|
||||
### How can I view the Frigate log files without using the Web UI?
|
||||
|
||||
|
||||
Vendored
+74
@@ -693,6 +693,43 @@ paths:
|
||||
**Access:** Admin role required.
|
||||
|
||||
Set a camera feature state. Use camera_name='*' to target all cameras.
|
||||
|
||||
The value to set is sent in the request body as `{"value": "<value>"}`.
|
||||
|
||||
| Feature | Accepted values |
|
||||
| --- | --- |
|
||||
| `enabled` | `ON`, `OFF` |
|
||||
| `detect` | `ON`, `OFF` |
|
||||
| `motion` | `ON`, `OFF` |
|
||||
| `recordings` | `ON`, `OFF` |
|
||||
| `snapshots` | `ON`, `OFF` |
|
||||
| `audio` | `ON`, `OFF` |
|
||||
| `audio_transcription` | `ON`, `OFF` |
|
||||
| `notifications` | `ON`, `OFF` |
|
||||
| `review_alerts` | `ON`, `OFF` |
|
||||
| `review_detections` | `ON`, `OFF` |
|
||||
| `object_descriptions` | `ON`, `OFF` |
|
||||
| `review_descriptions` | `ON`, `OFF` |
|
||||
| `improve_contrast` | `ON`, `OFF` |
|
||||
| `ptz_autotracker` | `ON`, `OFF` |
|
||||
| `birdseye` | `ON`, `OFF` |
|
||||
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
|
||||
| `motion_contour_area` | integer |
|
||||
| `motion_threshold` | integer |
|
||||
| `motion_mask` | `ON`, `OFF` |
|
||||
| `object_mask` | `ON`, `OFF` |
|
||||
| `zone` | `ON`, `OFF` |
|
||||
| `profile` | a profile name, or `none` to deactivate |
|
||||
|
||||
`motion_mask`, `object_mask`, and `zone` require the `sub_command` path
|
||||
parameter to be set to the name of the mask or zone. All other features
|
||||
reject a sub-command.
|
||||
|
||||
`profile` applies globally rather than per camera, so it requires
|
||||
`camera_name` to be `*`.
|
||||
|
||||
These features map to the equivalent MQTT topics, which document the
|
||||
behavior of each value in more detail.
|
||||
operationId:
|
||||
camera_set_camera__camera_name__set__feature___sub_command__put
|
||||
parameters:
|
||||
@@ -746,6 +783,43 @@ paths:
|
||||
**Access:** Admin role required.
|
||||
|
||||
Set a camera feature state. Use camera_name='*' to target all cameras.
|
||||
|
||||
The value to set is sent in the request body as `{"value": "<value>"}`.
|
||||
|
||||
| Feature | Accepted values |
|
||||
| --- | --- |
|
||||
| `enabled` | `ON`, `OFF` |
|
||||
| `detect` | `ON`, `OFF` |
|
||||
| `motion` | `ON`, `OFF` |
|
||||
| `recordings` | `ON`, `OFF` |
|
||||
| `snapshots` | `ON`, `OFF` |
|
||||
| `audio` | `ON`, `OFF` |
|
||||
| `audio_transcription` | `ON`, `OFF` |
|
||||
| `notifications` | `ON`, `OFF` |
|
||||
| `review_alerts` | `ON`, `OFF` |
|
||||
| `review_detections` | `ON`, `OFF` |
|
||||
| `object_descriptions` | `ON`, `OFF` |
|
||||
| `review_descriptions` | `ON`, `OFF` |
|
||||
| `improve_contrast` | `ON`, `OFF` |
|
||||
| `ptz_autotracker` | `ON`, `OFF` |
|
||||
| `birdseye` | `ON`, `OFF` |
|
||||
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
|
||||
| `motion_contour_area` | integer |
|
||||
| `motion_threshold` | integer |
|
||||
| `motion_mask` | `ON`, `OFF` |
|
||||
| `object_mask` | `ON`, `OFF` |
|
||||
| `zone` | `ON`, `OFF` |
|
||||
| `profile` | a profile name, or `none` to deactivate |
|
||||
|
||||
`motion_mask`, `object_mask`, and `zone` require the `sub_command` path
|
||||
parameter to be set to the name of the mask or zone. All other features
|
||||
reject a sub-command.
|
||||
|
||||
`profile` applies globally rather than per camera, so it requires
|
||||
`camera_name` to be `*`.
|
||||
|
||||
These features map to the equivalent MQTT topics, which document the
|
||||
behavior of each value in more detail.
|
||||
operationId: camera_set_camera__camera_name__set__feature__put
|
||||
parameters:
|
||||
- name: camera_name
|
||||
|
||||
+15
-1
@@ -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,
|
||||
@@ -963,6 +966,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=(
|
||||
{
|
||||
|
||||
+9
-9
@@ -31,7 +31,7 @@ from frigate.api.media_auth import (
|
||||
deny_response_for_media_uri,
|
||||
is_role_restricted,
|
||||
)
|
||||
from frigate.config import AuthConfig, NetworkingConfig, ProxyConfig
|
||||
from frigate.config import AuthConfig, ProxyConfig
|
||||
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
||||
from frigate.models import User
|
||||
|
||||
@@ -620,18 +620,18 @@ def resolve_role(
|
||||
def auth(request: Request):
|
||||
auth_config: AuthConfig = request.app.frigate_config.auth
|
||||
proxy_config: ProxyConfig = request.app.frigate_config.proxy
|
||||
networking_config: NetworkingConfig = request.app.frigate_config.networking
|
||||
|
||||
success_response = Response("", status_code=202)
|
||||
|
||||
# handle case where internal port is a string with ip:port
|
||||
internal_port = networking_config.listen.internal
|
||||
if type(internal_port) is str:
|
||||
internal_port = int(internal_port.split(":")[-1])
|
||||
|
||||
# dont require auth if the request is on the internal port
|
||||
# this header is set by Frigate's nginx proxy, so it cant be spoofed
|
||||
if int(request.headers.get("x-server-port", default=0)) == internal_port:
|
||||
# this header is set by Frigate's nginx proxy, so it cant be spoofed.
|
||||
# the port is the boot-time snapshot rather than the live config value:
|
||||
# nginx's listeners are fixed at container start, so an in-memory config
|
||||
# change must never move the port that is trusted here
|
||||
if (
|
||||
int(request.headers.get("x-server-port", default=0))
|
||||
== request.app.auth_internal_port
|
||||
):
|
||||
success_response.headers["remote-user"] = "anonymous"
|
||||
success_response.headers["remote-role"] = "admin"
|
||||
return success_response
|
||||
|
||||
+39
-1
@@ -1328,7 +1328,45 @@ def camera_set(
|
||||
body: CameraSetBody,
|
||||
sub_command: str | None = None,
|
||||
):
|
||||
"""Set a camera feature state. Use camera_name='*' to target all cameras."""
|
||||
"""Set a camera feature state. Use camera_name='*' to target all cameras.
|
||||
|
||||
The value to set is sent in the request body as `{"value": "<value>"}`.
|
||||
|
||||
| Feature | Accepted values |
|
||||
| --- | --- |
|
||||
| `enabled` | `ON`, `OFF` |
|
||||
| `detect` | `ON`, `OFF` |
|
||||
| `motion` | `ON`, `OFF` |
|
||||
| `recordings` | `ON`, `OFF` |
|
||||
| `snapshots` | `ON`, `OFF` |
|
||||
| `audio` | `ON`, `OFF` |
|
||||
| `audio_transcription` | `ON`, `OFF` |
|
||||
| `notifications` | `ON`, `OFF` |
|
||||
| `review_alerts` | `ON`, `OFF` |
|
||||
| `review_detections` | `ON`, `OFF` |
|
||||
| `object_descriptions` | `ON`, `OFF` |
|
||||
| `review_descriptions` | `ON`, `OFF` |
|
||||
| `improve_contrast` | `ON`, `OFF` |
|
||||
| `ptz_autotracker` | `ON`, `OFF` |
|
||||
| `birdseye` | `ON`, `OFF` |
|
||||
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
|
||||
| `motion_contour_area` | integer |
|
||||
| `motion_threshold` | integer |
|
||||
| `motion_mask` | `ON`, `OFF` |
|
||||
| `object_mask` | `ON`, `OFF` |
|
||||
| `zone` | `ON`, `OFF` |
|
||||
| `profile` | a profile name, or `none` to deactivate |
|
||||
|
||||
`motion_mask`, `object_mask`, and `zone` require the `sub_command` path
|
||||
parameter to be set to the name of the mask or zone. All other features
|
||||
reject a sub-command.
|
||||
|
||||
`profile` applies globally rather than per camera, so it requires
|
||||
`camera_name` to be `*`.
|
||||
|
||||
These features map to the equivalent MQTT topics, which document the
|
||||
behavior of each value in more detail.
|
||||
"""
|
||||
dispatcher = request.app.dispatcher
|
||||
frigate_config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
@@ -150,6 +152,8 @@ def create_fastapi_app(
|
||||
app.include_router(debug_replay.router)
|
||||
# App Properties
|
||||
app.frigate_config = frigate_config
|
||||
# snapshot the port nginx bound at startup, the live config can be swapped
|
||||
app.auth_internal_port = frigate_config.networking.listen.internal_port
|
||||
app.genai_manager = GenAIClientManager(frigate_config)
|
||||
app.embeddings = embeddings
|
||||
app.detected_frames_processor = detected_frames_processor
|
||||
@@ -162,6 +166,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()
|
||||
|
||||
+16
-1
@@ -53,6 +53,7 @@ from frigate.util.file import (
|
||||
)
|
||||
from frigate.util.image import get_image_from_recording, get_image_quality_params
|
||||
from frigate.util.media import get_keyframe_before
|
||||
from frigate.util.object import create_empty_regions_grid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1083,7 +1084,21 @@ def clear_region_grid(request: Request, camera_name: str):
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
Regions.delete().where(Regions.camera == camera_name).execute()
|
||||
# store an empty grid instead of deleting the row so the grid is
|
||||
# rebuilt from newly tracked objects and not from all past history
|
||||
region = {
|
||||
Regions.camera: camera_name,
|
||||
Regions.grid: create_empty_regions_grid(),
|
||||
Regions.last_update: datetime.now().timestamp(),
|
||||
}
|
||||
(
|
||||
Regions.insert(region)
|
||||
.on_conflict(
|
||||
conflict_target=[Regions.camera],
|
||||
update=region,
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Region grid cleared"},
|
||||
)
|
||||
|
||||
@@ -182,7 +182,7 @@ async def get_motion_search_status_endpoint(
|
||||
)
|
||||
|
||||
job = get_motion_search_job(job_id)
|
||||
if not job:
|
||||
if not job or job.camera != camera_name:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Job not found"},
|
||||
status_code=404,
|
||||
@@ -253,7 +253,7 @@ async def cancel_motion_search_endpoint(
|
||||
)
|
||||
|
||||
job = get_motion_search_job(job_id)
|
||||
if not job:
|
||||
if not job or job.camera != camera_name:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Job not found"},
|
||||
status_code=404,
|
||||
|
||||
+24
-23
@@ -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,
|
||||
@@ -121,8 +122,18 @@ class FrigateApp:
|
||||
self.ptz_metrics: dict[str, PTZMetrics] = {}
|
||||
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 = [
|
||||
@@ -343,25 +354,6 @@ class FrigateApp:
|
||||
)
|
||||
self.dispatcher.profile_manager = self.profile_manager
|
||||
|
||||
def restore_active_profile(self) -> None:
|
||||
"""Re-activate the persisted profile after subscribers are connected.
|
||||
|
||||
ZMQ PUB/SUB drops messages with no subscribers, so activation must
|
||||
run after every config_updater subscriber is up.
|
||||
"""
|
||||
if self.profile_manager is None:
|
||||
return
|
||||
|
||||
persisted = ProfileManager.load_persisted_profile()
|
||||
if persisted and any(
|
||||
persisted in cam.profiles for cam in self.config.cameras.values()
|
||||
):
|
||||
logger.info("Restoring persisted profile '%s'", persisted)
|
||||
# runtime overrides are layered on top via restore_runtime_state()
|
||||
self.profile_manager.activate_profile(
|
||||
persisted, clear_runtime_overrides=False
|
||||
)
|
||||
|
||||
def start_detectors(self) -> None:
|
||||
for name in self.config.cameras.keys():
|
||||
try:
|
||||
@@ -610,6 +602,13 @@ class FrigateApp:
|
||||
self.start_detectors()
|
||||
self.init_dispatcher()
|
||||
self.init_profile_manager()
|
||||
|
||||
# workers get a copy of the config and can miss the broadcast below, so
|
||||
# apply both layers here. must stay after init_profile_manager(), which
|
||||
# snapshots the base config that profile deactivation resets to
|
||||
self.profile_manager.restore_persisted_profile_to_config()
|
||||
self.dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
self.init_embeddings_client()
|
||||
self.start_video_output_processor()
|
||||
self.start_ptz_autotracker()
|
||||
@@ -624,8 +623,9 @@ class FrigateApp:
|
||||
self.start_record_cleanup()
|
||||
self.start_watchdog()
|
||||
|
||||
# restore persisted runtime overrides on top of config
|
||||
self.restore_active_profile()
|
||||
# publish for the recording/review/embeddings processes, which start
|
||||
# before the config can be corrected, and for the retained MQTT states
|
||||
self.profile_manager.restore_persisted_profile()
|
||||
self.dispatcher.restore_runtime_state()
|
||||
|
||||
self.init_auth()
|
||||
@@ -645,6 +645,7 @@ class FrigateApp:
|
||||
self.replay_manager,
|
||||
self.dispatcher,
|
||||
self.profile_manager,
|
||||
config_holder=self.config_holder,
|
||||
),
|
||||
host="127.0.0.1",
|
||||
port=5001,
|
||||
|
||||
@@ -77,6 +77,11 @@ class MqttClient(Communicator):
|
||||
"ON" if camera.audio.enabled_in_config else "OFF",
|
||||
retain=True,
|
||||
)
|
||||
self.publish(
|
||||
f"{camera_name}/audio_transcription/state",
|
||||
"ON" if camera.audio_transcription.live_enabled else "OFF",
|
||||
retain=True,
|
||||
)
|
||||
self.publish(
|
||||
f"{camera_name}/detect/state",
|
||||
"ON" if camera.detect.enabled else "OFF",
|
||||
@@ -258,6 +263,7 @@ class MqttClient(Communicator):
|
||||
"snapshots",
|
||||
"detect",
|
||||
"audio",
|
||||
"audio_transcription",
|
||||
"motion",
|
||||
"improve_contrast",
|
||||
"ptz_autotracker",
|
||||
|
||||
@@ -13,8 +13,8 @@ class CameraUiConfig(FrigateBaseModel):
|
||||
)
|
||||
dashboard: bool = Field(
|
||||
default=True,
|
||||
title="Show in UI",
|
||||
description="Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again.",
|
||||
title="Show on Live dashboard",
|
||||
description="Toggle whether this camera is visible on the default All Cameras live dashboard. The camera remains available everywhere else in the UI, including camera groups and settings.",
|
||||
)
|
||||
review: bool = Field(
|
||||
default=True,
|
||||
|
||||
@@ -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
|
||||
@@ -1,10 +1,18 @@
|
||||
from pydantic import Field
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from .base import FrigateBaseModel
|
||||
|
||||
__all__ = ["IPv6Config", "ListenConfig", "NetworkingConfig"]
|
||||
|
||||
|
||||
def parse_listen_port(value: int | str) -> int:
|
||||
"""Return the port number from a bare port or an "address:port" value."""
|
||||
if isinstance(value, str):
|
||||
return int(value.split(":")[-1])
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class IPv6Config(FrigateBaseModel):
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
@@ -25,6 +33,21 @@ class ListenConfig(FrigateBaseModel):
|
||||
description="External listening port for Frigate (default 8971).",
|
||||
)
|
||||
|
||||
@property
|
||||
def internal_port(self) -> int:
|
||||
return parse_listen_port(self.internal)
|
||||
|
||||
@property
|
||||
def external_port(self) -> int:
|
||||
return parse_listen_port(self.external)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_distinct_ports(self) -> "ListenConfig":
|
||||
if self.internal_port == self.external_port:
|
||||
raise ValueError("internal and external must listen on different ports")
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class NetworkingConfig(FrigateBaseModel):
|
||||
ipv6: IPv6Config = Field(
|
||||
|
||||
@@ -169,6 +169,93 @@ class ProfileManager:
|
||||
self.config.active_profile = None
|
||||
self._persist_active_profile(None)
|
||||
|
||||
def _validate_profile_name(self, profile_name: str | None) -> str | None:
|
||||
"""Return an error message if the name is not a defined profile."""
|
||||
if profile_name is not None and profile_name not in self.config.profiles:
|
||||
return f"Profile '{profile_name}' is not defined in the profiles section"
|
||||
|
||||
return None
|
||||
|
||||
def _apply_to_config(
|
||||
self, profile_name: str | None
|
||||
) -> tuple[dict[str, set[str]], str | None]:
|
||||
"""Reset every camera to base, then apply the named profile on top.
|
||||
|
||||
Returns the changed camera/section pairs, plus an error message if
|
||||
applying the profile failed partway through.
|
||||
"""
|
||||
changed: dict[str, set[str]] = {}
|
||||
|
||||
self._reset_to_base(changed)
|
||||
|
||||
if profile_name is not None:
|
||||
err = self._apply_profile_overrides(profile_name, changed)
|
||||
if err:
|
||||
return changed, err
|
||||
|
||||
return changed, None
|
||||
|
||||
def apply_profile_to_config(self, profile_name: str | None) -> str | None:
|
||||
"""Apply a profile to the in-memory config, without publishing it.
|
||||
|
||||
Safe to call ahead of activate_profile: both reset to the base config
|
||||
first, so the later call re-derives the same state and still reports
|
||||
every section as changed.
|
||||
|
||||
Returns:
|
||||
None on success, or an error message string on failure.
|
||||
"""
|
||||
err = self._validate_profile_name(profile_name)
|
||||
|
||||
if err:
|
||||
return err
|
||||
|
||||
return self._apply_to_config(profile_name)[1]
|
||||
|
||||
def _persisted_profile_to_restore(self) -> str | None:
|
||||
"""Return the persisted profile name, if it still applies to a camera."""
|
||||
persisted = self.load_persisted_profile()
|
||||
|
||||
if not persisted or not any(
|
||||
persisted in cam.profiles for cam in self.config.cameras.values()
|
||||
):
|
||||
return None
|
||||
|
||||
return persisted
|
||||
|
||||
def restore_persisted_profile_to_config(self) -> None:
|
||||
"""Restore the persisted profile into the config, without publishing.
|
||||
|
||||
Called before worker processes start, so they are handed a config that
|
||||
already carries the profile rather than relying on the broadcast that
|
||||
restore_persisted_profile() sends later.
|
||||
"""
|
||||
persisted = self._persisted_profile_to_restore()
|
||||
|
||||
if persisted is None:
|
||||
return
|
||||
|
||||
err = self.apply_profile_to_config(persisted)
|
||||
|
||||
if err:
|
||||
logger.error("Failed to apply persisted profile '%s': %s", persisted, err)
|
||||
|
||||
def restore_persisted_profile(self) -> None:
|
||||
"""Re-activate the persisted profile once subscribers are connected.
|
||||
|
||||
The config already carries the profile; this pass publishes it for the
|
||||
processes that start before the config can be corrected, and for the
|
||||
retained MQTT states.
|
||||
"""
|
||||
persisted = self._persisted_profile_to_restore()
|
||||
|
||||
if persisted is None:
|
||||
return
|
||||
|
||||
logger.info("Restoring persisted profile '%s'", persisted)
|
||||
# runtime overrides are layered on top by the dispatcher's replay
|
||||
self.activate_profile(persisted, clear_runtime_overrides=False)
|
||||
|
||||
def activate_profile(
|
||||
self,
|
||||
profile_name: str | None,
|
||||
@@ -187,23 +274,16 @@ class ProfileManager:
|
||||
Returns:
|
||||
None on success, or an error message string on failure.
|
||||
"""
|
||||
if profile_name is not None:
|
||||
if profile_name not in self.config.profiles:
|
||||
return (
|
||||
f"Profile '{profile_name}' is not defined in the profiles section"
|
||||
)
|
||||
err = self._validate_profile_name(profile_name)
|
||||
|
||||
if err:
|
||||
return err
|
||||
|
||||
# Track which camera/section pairs get changed for ZMQ publishing
|
||||
changed: dict[str, set[str]] = {}
|
||||
changed, err = self._apply_to_config(profile_name)
|
||||
|
||||
# Reset all cameras to base config
|
||||
self._reset_to_base(changed)
|
||||
|
||||
# Apply new profile overrides if activating
|
||||
if profile_name is not None:
|
||||
err = self._apply_profile_overrides(profile_name, changed)
|
||||
if err:
|
||||
return err
|
||||
if err:
|
||||
return err
|
||||
|
||||
# Publish ZMQ updates only for sections that actually changed
|
||||
self._publish_updates(changed)
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import logging
|
||||
import queue
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from frigate.detectors.detection_api import DetectionApi
|
||||
from frigate.detectors.detector_config import BaseDetectorConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
DETECTOR_KEY = "degirum"
|
||||
|
||||
|
||||
### DETECTOR CONFIG ###
|
||||
class DGDetectorConfig(BaseDetectorConfig):
|
||||
"""DeGirum detector for running models via DeGirum cloud or local inference services."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
title="DeGirum",
|
||||
)
|
||||
|
||||
type: Literal[DETECTOR_KEY]
|
||||
location: str = Field(
|
||||
default=None,
|
||||
title="Inference Location",
|
||||
description="Location of the DeGirim inference engine (e.g. '@cloud', '127.0.0.1').",
|
||||
)
|
||||
zoo: str = Field(
|
||||
default=None,
|
||||
title="Model Zoo",
|
||||
description="Path or URL to the DeGirum model zoo.",
|
||||
)
|
||||
token: str = Field(
|
||||
default=None,
|
||||
title="DeGirum Cloud Token",
|
||||
description="Token for DeGirum Cloud access.",
|
||||
)
|
||||
|
||||
|
||||
### ACTUAL DETECTOR ###
|
||||
class DGDetector(DetectionApi):
|
||||
type_key = DETECTOR_KEY
|
||||
|
||||
def __init__(self, detector_config: DGDetectorConfig):
|
||||
try:
|
||||
import degirum as dg
|
||||
except ModuleNotFoundError:
|
||||
raise ImportError("Unable to import DeGirum detector.") from None
|
||||
|
||||
self._queue = queue.Queue()
|
||||
self._zoo = dg.connect(
|
||||
detector_config.location, detector_config.zoo, detector_config.token
|
||||
)
|
||||
|
||||
logger.debug(f"Models in zoo: {self._zoo.list_models()}")
|
||||
|
||||
self.dg_model = self._zoo.load_model(
|
||||
detector_config.model.path,
|
||||
)
|
||||
|
||||
# Setting input image format to raw reduces preprocessing time
|
||||
self.dg_model.input_image_format = "RAW"
|
||||
|
||||
# Prioritize the most powerful hardware available
|
||||
self.select_best_device_type()
|
||||
# Frigate handles pre processing as long as these are all set
|
||||
input_shape = self.dg_model.input_shape[0]
|
||||
self.model_height = input_shape[1]
|
||||
self.model_width = input_shape[2]
|
||||
|
||||
# Passing in dummy frame so initial connection latency happens in
|
||||
# init function and not during actual prediction
|
||||
frame = np.zeros(
|
||||
(detector_config.model.width, detector_config.model.height, 3),
|
||||
dtype=np.uint8,
|
||||
)
|
||||
# Pass in frame to overcome first frame latency
|
||||
self.dg_model(frame)
|
||||
self.prediction = self.prediction_generator()
|
||||
|
||||
def select_best_device_type(self):
|
||||
"""
|
||||
Helper function that selects fastest hardware available per model runtime
|
||||
"""
|
||||
types = self.dg_model.supported_device_types
|
||||
|
||||
device_map = {
|
||||
"OPENVINO": ["GPU", "NPU", "CPU"],
|
||||
"HAILORT": ["HAILO8L", "HAILO8"],
|
||||
"N2X": ["ORCA1", "CPU"],
|
||||
"ONNX": ["VITIS_NPU", "CPU"],
|
||||
"RKNN": ["RK3566", "RK3568", "RK3588"],
|
||||
"TENSORRT": ["DLA", "GPU", "DLA_ONLY"],
|
||||
"TFLITE": ["ARMNN", "EDGETPU", "CPU"],
|
||||
}
|
||||
|
||||
runtime = types[0].split("/")[0]
|
||||
# Just create an array of format {runtime}/{hardware} for every hardware
|
||||
# in the value for appropriate key in device_map
|
||||
self.dg_model.device_type = [
|
||||
f"{runtime}/{hardware}" for hardware in device_map[runtime]
|
||||
]
|
||||
|
||||
def prediction_generator(self):
|
||||
"""
|
||||
Generator for all incoming frames. By using this generator, we don't have to keep
|
||||
reconnecting our websocket on every "predict" call.
|
||||
"""
|
||||
logger.debug("Prediction generator was called")
|
||||
with self.dg_model as model:
|
||||
while 1:
|
||||
logger.info(f"q size before calling get: {self._queue.qsize()}")
|
||||
data = self._queue.get(block=True)
|
||||
logger.info(f"q size after calling get: {self._queue.qsize()}")
|
||||
logger.debug(
|
||||
f"Data we're passing into model predict: {data}, shape of data: {data.shape}"
|
||||
)
|
||||
result = model.predict(data)
|
||||
logger.debug(f"Prediction result: {result}")
|
||||
yield result
|
||||
|
||||
def detect_raw(self, tensor_input):
|
||||
# Reshaping tensor to work with pysdk
|
||||
truncated_input = tensor_input.reshape(tensor_input.shape[1:])
|
||||
logger.debug(f"Detect raw was called for tensor input: {tensor_input}")
|
||||
|
||||
# add tensor_input to input queue
|
||||
self._queue.put(truncated_input)
|
||||
logger.debug(f"Queue size after adding truncated input: {self._queue.qsize()}")
|
||||
|
||||
# define empty detection result
|
||||
detections = np.zeros((20, 6), np.float32)
|
||||
# grab prediction
|
||||
res = next(self.prediction)
|
||||
|
||||
# If we have an empty prediction, return immediately
|
||||
if len(res.results) == 0 or len(res.results[0]) == 0:
|
||||
return detections
|
||||
|
||||
i = 0
|
||||
for result in res.results:
|
||||
if i >= 20:
|
||||
break
|
||||
|
||||
detections[i] = [
|
||||
result["category_id"],
|
||||
float(result["score"]),
|
||||
result["bbox"][1] / self.model_height,
|
||||
result["bbox"][0] / self.model_width,
|
||||
result["bbox"][3] / self.model_height,
|
||||
result["bbox"][2] / self.model_width,
|
||||
]
|
||||
i += 1
|
||||
|
||||
logger.debug(f"Detections output: {detections}")
|
||||
return detections
|
||||
@@ -9,6 +9,7 @@ from pydantic import ConfigDict, Field
|
||||
|
||||
from frigate.detectors.detection_api import DetectionApi
|
||||
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
|
||||
from frigate.util.model import xyxy_to_xywh_for_nms
|
||||
|
||||
try:
|
||||
from tflite_runtime.interpreter import Interpreter, load_delegate
|
||||
@@ -297,7 +298,7 @@ class EdgeTpuTfl(DetectionApi):
|
||||
# until after filtering out redundant boxes
|
||||
# Shift the logit scores to be non-negative (required by cv2)
|
||||
indices = cv2.dnn.NMSBoxes(
|
||||
bboxes=boxes_filtered_decoded,
|
||||
bboxes=xyxy_to_xywh_for_nms(boxes_filtered_decoded),
|
||||
scores=max_scores_filtered_shiftedpositive,
|
||||
score_threshold=(
|
||||
self.min_logit_value + self.logit_shift_to_positive_values
|
||||
|
||||
@@ -17,6 +17,7 @@ from frigate.detectors.detector_config import (
|
||||
ModelTypeEnum,
|
||||
)
|
||||
from frigate.util.file import FileLock
|
||||
from frigate.util.model import xyxy_to_xywh_for_nms
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -581,7 +582,7 @@ class MemryXDetector(DetectionApi):
|
||||
# Convert coordinates to integers
|
||||
x_min, y_min, x_max, y_max = map(int, [x_min, y_min, x_max, y_max])
|
||||
|
||||
# Append valid detections [class_id, confidence, x, y, width, height]
|
||||
# Append valid detections [class_id, confidence, x_min, y_min, x_max, y_max]
|
||||
detections.append([class_id, confidence, x_min, y_min, x_max, y_max])
|
||||
|
||||
final_detections = np.zeros((20, 6), np.float32)
|
||||
@@ -595,7 +596,7 @@ class MemryXDetector(DetectionApi):
|
||||
detections = np.array(detections, dtype=np.float32)
|
||||
|
||||
# Apply Non-Maximum Suppression (NMS)
|
||||
bboxes = detections[:, 2:6].tolist() # (x_min, y_min, width, height)
|
||||
bboxes = xyxy_to_xywh_for_nms(detections[:, 2:6])
|
||||
scores = detections[:, 1].tolist() # Confidence scores
|
||||
|
||||
indices = cv2.dnn.NMSBoxes(bboxes, scores, 0.45, 0.5)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -12,7 +12,7 @@ from frigate.const import MODEL_CACHE_DIR, SUPPORTED_RK_SOCS
|
||||
from frigate.detectors.detection_api import DetectionApi
|
||||
from frigate.detectors.detection_runners import RKNNModelRunner
|
||||
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
|
||||
from frigate.util.model import post_process_yolo
|
||||
from frigate.util.model import post_process_yolo, xyxy_to_xywh_for_nms
|
||||
from frigate.util.rknn_converter import auto_convert_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -285,7 +285,7 @@ class Rknn(DetectionApi):
|
||||
|
||||
# run nms
|
||||
indices = cv2.dnn.NMSBoxes(
|
||||
bboxes=boxes,
|
||||
bboxes=xyxy_to_xywh_for_nms(boxes),
|
||||
scores=scores,
|
||||
score_threshold=0.4,
|
||||
nms_threshold=0.4,
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-8
@@ -625,14 +625,18 @@ class OnvifController:
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
self.ptz_metrics[camera_name].motor_stopped.clear()
|
||||
logger.debug(
|
||||
f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name].frame_time.value}"
|
||||
)
|
||||
self.ptz_metrics[camera_name].start_time.value = self.ptz_metrics[
|
||||
camera_name
|
||||
].frame_time.value
|
||||
self.ptz_metrics[camera_name].stop_time.value = 0
|
||||
|
||||
# only track start_time for autotracking
|
||||
if self.ptz_metrics[camera_name].autotracker_enabled.value:
|
||||
self.ptz_metrics[camera_name].motor_stopped.clear()
|
||||
logger.debug(
|
||||
f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name].frame_time.value}"
|
||||
)
|
||||
self.ptz_metrics[camera_name].start_time.value = self.ptz_metrics[
|
||||
camera_name
|
||||
].frame_time.value
|
||||
self.ptz_metrics[camera_name].stop_time.value = 0
|
||||
|
||||
move_request = self.cams[camera_name]["relative_move_request"]
|
||||
|
||||
# function takes in -1 to 1 for pan and tilt, interpolate to the values of the camera.
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Tests that the internal port trusted by /auth cannot be moved at runtime."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import ruamel.yaml
|
||||
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
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.const import JWT_SECRET_ENV_VAR
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
|
||||
class TestAuthInternalPort(BaseTestHttp):
|
||||
"""/auth grants anonymous admin by port, so that port must stay put.
|
||||
|
||||
nginx binds its listeners once at container start and never reloads them,
|
||||
but /api/config/set can swap the live config object mid-process. If /auth
|
||||
read the port off the live config, saving networking.listen.internal would
|
||||
hand unauthenticated admin to whoever can reach the external port.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||
self.minimal_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"auth": {"enabled": True},
|
||||
"networking": {"listen": {"internal": 5000, "external": 8971}},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
def _create_app(self):
|
||||
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||
mock_publisher.publisher = MagicMock()
|
||||
|
||||
app = create_fastapi_app(
|
||||
FrigateConfig(**self.minimal_config),
|
||||
self.db,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
mock_publisher,
|
||||
None,
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
async def mock_get_current_user(request: Request):
|
||||
return {
|
||||
"username": request.headers.get("remote-user"),
|
||||
"role": request.headers.get("remote-role"),
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
def _write_config_file(self):
|
||||
"""Write the minimal config to a temp YAML file and return the path."""
|
||||
yaml = ruamel.yaml.YAML()
|
||||
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
|
||||
yaml.dump(self.minimal_config, f)
|
||||
f.close()
|
||||
return f.name
|
||||
|
||||
def test_internal_port_is_anonymous_admin(self):
|
||||
app = self._create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.get("/auth", headers={"x-server-port": "5000"})
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.headers["remote-user"], "anonymous")
|
||||
self.assertEqual(resp.headers["remote-role"], "admin")
|
||||
|
||||
def test_external_port_requires_auth(self):
|
||||
app = self._create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.get("/auth", headers={"x-server-port": "8971"})
|
||||
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
|
||||
def test_swapped_config_does_not_move_the_trusted_port(self):
|
||||
"""The live config is not what /auth trusts.
|
||||
|
||||
Stands in for every path that can rebind app.frigate_config while the
|
||||
process runs, whatever restart flag the caller claimed.
|
||||
"""
|
||||
app = self._create_app()
|
||||
|
||||
swapped = FrigateConfig(
|
||||
**{
|
||||
**self.minimal_config,
|
||||
"networking": {"listen": {"internal": 8971, "external": 5000}},
|
||||
}
|
||||
)
|
||||
app.frigate_config = swapped
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.get("/auth", headers={"x-server-port": "8971"})
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
|
||||
# nginx is still listening where it was told to at boot
|
||||
resp = client.get("/auth", headers={"x-server-port": "5000"})
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.headers["remote-role"], "admin")
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_config_set_rejects_internal_matching_external(self, mock_find_config):
|
||||
"""Saving the internal port onto the external one is refused outright."""
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app = self._create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"networking": {"listen": {"internal": 8971}}},
|
||||
"update_topic": "config/networking",
|
||||
"requires_restart": 1,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertFalse(resp.json()["success"])
|
||||
|
||||
# the rejected save must not have reached the live config
|
||||
self.assertEqual(
|
||||
app.frigate_config.networking.listen.internal_port, 5000
|
||||
)
|
||||
|
||||
resp = client.get("/auth", headers={"x-server-port": "8971"})
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
|
||||
with open(config_path) as f:
|
||||
self.assertNotIn("8971", f.read().split("external")[0])
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.app import FrigateApp
|
||||
from frigate.comms.dispatcher import Dispatcher
|
||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||
|
||||
@@ -363,5 +364,94 @@ class TestReapplyRuntimeStateToConfig(unittest.TestCase):
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
|
||||
class TestStartupAppliesConfigLayersBeforeWorkersStart(unittest.TestCase):
|
||||
"""Both layers must reach the config before config-carrying workers start.
|
||||
|
||||
A worker started before a layer is applied keeps the yaml value for the
|
||||
rest of the session: the config_updater broadcast sent later is dropped
|
||||
for subscribers that have not connected yet, and nothing re-sends it.
|
||||
"""
|
||||
|
||||
CONFIG_LAYERS = (
|
||||
"profile_manager.restore_persisted_profile_to_config",
|
||||
"dispatcher.reapply_runtime_state_to_config",
|
||||
)
|
||||
|
||||
# started with a copy of the camera config
|
||||
CONFIG_CARRYING_WORKERS = (
|
||||
"start_video_output_processor",
|
||||
"start_ptz_autotracker",
|
||||
"start_detected_frames_processor",
|
||||
"start_camera_processor",
|
||||
"start_audio_processor",
|
||||
)
|
||||
|
||||
def _start_call_order(self) -> list[str]:
|
||||
"""Return the names FrigateApp.start() calls, in order."""
|
||||
app = MagicMock()
|
||||
|
||||
with (
|
||||
patch("frigate.app.set_file_limit"),
|
||||
patch("frigate.app.cleanup_replay_cameras"),
|
||||
patch("frigate.app.reap_stale_exports"),
|
||||
patch("frigate.app.create_fastapi_app"),
|
||||
patch("frigate.app.uvicorn"),
|
||||
):
|
||||
FrigateApp.start(app)
|
||||
|
||||
return [name for name, _, _ in app.mock_calls]
|
||||
|
||||
def test_applied_before_any_config_carrying_worker(self) -> None:
|
||||
order = self._start_call_order()
|
||||
|
||||
for layer in self.CONFIG_LAYERS:
|
||||
for worker in self.CONFIG_CARRYING_WORKERS:
|
||||
self.assertLess(order.index(layer), order.index(worker))
|
||||
|
||||
def test_applied_after_the_dispatcher_exists(self) -> None:
|
||||
order = self._start_call_order()
|
||||
|
||||
for layer in self.CONFIG_LAYERS:
|
||||
self.assertLess(order.index("init_dispatcher"), order.index(layer))
|
||||
|
||||
def test_applied_after_the_profile_base_is_snapshotted(self) -> None:
|
||||
# ProfileManager snapshots the config as the "no profile" base that
|
||||
# deactivation resets to, so neither layer may be in the config yet
|
||||
order = self._start_call_order()
|
||||
|
||||
for layer in self.CONFIG_LAYERS:
|
||||
self.assertLess(order.index("init_profile_manager"), order.index(layer))
|
||||
|
||||
def test_layers_applied_in_order(self) -> None:
|
||||
# a runtime toggle is the layer the user set last, so it goes on top
|
||||
order = self._start_call_order()
|
||||
|
||||
self.assertLess(
|
||||
order.index("profile_manager.restore_persisted_profile_to_config"),
|
||||
order.index("dispatcher.reapply_runtime_state_to_config"),
|
||||
)
|
||||
|
||||
def test_overrides_still_re_applied_after_the_profile_is_restored(self) -> None:
|
||||
# activation resets the sections it owns to the base first, so the
|
||||
# overrides have to land on top again
|
||||
order = self._start_call_order()
|
||||
|
||||
self.assertLess(
|
||||
order.index("profile_manager.restore_persisted_profile"),
|
||||
order.index("dispatcher.restore_runtime_state"),
|
||||
)
|
||||
|
||||
def test_broadcast_replay_still_runs_at_the_end(self) -> None:
|
||||
# the broadcast is the only channel for the recording, review, and
|
||||
# embeddings processes, which start before the config can be corrected
|
||||
order = self._start_call_order()
|
||||
|
||||
for replay in (
|
||||
"profile_manager.restore_persisted_profile",
|
||||
"dispatcher.restore_runtime_state",
|
||||
):
|
||||
self.assertLess(order.index("start_audio_processor"), order.index(replay))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Tests for networking config validation."""
|
||||
|
||||
import unittest
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from frigate.config.network import ListenConfig
|
||||
|
||||
|
||||
class TestListenConfig(unittest.TestCase):
|
||||
def test_defaults_are_distinct(self):
|
||||
listen = ListenConfig()
|
||||
|
||||
self.assertEqual(listen.internal_port, 5000)
|
||||
self.assertEqual(listen.external_port, 8971)
|
||||
|
||||
def test_address_and_port_string_is_parsed(self):
|
||||
listen = ListenConfig(internal="127.0.0.1:5000", external="0.0.0.0:8971")
|
||||
|
||||
self.assertEqual(listen.internal_port, 5000)
|
||||
self.assertEqual(listen.external_port, 8971)
|
||||
|
||||
def test_identical_ports_rejected(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
ListenConfig(internal=8971, external=8971)
|
||||
|
||||
def test_same_port_on_different_addresses_rejected(self):
|
||||
# nginx would accept these as distinct listeners, but /auth decides on
|
||||
# the port alone, so the external one would inherit anonymous admin
|
||||
with self.assertRaises(ValidationError):
|
||||
ListenConfig(internal="127.0.0.1:8971", external="0.0.0.0:8971")
|
||||
|
||||
def test_distinct_ports_accepted(self):
|
||||
listen = ListenConfig(internal=5001, external="0.0.0.0:8971")
|
||||
|
||||
self.assertEqual(listen.internal_port, 5001)
|
||||
self.assertEqual(listen.external_port, 8971)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Tests for detector post-processing NMS box format handling.
|
||||
|
||||
cv2.dnn.NMSBoxes expects boxes as [x, y, width, height]. Passing corner
|
||||
coordinates [x1, y1, x2, y2] makes OpenCV treat x2/y2 as width/height,
|
||||
inflating every box toward the bottom-right by its distance from the origin.
|
||||
Two well separated objects far from the origin then appear to overlap and the
|
||||
lower scoring one is silently suppressed.
|
||||
|
||||
The regression geometry used throughout: two boxes with zero true overlap,
|
||||
A = (393, 499, 484, 620) and B = (527, 499, 618, 620) in a 640x640 input
|
||||
(43 px gap). Misread as [x, y, w, h] their IoU is 0.465, above the 0.4 NMS
|
||||
threshold, so the buggy format drops the lower scoring box while correct
|
||||
conversion keeps both.
|
||||
"""
|
||||
|
||||
import math
|
||||
import unittest
|
||||
from queue import Queue
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.detectors.plugins.memryx import MemryXDetector
|
||||
from frigate.util.model import (
|
||||
post_process_dfine,
|
||||
post_process_rfdetr,
|
||||
post_process_yolo,
|
||||
post_process_yolox,
|
||||
)
|
||||
|
||||
WIDTH = 640
|
||||
HEIGHT = 640
|
||||
|
||||
# box A: xyxy (393, 499, 484, 620) as center format
|
||||
A_CX, A_CY, A_W, A_H = 438.5, 559.5, 91.0, 121.0
|
||||
# box B: xyxy (527, 499, 618, 620) as center format
|
||||
B_CX, B_CY, B_W, B_H = 572.5, 559.5, 91.0, 121.0
|
||||
|
||||
# expected normalized output rows: [class_id, conf, y1, x1, y2, x2]
|
||||
A_ROW = [499 / 640, 393 / 640, 620 / 640, 484 / 640]
|
||||
B_ROW = [499 / 640, 527 / 640, 620 / 640, 618 / 640]
|
||||
|
||||
|
||||
def kept(detections: np.ndarray) -> np.ndarray:
|
||||
"""Rows of the padded (20, 6) output that hold real detections."""
|
||||
return detections[detections[:, 1] > 0]
|
||||
|
||||
|
||||
class TestYoloNmsPostProcess(unittest.TestCase):
|
||||
def _single_output(self, rows: list[list[float]]) -> list[np.ndarray]:
|
||||
"""Build a single-tensor YOLO output (1, attrs, anchors) from
|
||||
[cx, cy, w, h, class scores...] rows, padded with empty anchors."""
|
||||
anchors = np.zeros((10, len(rows[0])), dtype=np.float32)
|
||||
anchors[: len(rows)] = np.array(rows, dtype=np.float32)
|
||||
return [anchors.T[np.newaxis, ...]]
|
||||
|
||||
def test_keeps_separated_objects_far_from_origin(self):
|
||||
output = self._single_output(
|
||||
[
|
||||
[A_CX, A_CY, A_W, A_H, 0.90, 0.0],
|
||||
[B_CX, B_CY, B_W, B_H, 0.0, 0.85],
|
||||
]
|
||||
)
|
||||
|
||||
detections = kept(post_process_yolo(output, WIDTH, HEIGHT))
|
||||
|
||||
self.assertEqual(len(detections), 2)
|
||||
np.testing.assert_allclose(detections[0], [0, 0.90, *A_ROW], atol=2e-3)
|
||||
np.testing.assert_allclose(detections[1], [1, 0.85, *B_ROW], atol=2e-3)
|
||||
|
||||
def test_still_suppresses_true_duplicates(self):
|
||||
# same object twice, shifted 4 px: true IoU 0.92, must dedupe to one
|
||||
output = self._single_output(
|
||||
[
|
||||
[A_CX, A_CY, A_W, A_H, 0.90, 0.0],
|
||||
[A_CX + 4, A_CY, A_W, A_H, 0.85, 0.0],
|
||||
]
|
||||
)
|
||||
|
||||
detections = kept(post_process_yolo(output, WIDTH, HEIGHT))
|
||||
|
||||
self.assertEqual(len(detections), 1)
|
||||
np.testing.assert_allclose(detections[0], [0, 0.90, *A_ROW], atol=2e-3)
|
||||
|
||||
|
||||
class TestMultipartYoloPostProcess(unittest.TestCase):
|
||||
def _multipart_output(self) -> list[np.ndarray]:
|
||||
"""Build a 3-scale anchor-based YOLO output containing boxes A and B,
|
||||
both decoded through anchor 0 of the stride-32 scale."""
|
||||
outputs = [
|
||||
np.zeros((1, 255, 80, 80), dtype=np.float32),
|
||||
np.zeros((1, 255, 40, 40), dtype=np.float32),
|
||||
np.zeros((1, 255, 20, 20), dtype=np.float32),
|
||||
]
|
||||
stride, (anchor_w, anchor_h) = 32, (142, 110)
|
||||
|
||||
for cx, cy, w, h, conf, class_channel in [
|
||||
(A_CX, A_CY, A_W, A_H, 0.95, 5), # class 0
|
||||
(B_CX, B_CY, B_W, B_H, 0.90, 6), # class 1
|
||||
]:
|
||||
cell_x, cell_y = int(cx // stride), int(cy // stride)
|
||||
dx = (cx / stride - cell_x + 0.5) / 2
|
||||
dy = (cy / stride - cell_y + 0.5) / 2
|
||||
dw = math.sqrt(w / anchor_w) / 2
|
||||
dh = math.sqrt(h / anchor_h) / 2
|
||||
# anchor 0 occupies channels 0-84 of the 255 channel tensor
|
||||
outputs[2][0, 0:4, cell_y, cell_x] = [dx, dy, dw, dh]
|
||||
outputs[2][0, 4, cell_y, cell_x] = conf
|
||||
outputs[2][0, class_channel, cell_y, cell_x] = 1.0
|
||||
|
||||
return outputs
|
||||
|
||||
def test_keeps_separated_objects_far_from_origin(self):
|
||||
detections = kept(post_process_yolo(self._multipart_output(), WIDTH, HEIGHT))
|
||||
|
||||
self.assertEqual(len(detections), 2)
|
||||
np.testing.assert_allclose(detections[0], [0, 0.95, *A_ROW], atol=2e-3)
|
||||
np.testing.assert_allclose(detections[1], [1, 0.90, *B_ROW], atol=2e-3)
|
||||
|
||||
def test_empty_output_returns_no_detections(self):
|
||||
outputs = [
|
||||
np.zeros((1, 255, 80, 80), dtype=np.float32),
|
||||
np.zeros((1, 255, 40, 40), dtype=np.float32),
|
||||
np.zeros((1, 255, 20, 20), dtype=np.float32),
|
||||
]
|
||||
|
||||
detections = kept(post_process_yolo(outputs, WIDTH, HEIGHT))
|
||||
|
||||
self.assertEqual(len(detections), 0)
|
||||
|
||||
|
||||
class TestYoloxPostProcess(unittest.TestCase):
|
||||
def test_keeps_separated_objects_far_from_origin(self):
|
||||
# with zero grids and unit strides the decode reduces to
|
||||
# cx = raw cx and w = exp(raw w)
|
||||
rows = np.zeros((10, 7), dtype=np.float32)
|
||||
rows[0] = [A_CX, A_CY, math.log(A_W), math.log(A_H), 1.0, 0.90, 0.0]
|
||||
rows[1] = [B_CX, B_CY, math.log(B_W), math.log(B_H), 1.0, 0.0, 0.85]
|
||||
predictions = rows[np.newaxis, ...]
|
||||
grids = np.zeros((1, 10, 2), dtype=np.float32)
|
||||
expanded_strides = np.ones((1, 10, 1), dtype=np.float32)
|
||||
|
||||
detections = kept(
|
||||
post_process_yolox(predictions, WIDTH, HEIGHT, grids, expanded_strides)
|
||||
)
|
||||
|
||||
self.assertEqual(len(detections), 2)
|
||||
np.testing.assert_allclose(detections[0], [0, 0.90, *A_ROW], atol=2e-3)
|
||||
np.testing.assert_allclose(detections[1], [1, 0.85, *B_ROW], atol=2e-3)
|
||||
|
||||
|
||||
class TestDfinePostProcess(unittest.TestCase):
|
||||
def test_keeps_separated_objects_far_from_origin(self):
|
||||
# D-FINE emits absolute pixel xyxy boxes alongside labels and scores
|
||||
labels = np.zeros((1, 10), dtype=np.int64)
|
||||
labels[0, 1] = 1
|
||||
boxes = np.zeros((1, 10, 4), dtype=np.float32)
|
||||
boxes[0, 0] = [393, 499, 484, 620]
|
||||
boxes[0, 1] = [527, 499, 618, 620]
|
||||
scores = np.zeros((1, 10), dtype=np.float32)
|
||||
scores[0, 0] = 0.90
|
||||
scores[0, 1] = 0.85
|
||||
|
||||
detections = kept(post_process_dfine([labels, boxes, scores], WIDTH, HEIGHT))
|
||||
|
||||
self.assertEqual(len(detections), 2)
|
||||
np.testing.assert_allclose(detections[0], [0, 0.90, *A_ROW], atol=2e-3)
|
||||
np.testing.assert_allclose(detections[1], [1, 0.85, *B_ROW], atol=2e-3)
|
||||
|
||||
|
||||
class TestRfdetrPostProcess(unittest.TestCase):
|
||||
def test_keeps_separated_objects_far_from_origin(self):
|
||||
# RF-DETR emits normalized center format boxes and class logits where
|
||||
# logit index 0 is the background class
|
||||
boxes = np.zeros((1, 10, 4), dtype=np.float32)
|
||||
boxes[0, 0] = [A_CX / WIDTH, A_CY / HEIGHT, A_W / WIDTH, A_H / HEIGHT]
|
||||
boxes[0, 1] = [B_CX / WIDTH, B_CY / HEIGHT, B_W / WIDTH, B_H / HEIGHT]
|
||||
# background heavy logits everywhere, then two confident objects
|
||||
logits = np.tile(np.array([10.0, 0.0, 0.0], dtype=np.float32), (1, 10, 1))
|
||||
logits[0, 0] = [0.0, 4.0, 0.0] # class 0 after background offset
|
||||
logits[0, 1] = [0.0, 0.0, 3.5] # class 1 after background offset
|
||||
|
||||
detections = kept(post_process_rfdetr([boxes, logits]))
|
||||
|
||||
conf_a = math.exp(4.0) / (math.exp(4.0) + 2)
|
||||
conf_b = math.exp(3.5) / (math.exp(3.5) + 2)
|
||||
self.assertEqual(len(detections), 2)
|
||||
np.testing.assert_allclose(detections[0], [0, conf_a, *A_ROW], atol=2e-3)
|
||||
np.testing.assert_allclose(detections[1], [1, conf_b, *B_ROW], atol=2e-3)
|
||||
|
||||
|
||||
class TestMemryxSsdlitePostProcess(unittest.TestCase):
|
||||
def test_keeps_separated_objects_far_from_origin(self):
|
||||
# the NMS math runs on the host CPU, so the real method is testable
|
||||
# without MemryX hardware; it only needs the model dimensions and
|
||||
# the output queue
|
||||
detector = object.__new__(MemryXDetector)
|
||||
detector.memx_model_width = WIDTH
|
||||
detector.memx_model_height = HEIGHT
|
||||
detector.output_queue = Queue()
|
||||
|
||||
# this path uses a 0.5 NMS threshold, so use a tighter pair: zero
|
||||
# true overlap (10 px gap), IoU 0.69 when misread as [x, y, w, h]
|
||||
dets = np.zeros((1, 10, 5), dtype=np.float32)
|
||||
dets[0, 0] = [480, 500, 540, 620, 0.90]
|
||||
dets[0, 1] = [550, 500, 610, 620, 0.85]
|
||||
labels = np.zeros((1, 10), dtype=np.float32)
|
||||
labels[0, 1] = 1
|
||||
|
||||
detector.post_process_ssdlite([dets, labels])
|
||||
detections = kept(detector.output_queue.get())
|
||||
|
||||
self.assertEqual(len(detections), 2)
|
||||
np.testing.assert_allclose(
|
||||
detections[0],
|
||||
[0, 0.90, 500 / 640, 480 / 640, 620 / 640, 540 / 640],
|
||||
atol=2e-3,
|
||||
)
|
||||
np.testing.assert_allclose(
|
||||
detections[1],
|
||||
[1, 0.85, 500 / 640, 550 / 640, 620 / 640, 610 / 640],
|
||||
atol=2e-3,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -785,6 +785,98 @@ class TestProfileManager(unittest.TestCase):
|
||||
manager.activate_profile("armed", clear_runtime_overrides=False)
|
||||
dispatcher.clear_runtime_state.assert_not_called()
|
||||
|
||||
def test_apply_profile_to_config_mutates_the_config(self):
|
||||
"""The config-only half applies the same overrides as activation."""
|
||||
err = self.manager.apply_profile_to_config("armed")
|
||||
assert err is None
|
||||
|
||||
front = self.config.cameras["front"]
|
||||
assert front.notifications.enabled is True
|
||||
assert front.objects.track == ["person", "car", "package"]
|
||||
|
||||
def test_apply_profile_to_config_makes_no_zmq_mqtt_or_disk_writes(self):
|
||||
"""Workers are started with the values, so nothing is published yet."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
|
||||
with patch.object(ProfileManager, "_persist_active_profile") as mock_persist:
|
||||
manager.apply_profile_to_config("armed")
|
||||
|
||||
self.mock_updater.publish_update.assert_not_called()
|
||||
dispatcher.publish.assert_not_called()
|
||||
mock_persist.assert_not_called()
|
||||
# bookkeeping stays with activate_profile
|
||||
assert self.config.active_profile is None
|
||||
|
||||
def test_apply_profile_to_config_rejects_an_unknown_profile(self):
|
||||
err = self.manager.apply_profile_to_config("nonexistent")
|
||||
assert err is not None
|
||||
assert "not defined" in err
|
||||
|
||||
def test_restore_persisted_profile_to_config_applies_it(self):
|
||||
"""The startup config pass restores what was persisted."""
|
||||
with patch.object(
|
||||
ProfileManager, "load_persisted_profile", return_value="armed"
|
||||
):
|
||||
self.manager.restore_persisted_profile_to_config()
|
||||
|
||||
assert self.config.cameras["front"].notifications.enabled is True
|
||||
# still the config-only half, so nothing is published or persisted
|
||||
self.mock_updater.publish_update.assert_not_called()
|
||||
assert self.config.active_profile is None
|
||||
|
||||
def test_restore_persisted_profile_to_config_no_op_when_none_persisted(self):
|
||||
with patch.object(ProfileManager, "load_persisted_profile", return_value=None):
|
||||
self.manager.restore_persisted_profile_to_config()
|
||||
|
||||
assert self.config.cameras["front"].notifications.enabled is False
|
||||
|
||||
def test_restore_persisted_profile_to_config_ignores_a_stale_name(self):
|
||||
"""A profile no longer offered by any camera must not be applied."""
|
||||
with patch.object(
|
||||
ProfileManager, "load_persisted_profile", return_value="ghost"
|
||||
):
|
||||
self.manager.restore_persisted_profile_to_config()
|
||||
|
||||
assert self.config.cameras["front"].notifications.enabled is False
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_restore_persisted_profile_activates_and_publishes(self, mock_persist):
|
||||
"""The startup publish pass runs a full activation."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
|
||||
with patch.object(
|
||||
ProfileManager, "load_persisted_profile", return_value="armed"
|
||||
):
|
||||
manager.restore_persisted_profile()
|
||||
|
||||
assert self.config.active_profile == "armed"
|
||||
self.mock_updater.publish_update.assert_called()
|
||||
# a startup replay must not wipe the runtime overrides layered on top
|
||||
dispatcher.clear_runtime_state.assert_not_called()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_activation_after_apply_still_publishes_every_section(self, mock_persist):
|
||||
"""Re-deriving the same state must not skip the broadcast.
|
||||
|
||||
The processes that started before the config was corrected have no
|
||||
other channel.
|
||||
"""
|
||||
self.manager.apply_profile_to_config("armed")
|
||||
self.mock_updater.publish_update.reset_mock()
|
||||
|
||||
err = self.manager.activate_profile("armed", clear_runtime_overrides=False)
|
||||
assert err is None
|
||||
|
||||
published = {
|
||||
call.args[0].update_type.name
|
||||
for call in self.mock_updater.publish_update.call_args_list
|
||||
}
|
||||
assert "notifications" in published
|
||||
assert "objects" in published
|
||||
assert self.config.active_profile == "armed"
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_update_config_preserves_runtime_state_with_active_profile(
|
||||
self, mock_persist
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for ONVIF init state that must not depend on the autotracking config.
|
||||
"""Tests for ONVIF state that must not depend on the autotracking config.
|
||||
|
||||
Regression coverage for a camera that is initialized while autotracking is off and
|
||||
has it enabled later, which is the normal wizard flow: set the camera up first,
|
||||
@@ -10,12 +10,17 @@ the tracking thread.
|
||||
|
||||
The request objects are built from the locally parsed WSDL and cost no network, so
|
||||
they are always created and init=True now implies they exist.
|
||||
|
||||
Also covers the inverse direction: the ptz movement timestamps must not be written
|
||||
for a camera that has autotracking off, because nothing clears them back out.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.ptz.autotrack import ptz_moving_at_frame_time
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
|
||||
CAMERA = "ptz_cam"
|
||||
@@ -97,6 +102,36 @@ def _make_controller(autotracking_enabled: bool) -> OnvifController:
|
||||
return controller
|
||||
|
||||
|
||||
def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
|
||||
"""Build an already initialized controller for a camera that supports relative
|
||||
FOV movement, with real metrics so the timestamp writes can be asserted on."""
|
||||
config = _config(autotracking_enabled)
|
||||
controller = OnvifController.__new__(OnvifController)
|
||||
controller.config = config
|
||||
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
|
||||
controller.failed_cams = {}
|
||||
|
||||
ptz = MagicMock()
|
||||
ptz.RelativeMove = AsyncMock()
|
||||
controller.cams = {
|
||||
CAMERA: {
|
||||
"init": True,
|
||||
"active": False,
|
||||
"ptz": ptz,
|
||||
"features": ["pt", "pt-r-fov"],
|
||||
"relative_move_request": MagicMock(),
|
||||
"relative_fov_range": {
|
||||
"XRange": {"Min": -1.0, "Max": 1.0},
|
||||
"YRange": {"Min": -1.0, "Max": 1.0},
|
||||
},
|
||||
}
|
||||
}
|
||||
controller.ptz_metrics = {
|
||||
CAMERA: PTZMetrics(autotracker_enabled=autotracking_enabled)
|
||||
}
|
||||
return controller
|
||||
|
||||
|
||||
class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_status_request_created_when_autotracking_disabled(self) -> None:
|
||||
# the wizard flow: onvif configured first, autotracking enabled later
|
||||
@@ -143,5 +178,61 @@ class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
|
||||
ptz.GetStatus.assert_not_called()
|
||||
|
||||
|
||||
class TestManualRelativeMoveMetrics(unittest.IsolatedAsyncioTestCase):
|
||||
"""A manual move from the UI (click to move, drag to zoom) sends move_relative
|
||||
for any camera that advertises pt-r-fov, autotracking or not."""
|
||||
|
||||
async def test_metrics_untouched_when_autotracking_disabled(self) -> None:
|
||||
# only camera_maintenance polls get_camera_status, and only for autotracking
|
||||
# cameras, so a manual move that starts the clock here is never stopped
|
||||
controller = _make_move_controller(autotracking_enabled=False)
|
||||
metrics = controller.ptz_metrics[CAMERA]
|
||||
metrics.frame_time.value = 1000.0
|
||||
|
||||
await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1)
|
||||
|
||||
controller.cams[CAMERA]["ptz"].RelativeMove.assert_awaited_once()
|
||||
self.assertEqual(metrics.start_time.value, 0)
|
||||
self.assertEqual(metrics.stop_time.value, 0)
|
||||
self.assertTrue(metrics.motor_stopped.is_set())
|
||||
|
||||
async def test_detection_regions_not_suppressed_after_manual_move(self) -> None:
|
||||
# the symptom of the bug: object detection stops entirely because motion
|
||||
# boxes are never promoted to detection regions again
|
||||
controller = _make_move_controller(autotracking_enabled=False)
|
||||
metrics = controller.ptz_metrics[CAMERA]
|
||||
metrics.frame_time.value = 1000.0
|
||||
|
||||
await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1)
|
||||
|
||||
for later_frame_time in (1001.0, 1060.0, 4600.0):
|
||||
with self.subTest(frame_time=later_frame_time):
|
||||
self.assertFalse(
|
||||
ptz_moving_at_frame_time(
|
||||
later_frame_time,
|
||||
metrics.start_time.value,
|
||||
metrics.stop_time.value,
|
||||
)
|
||||
)
|
||||
|
||||
async def test_metrics_written_when_autotracking_enabled(self) -> None:
|
||||
# get_camera_status resets stop_time once the camera reports IDLE, so the
|
||||
# autotracking path keeps its motion estimation timestamps
|
||||
controller = _make_move_controller(autotracking_enabled=True)
|
||||
metrics = controller.ptz_metrics[CAMERA]
|
||||
metrics.frame_time.value = 1000.0
|
||||
|
||||
await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1)
|
||||
|
||||
self.assertEqual(metrics.start_time.value, 1000.0)
|
||||
self.assertEqual(metrics.stop_time.value, 0)
|
||||
self.assertFalse(metrics.motor_stopped.is_set())
|
||||
self.assertTrue(
|
||||
ptz_moving_at_frame_time(
|
||||
1001.0, metrics.start_time.value, metrics.stop_time.value
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+37
-5
@@ -16,6 +16,31 @@ logger = logging.getLogger(__name__)
|
||||
### Post Processing
|
||||
|
||||
|
||||
def xyxy_to_xywh_for_nms(boxes: np.ndarray | list) -> np.ndarray:
|
||||
"""Convert [x1, y1, x2, y2] boxes to the [x, y, width, height] format
|
||||
that cv2.dnn.NMSBoxes expects.
|
||||
|
||||
Passing corner coordinates directly makes OpenCV treat x2/y2 as the box
|
||||
size, inflating every box toward the bottom-right by its distance from
|
||||
the origin, which suppresses valid detections near other objects.
|
||||
|
||||
Args:
|
||||
boxes: Array-like of shape (N, 4) in corner format.
|
||||
|
||||
Returns:
|
||||
Float32 array of shape (N, 4) in top-left plus size format.
|
||||
"""
|
||||
boxes = np.asarray(boxes, dtype=np.float32)
|
||||
|
||||
if boxes.size == 0:
|
||||
return np.zeros((0, 4), dtype=np.float32)
|
||||
|
||||
xywh = boxes.copy()
|
||||
xywh[:, 2] -= xywh[:, 0]
|
||||
xywh[:, 3] -= xywh[:, 1]
|
||||
return xywh
|
||||
|
||||
|
||||
def post_process_dfine(
|
||||
tensor_output: np.ndarray, width: int, height: int
|
||||
) -> np.ndarray:
|
||||
@@ -25,7 +50,9 @@ def post_process_dfine(
|
||||
|
||||
input_shape = np.array([height, width, height, width])
|
||||
boxes = np.divide(boxes, input_shape, dtype=np.float32)
|
||||
indices = cv2.dnn.NMSBoxes(boxes, scores, score_threshold=0.4, nms_threshold=0.4)
|
||||
indices = cv2.dnn.NMSBoxes(
|
||||
xyxy_to_xywh_for_nms(boxes), scores, score_threshold=0.4, nms_threshold=0.4
|
||||
)
|
||||
detections = np.zeros((20, 6), np.float32)
|
||||
|
||||
for i, (bbox, confidence, class_id) in enumerate(
|
||||
@@ -78,7 +105,10 @@ def post_process_rfdetr(tensor_output: list[np.ndarray, np.ndarray]) -> np.ndarr
|
||||
|
||||
# apply nms
|
||||
indices = cv2.dnn.NMSBoxes(
|
||||
filtered_boxes, filtered_scores, score_threshold=0.4, nms_threshold=0.4
|
||||
xyxy_to_xywh_for_nms(filtered_boxes),
|
||||
filtered_scores,
|
||||
score_threshold=0.4,
|
||||
nms_threshold=0.4,
|
||||
)
|
||||
detections = np.zeros((20, 6), np.float32)
|
||||
|
||||
@@ -159,7 +189,7 @@ def __post_process_multipart_yolo(
|
||||
all_class_ids.append(class_id)
|
||||
|
||||
indices = cv2.dnn.NMSBoxes(
|
||||
bboxes=all_boxes,
|
||||
bboxes=xyxy_to_xywh_for_nms(all_boxes),
|
||||
scores=all_scores,
|
||||
score_threshold=0.4,
|
||||
nms_threshold=0.4,
|
||||
@@ -206,7 +236,9 @@ def __post_process_nms_yolo(predictions: np.ndarray, width, height) -> np.ndarra
|
||||
boxes = boxes_xyxy
|
||||
|
||||
# run NMS
|
||||
indices = cv2.dnn.NMSBoxes(boxes, scores, score_threshold=0.4, nms_threshold=0.4)
|
||||
indices = cv2.dnn.NMSBoxes(
|
||||
xyxy_to_xywh_for_nms(boxes), scores, score_threshold=0.4, nms_threshold=0.4
|
||||
)
|
||||
detections = np.zeros((20, 6), np.float32)
|
||||
for i, (bbox, confidence, class_id) in enumerate(
|
||||
zip(boxes[indices], scores[indices], class_ids[indices])
|
||||
@@ -258,7 +290,7 @@ def post_process_yolox(
|
||||
scores = scores[np.arange(len(cls_inds)), cls_inds]
|
||||
|
||||
indices = cv2.dnn.NMSBoxes(
|
||||
boxes_xyxy, scores, score_threshold=0.4, nms_threshold=0.4
|
||||
xyxy_to_xywh_for_nms(boxes_xyxy), scores, score_threshold=0.4, nms_threshold=0.4
|
||||
)
|
||||
|
||||
detections = np.zeros((20, 6), np.float32)
|
||||
|
||||
@@ -35,6 +35,11 @@ logger = logging.getLogger(__name__)
|
||||
GRID_SIZE = 8
|
||||
|
||||
|
||||
def create_empty_regions_grid() -> list[list[dict[str, Any]]]:
|
||||
"""Create a region grid with no learned sizes."""
|
||||
return [[{"sizes": []} for _ in range(GRID_SIZE)] for _ in range(GRID_SIZE)]
|
||||
|
||||
|
||||
def get_camera_regions_grid(
|
||||
name: str,
|
||||
detect: DetectConfig,
|
||||
@@ -47,12 +52,7 @@ def get_camera_regions_grid(
|
||||
grid = regions.grid
|
||||
last_update = regions.last_update
|
||||
except DoesNotExist:
|
||||
grid = []
|
||||
for x in range(GRID_SIZE):
|
||||
row = []
|
||||
for y in range(GRID_SIZE):
|
||||
row.append({"sizes": []})
|
||||
grid.append(row)
|
||||
grid = create_empty_regions_grid()
|
||||
last_update = 0
|
||||
|
||||
# get events for timeline entries
|
||||
|
||||
+11
-6
@@ -358,12 +358,17 @@ def process_frames(
|
||||
]
|
||||
|
||||
# only add in the motion boxes when not calibrating and a ptz is not moving via autotracking
|
||||
# ptz_moving_at_frame_time() always returns False for non-autotracking cameras
|
||||
if not motion_detector.is_calibrating() and not ptz_moving_at_frame_time(
|
||||
frame_time,
|
||||
ptz_metrics.start_time.value,
|
||||
ptz_metrics.stop_time.value,
|
||||
):
|
||||
# the ptz timestamps are only maintained while autotracking is on, so gate
|
||||
# on the metric rather than trusting them to be reset otherwise
|
||||
ptz_moving = ptz_metrics.autotracker_enabled.value and (
|
||||
ptz_moving_at_frame_time(
|
||||
frame_time,
|
||||
ptz_metrics.start_time.value,
|
||||
ptz_metrics.stop_time.value,
|
||||
)
|
||||
)
|
||||
|
||||
if not motion_detector.is_calibrating() and not ptz_moving:
|
||||
# find motion boxes that are not inside tracked object regions
|
||||
standalone_motion_boxes = [
|
||||
b for b in motion_boxes if not inside_any(b, regions)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Camera live playback stream settings tests -- MEDIUM tier.
|
||||
*
|
||||
* The live streams field maps a display name to a go2rtc stream. Switching
|
||||
* cameras from the selector keeps the form mounted and only swaps its data, so
|
||||
* the stream name input has to follow the newly selected camera. It used to be
|
||||
* an uncontrolled input, which left the previous camera's stream name on screen
|
||||
* and renamed the wrong key if the stale text was ever committed.
|
||||
*
|
||||
* Renames are committed per keystroke so the section is marked as modified
|
||||
* right away, except while the typed name belongs to another stream, since
|
||||
* renaming onto an existing name merges the two entries.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { configFactory } from "../../fixtures/mock-data/config";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_SCHEMA = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
|
||||
const GO2RTC_STREAMS = {
|
||||
front_door_main: ["rtsp://user:pass@192.168.0.20:554/Stream1"],
|
||||
backyard_main: ["rtsp://user:pass@192.168.0.21:554/Stream1"],
|
||||
};
|
||||
|
||||
const CAMERA_LIVE_STREAMS = {
|
||||
front_door: { front_door: "front_door_main" },
|
||||
backyard: { backyard: "backyard_main" },
|
||||
};
|
||||
|
||||
const SETTINGS_URL = "/settings?page=cameraLivePlayback&camera=front_door";
|
||||
|
||||
async function installRoutes(
|
||||
page: Page,
|
||||
frontDoorStreams: Record<string, string> = CAMERA_LIVE_STREAMS.front_door,
|
||||
) {
|
||||
const config = configFactory({
|
||||
go2rtc: { streams: GO2RTC_STREAMS },
|
||||
cameras: {
|
||||
front_door: { live: { streams: frontDoorStreams } },
|
||||
backyard: { live: { streams: CAMERA_LIVE_STREAMS.backyard } },
|
||||
},
|
||||
});
|
||||
|
||||
let lastSavedConfig: unknown = null;
|
||||
|
||||
await page.route("**/api/config/schema.json", (route) =>
|
||||
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||
);
|
||||
await page.route("**/api/config", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({ json: config });
|
||||
}
|
||||
return route.fulfill({ json: { success: true } });
|
||||
});
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
go2rtc: { streams: GO2RTC_STREAMS },
|
||||
cameras: {
|
||||
front_door: { live: { streams: frontDoorStreams } },
|
||||
backyard: { live: { streams: CAMERA_LIVE_STREAMS.backyard } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/config/set", async (route) => {
|
||||
lastSavedConfig = route.request().postDataJSON();
|
||||
await route.fulfill({ json: { success: true, require_restart: false } });
|
||||
});
|
||||
|
||||
return { capturedConfig: () => lastSavedConfig };
|
||||
}
|
||||
|
||||
async function selectCamera(page: Page, friendlyName: string) {
|
||||
await page.getByRole("button", { name: "Select a camera" }).click();
|
||||
await page.getByRole("switch", { name: friendlyName }).click();
|
||||
}
|
||||
|
||||
function streamNameInputs(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Stream name" });
|
||||
}
|
||||
|
||||
function streamNames(page: Page) {
|
||||
return streamNameInputs(page).evaluateAll((inputs) =>
|
||||
inputs.map((input) => (input as HTMLInputElement).value),
|
||||
);
|
||||
}
|
||||
|
||||
/** Rows render in config order, which is not the order they were declared in. */
|
||||
async function streamNameRow(page: Page, name: string) {
|
||||
await expect.poll(() => streamNames(page)).toContain(name);
|
||||
const names = await streamNames(page);
|
||||
return streamNameInputs(page).nth(names.indexOf(name));
|
||||
}
|
||||
|
||||
test.describe("camera live playback streams @medium", () => {
|
||||
test("switching cameras updates the stream name field", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
const streamName = frigateApp.page.getByRole("textbox", {
|
||||
name: "Stream name",
|
||||
});
|
||||
await expect(streamName).toHaveValue("front_door");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("combobox", { name: "go2rtc stream" }),
|
||||
).toContainText("front_door_main");
|
||||
|
||||
await selectCamera(frigateApp.page, "Backyard");
|
||||
|
||||
await expect(streamName).toHaveValue("backyard");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("combobox", { name: "go2rtc stream" }),
|
||||
).toContainText("backyard_main");
|
||||
});
|
||||
|
||||
test("typing a new name enables Save without leaving the field", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
const save = frigateApp.page.getByRole("button", { name: "Save" });
|
||||
await expect(save).toBeDisabled();
|
||||
|
||||
const streamName = await streamNameRow(frigateApp.page, "front_door");
|
||||
await streamName.click();
|
||||
await frigateApp.page.keyboard.press("End");
|
||||
await frigateApp.page.keyboard.type("_hd");
|
||||
|
||||
// Still focused: the rename is committed per keystroke, not on blur.
|
||||
await expect(save).toBeEnabled();
|
||||
await expect(streamName).toBeFocused();
|
||||
await expect(streamName).toHaveValue("front_door_hd");
|
||||
});
|
||||
|
||||
test("typing through another stream's name keeps both streams", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, {
|
||||
front: "front_door_main",
|
||||
front_door: "backyard_main",
|
||||
});
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
const streamName = await streamNameRow(frigateApp.page, "front_door");
|
||||
await streamName.click();
|
||||
await frigateApp.page.keyboard.press("End");
|
||||
// "front_door" passes through "front", which the other row already uses.
|
||||
await frigateApp.page.keyboard.press("Backspace");
|
||||
await frigateApp.page.keyboard.press("Backspace");
|
||||
await frigateApp.page.keyboard.press("Backspace");
|
||||
await frigateApp.page.keyboard.press("Backspace");
|
||||
await frigateApp.page.keyboard.press("Backspace");
|
||||
await expect(streamName).toHaveValue("front");
|
||||
await frigateApp.page.keyboard.type("yard");
|
||||
await streamName.blur();
|
||||
|
||||
expect(await streamNames(frigateApp.page)).toEqual(["frontyard", "front"]);
|
||||
});
|
||||
|
||||
test("renaming a stream saves the new name for the selected camera", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const capture = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await selectCamera(frigateApp.page, "Backyard");
|
||||
|
||||
const streamName = frigateApp.page.getByRole("textbox", {
|
||||
name: "Stream name",
|
||||
});
|
||||
await expect(streamName).toHaveValue("backyard");
|
||||
await streamName.fill("Backyard HD");
|
||||
// The rename is committed on blur, not on every keystroke.
|
||||
await streamName.blur();
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
|
||||
.toMatchObject({
|
||||
config_data: {
|
||||
cameras: {
|
||||
backyard: {
|
||||
live: { streams: { "Backyard HD": "backyard_main" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -867,8 +867,8 @@
|
||||
"description": "Ordre numèric utilitzat per ordenar la càmera a la interfície d'usuari (taulell de control i llistes per defecte); els nombres més grans apareixen més tard."
|
||||
},
|
||||
"dashboard": {
|
||||
"label": "Mostra a l'interfície d'usuari",
|
||||
"description": "Estableix si aquesta càmera és visible a tot arreu a la interfície d'usuari de la Frigate. Desactivar això requerirà editar manualment la configuració per tornar a veure aquesta càmera a la interfície d'usuari."
|
||||
"label": "Mostra al tauler en directe",
|
||||
"description": "Alterna si aquesta càmera és visible al tauler de control en directe de totes les càmeres per defecte. La càmera roman disponible a tot arreu a la interfície d'usuari, inclosos els grups i la configuració de la càmera."
|
||||
},
|
||||
"review": {
|
||||
"label": "Mostra en la revisió",
|
||||
|
||||
@@ -2338,8 +2338,8 @@
|
||||
"description": "Ordre numèric utilitzat per ordenar la càmera a la interfície d'usuari (taulell de control i llistes per defecte); els nombres més grans apareixen més tard."
|
||||
},
|
||||
"dashboard": {
|
||||
"label": "Mostra a la interfície",
|
||||
"description": "Estableix si aquesta càmera és visible a tot arreu a la interfície d'usuari de Frigate. Desactivar això requerirà editar manualment la configuració per tornar a veure aquesta càmera a la interfície d'usuari."
|
||||
"label": "Mostra al tauler en directe",
|
||||
"description": "Alterna si aquesta càmera és visible al tauler de control en directe de totes les càmeres per defecte. La càmera roman disponible a tot arreu a la interfície d'usuari, inclosos els grups i la configuració de la càmera."
|
||||
},
|
||||
"review": {
|
||||
"label": "Mostra en la revisió",
|
||||
|
||||
@@ -126,5 +126,7 @@
|
||||
"baby_stroller": "Cotxet",
|
||||
"rickshaw": "Ricksaw",
|
||||
"Rodent": "Rosegador",
|
||||
"rodent": "Rosegador"
|
||||
"rodent": "Rosegador",
|
||||
"possum": "Possum",
|
||||
"garbage_truck": "Camió de brossa"
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"menuItem": "Visualitza les vistes prèvies del moviment",
|
||||
"title": "Vista prèvia del moviment: {{camera}}",
|
||||
"mobileSettingsTitle": "Configuració de la vista prèvia del moviment",
|
||||
"mobileSettingsDesc": "Ajusteu la velocitat de reproducció i l'enfosquiment, i trieu una data per a revisar clips només en moviment.",
|
||||
"mobileSettingsDesc": "Ajusteu la velocitat de reproducció, l'enfosquiment i l'escapçament, i trieu una data per a revisar clips només en moviment.",
|
||||
"dim": "Atenuar",
|
||||
"dimAria": "Ajusta la intensitat de l'enfosquiment",
|
||||
"dimDesc": "Incrementa l'enfosquiment per augmentar la visibilitat de l'àrea de moviment.",
|
||||
@@ -89,6 +89,9 @@
|
||||
"seekAria": "Cerca el reproductor {{camera}} a {{time}}",
|
||||
"filter": "Filtre",
|
||||
"filterDesc": "Seleccioneu àrees per a mostrar només clips amb moviment en aquestes regions.",
|
||||
"filterClear": "Neteja"
|
||||
"filterClear": "Neteja",
|
||||
"crop": "Escapça per filtrar",
|
||||
"cropAria": "Commuta la vista prèvia d'escapçament a les àrees filtrades",
|
||||
"cropDesc": "Amplia les vistes prèvies a les àrees de filtre seleccionades en lloc de mostrar el fotograma complet."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1330,7 +1330,7 @@
|
||||
"webuiUrlHelp": "URL per a visitar la interfície d'usuari web de la càmera directament des de la vista de depuració. Deixeu-ho en blanc per desactivar l'enllaç.",
|
||||
"webuiUrlInvalid": "Ha de ser un URL vàlid (p. ex., https://example.com).",
|
||||
"dashboardLabel": "Mostra al tauler en directe",
|
||||
"dashboardHelp": "Mostra aquesta càmera al Tauler en viu.",
|
||||
"dashboardHelp": "Mostra aquesta càmera al tauler de control en directe predeterminat de totes les càmeres. Es manté disponible a tot arreu, inclosos els grups de càmeres.",
|
||||
"reviewLabel": "Mostra a la ressenya",
|
||||
"reviewHelp": "Mostra aquesta càmera a Revisió, incloent el filtre de càmera, la revisió de moviment i la vista de l'historial."
|
||||
},
|
||||
@@ -1377,7 +1377,7 @@
|
||||
"deleteCameraDialog": {
|
||||
"title": "Suprimeix la càmera",
|
||||
"description": "Suprimir una càmera eliminarà permanentment tots els enregistraments, els objectes rastrejats i la configuració d'aquesta càmera. Qualsevol flux go2rtc associat amb aquesta càmera encara pot haver de ser eliminat manualment.",
|
||||
"selectPlaceholder": "Trieu la càmera...",
|
||||
"selectPlaceholder": "Trieu la càmera…",
|
||||
"confirmTitle": "N'estàs segur?",
|
||||
"confirmWarning": "Suprimir <strong>{{cameraName}}</strong> no es pot desfer.",
|
||||
"deleteExports": "Elimina també les exportacions d'aquesta càmera",
|
||||
|
||||
@@ -443,5 +443,60 @@
|
||||
"slap": "Plácnout",
|
||||
"smash": "Rozmlátit",
|
||||
"bouncing": "Odrážející",
|
||||
"scratch": "Škrábat"
|
||||
"scratch": "Škrábat",
|
||||
"spray": "Sprej",
|
||||
"pulse": "Pulz",
|
||||
"inside": "Uvnitř",
|
||||
"outside": "Venku",
|
||||
"reverberation": "Dozvuk",
|
||||
"echo": "Ozvěna",
|
||||
"noise": "Hluk",
|
||||
"mains_hum": "Síťový brum",
|
||||
"distortion": "Zkreslení",
|
||||
"sidetone": "Příposlech",
|
||||
"cacophony": "Kakofonie",
|
||||
"throbbing": "Pulzování",
|
||||
"vibration": "Vibrace",
|
||||
"sodeling": "Jódlování",
|
||||
"shofar": "Šofar",
|
||||
"slosh": "Šplouchání",
|
||||
"gush": "Příval",
|
||||
"whoosh": "Svištění",
|
||||
"thump": "Tlumená rána",
|
||||
"thunk": "Dutá rána",
|
||||
"effects_unit": "Efektová jednotka",
|
||||
"chorus_effect": "Chorus",
|
||||
"whack": "Úder",
|
||||
"breaking": "Rozbíjení",
|
||||
"whip": "Švihnutí",
|
||||
"flap": "Třepotání",
|
||||
"scrape": "Drhnutí",
|
||||
"rub": "Tření",
|
||||
"roll": "Kutálení",
|
||||
"crushing": "Drcení",
|
||||
"crumpling": "Mačkání",
|
||||
"tearing": "Trhání",
|
||||
"beep": "Pípnutí",
|
||||
"ping": "Ping",
|
||||
"ding": "Cinknutí",
|
||||
"clang": "Řinčení",
|
||||
"squeal": "Skřípění",
|
||||
"creak": "Vrzání",
|
||||
"rustle": "Šustění",
|
||||
"whir": "Hučení",
|
||||
"clatter": "Rachocení",
|
||||
"sizzle": "Prskání",
|
||||
"clicking": "Klikání",
|
||||
"clickety_clack": "Klapot",
|
||||
"rumble": "Dunění",
|
||||
"plop": "Žbluňknutí",
|
||||
"hum": "Brum",
|
||||
"zing": "Zvonivý tón",
|
||||
"boing": "Pružinový zvuk",
|
||||
"crunch": "Křupání",
|
||||
"sine_wave": "Sinusový tón",
|
||||
"harmonic": "Harmonický tón",
|
||||
"chirp_tone": "Klouzavý tón",
|
||||
"pump": "Pumpa",
|
||||
"basketball_bounce": "Basketbalový odraz"
|
||||
}
|
||||
|
||||
@@ -120,7 +120,19 @@
|
||||
"deleteNow": "Smazat hned",
|
||||
"next": "Další",
|
||||
"export": "Exportovat",
|
||||
"continue": "Pokračovat"
|
||||
"continue": "Pokračovat",
|
||||
"add": "Přidat",
|
||||
"applying": "Aplikuje se…",
|
||||
"undo": "Vrátit",
|
||||
"copiedToClipboard": "Zkopírováno do schránky",
|
||||
"modified": "Upraveno",
|
||||
"overridden": "Přepsáno",
|
||||
"resetToGlobal": "Obnovit globální nastavení",
|
||||
"resetToDefault": "Obnovit výchozí nastavení",
|
||||
"saveAll": "Uložit vše",
|
||||
"savingAll": "Ukládání…",
|
||||
"undoAll": "Vrátit vše",
|
||||
"retry": "Zkusit znovu"
|
||||
},
|
||||
"label": {
|
||||
"back": "Jdi zpět",
|
||||
@@ -213,7 +225,9 @@
|
||||
"gl": "Galego (Galicijština)",
|
||||
"id": "Bahasa Indonesia (Indonéština)",
|
||||
"ur": "اردو (Urdština)",
|
||||
"hr": "Hrvatski (Chorvatština)"
|
||||
"hr": "Hrvatski (Chorvatština)",
|
||||
"zhHant": "繁體中文 (Tradiční čínština)",
|
||||
"bs": "Bosanski (Bosenština)"
|
||||
},
|
||||
"theme": {
|
||||
"highcontrast": "Vysoký kontrast",
|
||||
@@ -251,7 +265,11 @@
|
||||
"faceLibrary": "Knihovna Obličejů",
|
||||
"configurationEditor": "Editor Konfigurace",
|
||||
"withSystem": "Systém",
|
||||
"classification": "Klasifikace"
|
||||
"classification": "Klasifikace",
|
||||
"profiles": "Profily",
|
||||
"actions": "Akce",
|
||||
"features": "Funkce",
|
||||
"chat": "Chat"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": {
|
||||
@@ -282,7 +300,8 @@
|
||||
"error": {
|
||||
"title": "Chyba při ukládání změn konfigurace: {{errorMessage}}",
|
||||
"noMessage": "Chyba při ukládání změn konfigurace"
|
||||
}
|
||||
},
|
||||
"success": "Změny konfigurace byly úspěšně uloženy."
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
@@ -303,5 +322,10 @@
|
||||
},
|
||||
"information": {
|
||||
"pixels": "{{area}}px"
|
||||
},
|
||||
"no_items": "Žádné položky",
|
||||
"validation_errors": "Chyby ověření",
|
||||
"credentialField": {
|
||||
"savedPlaceholder": "Uloženo – ponechte prázdné pro zachování aktuální hodnoty"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,10 @@
|
||||
"desc": "Vyberte kamery pro tuto skupinu."
|
||||
},
|
||||
"icon": "Ikona",
|
||||
"success": "Skupina kamer {{name}} byla uložena."
|
||||
"success": "Skupina kamer {{name}} byla uložena.",
|
||||
"showAll": "Zobrazit všechny skupiny kamer",
|
||||
"showLess": "Zobrazit méně",
|
||||
"editGroups": "Upravit skupiny kamer"
|
||||
},
|
||||
"debug": {
|
||||
"options": {
|
||||
@@ -82,6 +85,7 @@
|
||||
"regions": "Kraje",
|
||||
"timestamp": "Časové razítko",
|
||||
"boundingBox": "Ohraničení",
|
||||
"mask": "Maska"
|
||||
"mask": "Maska",
|
||||
"paths": "Cesty"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"title": "Frigate restartuje",
|
||||
"content": "Tato stránka bude obnovena za {{countdown}} sekund.",
|
||||
"button": "Vynutit opětovné načtení"
|
||||
}
|
||||
},
|
||||
"description": "Frigate bude během restartu krátce nedostupný."
|
||||
},
|
||||
"explore": {
|
||||
"plus": {
|
||||
@@ -19,6 +20,9 @@
|
||||
},
|
||||
"state": {
|
||||
"submitted": "Odesláno"
|
||||
},
|
||||
"toast": {
|
||||
"error": "Odeslání do Frigate+ se nezdařilo. Zkontrolujte své síťové připojení a zkuste to znovu."
|
||||
}
|
||||
},
|
||||
"submitToPlus": {
|
||||
@@ -74,7 +78,11 @@
|
||||
"endTimeMustAfterStartTime": "Čas konce musí být po čase začátku",
|
||||
"noVaildTimeSelected": "Není vybráno žádné platné časové období"
|
||||
},
|
||||
"view": "Zobrazení"
|
||||
"view": "Zobrazení",
|
||||
"queued": "Export zařazen do fronty. Průběh zobrazíte na stránce Exporty.",
|
||||
"batchSuccess_one": "Začal 1 export. Nyní se otevírá případ.",
|
||||
"batchSuccess_few": "Začaly {{count}} exporty. Nyní se otevírá případ.",
|
||||
"batchSuccess_other": "Začalo {{count}} exportů. Nyní se otevírá případ."
|
||||
},
|
||||
"fromTimeline": {
|
||||
"saveExport": "Uložit export",
|
||||
@@ -82,6 +90,65 @@
|
||||
},
|
||||
"name": {
|
||||
"placeholder": "Jméno exportu"
|
||||
},
|
||||
"case": {
|
||||
"newCaseOption": "Vytvořit nový případ",
|
||||
"newCaseNamePlaceholder": "Název nového případu",
|
||||
"newCaseDescriptionPlaceholder": "Popis případu",
|
||||
"label": "Případ",
|
||||
"nonAdminHelp": "Pro tyto exporty bude vytvořen nový případ.",
|
||||
"placeholder": "Vyberte případ"
|
||||
},
|
||||
"queueing": "Přidávání exportu do fronty…",
|
||||
"tabs": {
|
||||
"export": "Jedna kamera",
|
||||
"multiCamera": "Vícekamerový"
|
||||
},
|
||||
"multiCamera": {
|
||||
"timeRange": "Časový rozsah",
|
||||
"selectFromTimeline": "Vybrat z časové osy",
|
||||
"cameraSelection": "Kamery",
|
||||
"cameraSelectionHelp": "Kamery se sledovanými objekty v tomto časovém rozsahu jsou předem vybrány",
|
||||
"searchOrSelectGroup": "Vyhledejte nebo vyberte skupinu kamer…",
|
||||
"selectAll": "Vybrat všechny kamery",
|
||||
"clearSelection": "Zrušit výběr",
|
||||
"selectWithActivity": "Kamery se sledovanými objekty",
|
||||
"selectGroup": "Vybrat skupinu",
|
||||
"noMatchingCameras": "Žádná kamera neodpovídá vašemu hledání",
|
||||
"selectedCount": "{{selected}} / {{total}} vybráno",
|
||||
"checkingActivity": "Kontrola aktivity kamer…",
|
||||
"noCameras": "Nejsou k dispozici žádné kamery",
|
||||
"detectionCount_one": "1 sledovaný objekt",
|
||||
"detectionCount_few": "{{count}} sledované objekty",
|
||||
"detectionCount_other": "{{count}} sledovaných objektů",
|
||||
"nameLabel": "Název exportu",
|
||||
"namePlaceholder": "Volitelný základní název pro tyto exporty",
|
||||
"queueingButton": "Příprava exportů…",
|
||||
"exportButton_one": "Exportovat 1 kameru",
|
||||
"exportButton_few": "Exportovat {{count}} kamery",
|
||||
"exportButton_other": "Exportovat {{count}} kamer"
|
||||
},
|
||||
"multi": {
|
||||
"title_one": "Exportovat 1 kontrolu",
|
||||
"title_few": "Exportovat {{count}} kontroly",
|
||||
"title_other": "Exportovat {{count}} kontrol",
|
||||
"description": "Exportujte každou vybranou kontrolu. Všechny exporty budou seskupeny do jednoho případu.",
|
||||
"descriptionNoCase": "Exportujte každou vybranou kontrolu.",
|
||||
"caseNamePlaceholder": "Export kontroly – {{date}}",
|
||||
"exportButton_one": "Exportovat 1 kontrolu",
|
||||
"exportButton_few": "Exportovat {{count}} kontroly",
|
||||
"exportButton_other": "Exportovat {{count}} kontrol",
|
||||
"exportingButton": "Exportování…",
|
||||
"toast": {
|
||||
"started_one": "Spuštěn 1 export. Otevírá se případ.",
|
||||
"started_few": "Spuštěny {{count}} exporty. Otevírá se případ.",
|
||||
"started_other": "Spuštěno {{count}} exportů. Otevírá se případ.",
|
||||
"startedNoCase_one": "Spuštěn 1 export.",
|
||||
"startedNoCase_few": "Spuštěny {{count}} exporty.",
|
||||
"startedNoCase_other": "Spuštěno {{count}} exportů.",
|
||||
"partial": "Spuštěno {{successful}} z {{total}} exportů. Selhalo: {{failedItems}}",
|
||||
"failed": "Nepodařilo se spustit {{total}} exportů. Selhalo: {{failedItems}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
|
||||
@@ -309,7 +309,8 @@
|
||||
"triggers": "Spouštěče",
|
||||
"cameraManagement": "Správa",
|
||||
"cameraReview": "Kontrola",
|
||||
"roles": "Role"
|
||||
"roles": "Role",
|
||||
"profiles": "Profily"
|
||||
},
|
||||
"dialog": {
|
||||
"unsavedChanges": {
|
||||
|
||||
@@ -206,5 +206,140 @@
|
||||
"whale_vocalization": "Hvallyde",
|
||||
"music": "Musik",
|
||||
"musical_instrument": "Musikinstrument",
|
||||
"plucked_string_instrument": "Strengeinstrument"
|
||||
"plucked_string_instrument": "Strengeinstrument",
|
||||
"zither": "Citar",
|
||||
"ukulele": "Ukulele",
|
||||
"piano": "Piano",
|
||||
"electric_piano": "Elektrisk Piano",
|
||||
"organ": "orgel",
|
||||
"electronic_organ": "Elektrisk orgel",
|
||||
"hammond_organ": "Hammond orgel",
|
||||
"synthesizer": "Synthesizer",
|
||||
"sampler": "Sampler",
|
||||
"harpsichord": "Mundharmonika",
|
||||
"percussion": "Percussion",
|
||||
"drum_kit": "Trommesæt",
|
||||
"drum_machine": "Trommemaskine",
|
||||
"drum": "Tromme",
|
||||
"timpani": "Pauker",
|
||||
"tabla": "Tabla",
|
||||
"cymbal": "Bækken",
|
||||
"hi_hat": "Hi-Hat",
|
||||
"wood_block": "Woodblock",
|
||||
"maraca": "Maraca",
|
||||
"gong": "Gong",
|
||||
"tubular_bells": "Rørklokker",
|
||||
"mallet_percussion": "Mallet percussion",
|
||||
"glockenspiel": "Klokkespil",
|
||||
"vibraphone": "Vibrafon",
|
||||
"steelpan": "olietønde",
|
||||
"orchestra": "Orkester",
|
||||
"brass_instrument": "Messingblæser",
|
||||
"french_horn": "Valdhorn",
|
||||
"bowed_string_instrument": "Stryger",
|
||||
"string_section": "Strygerdel",
|
||||
"pizzicato": "Pizzicato",
|
||||
"cello": "Cello",
|
||||
"double_bass": "Kontrabas",
|
||||
"wind_instrument": "Blæseinstrument",
|
||||
"church_bell": "Kirkeklokke",
|
||||
"jingle_bell": "Bjældeklang",
|
||||
"bicycle_bell": "Ringeklokke",
|
||||
"tuning_fork": "Stemmegaffel",
|
||||
"chime": "Kimen",
|
||||
"wind_chime": "Vindklokke",
|
||||
"accordion": "Harmonika",
|
||||
"theremin": "Theremin",
|
||||
"singing_bowl": "Syngeskål",
|
||||
"scratching": "Kradse",
|
||||
"pop_music": "Popmusik",
|
||||
"hip_hop_music": "Hip-Hopmusik",
|
||||
"beatboxing": "Beatboxing",
|
||||
"rock_music": "Rockmusik",
|
||||
"heavy_metal": "Heavymetal",
|
||||
"punk_rock": "Punkrock",
|
||||
"grunge": "Grunge",
|
||||
"progressive_rock": "Progressiv rock",
|
||||
"rock_and_roll": "Rock and Roll",
|
||||
"psychedelic_rock": "Psykedelisk rock",
|
||||
"rhythm_and_blues": "Rhythm and Blues",
|
||||
"soul_music": "Soulmusik",
|
||||
"reggae": "Reggae",
|
||||
"country": "Country",
|
||||
"disco": "Disko",
|
||||
"classical_music": "Klassisk musik",
|
||||
"electronic_music": "Elektronisk musik",
|
||||
"house_music": "Housemusik",
|
||||
"drum_and_bass": "Drum and Bass",
|
||||
"electronica": "Elektronisk musik",
|
||||
"electronic_dance_music": "Elektronisk dancemusik",
|
||||
"ambient_music": "Ambient musik",
|
||||
"trance_music": "Trancemusik",
|
||||
"music_of_latin_america": "Musik fra Latin Amerika",
|
||||
"salsa_music": "Salsamusik",
|
||||
"flamenco": "Flamenco",
|
||||
"music_for_children": "Musik for børn",
|
||||
"new-age_music": "New Age musik",
|
||||
"vocal_music": "Vokalmusik",
|
||||
"a_capella": "A Capella",
|
||||
"music_of_africa": "Musik fra Afrika",
|
||||
"afrobeat": "Afrobeat",
|
||||
"christian_music": "Kristen musik",
|
||||
"gospel_music": "Gospelmusik",
|
||||
"music_of_asia": "Musik fra Asian",
|
||||
"ska": "Ska",
|
||||
"traditional_music": "Traditionel musik",
|
||||
"independent_music": "Individual musik",
|
||||
"background_music": "Baggrundsmusik",
|
||||
"theme_music": "Temamusik",
|
||||
"jingle": "Jingle",
|
||||
"soundtrack_music": "Soundtrack musik",
|
||||
"video_game_music": "Videospil musik",
|
||||
"christmas_music": "Julemusik",
|
||||
"dance_music": "Dance musik",
|
||||
"wedding_music": "Bryllupsmusik",
|
||||
"happy_music": "Glad musik",
|
||||
"sad_music": "Trist musik",
|
||||
"tender_music": "Kærlighedsmusik",
|
||||
"exciting_music": "Spændende musik",
|
||||
"angry_music": "Vred musik",
|
||||
"scary_music": "Skræmmende musik",
|
||||
"rustling_leaves": "Raslende blade",
|
||||
"wind_noise": "Vindstøj",
|
||||
"rain_on_surface": "Regn på en overflade",
|
||||
"stream": "Strøm",
|
||||
"ocean": "Verdenshav",
|
||||
"steam": "Damp",
|
||||
"gurgling": "Gurgle",
|
||||
"crackle": "Knitren",
|
||||
"motor_vehicle": "Motoriseret køretøj",
|
||||
"toot": "Dytte",
|
||||
"car_alarm": "Bilalarm",
|
||||
"power_windows": "Elruder",
|
||||
"skidding": "Udskridning",
|
||||
"tire_squeal": "Dækhvin",
|
||||
"car_passing_by": "Forbikørende bil",
|
||||
"race_car": "Racerbil",
|
||||
"truck": "Lastbil",
|
||||
"air_brake": "Trykluftsbremse",
|
||||
"air_horn": "Trykluftshorn",
|
||||
"reversing_beeps": "Bakalarm",
|
||||
"ice_cream_truck": "Isbil",
|
||||
"emergency_vehicle": "Redningskøretøj",
|
||||
"police_car": "Politibil",
|
||||
"fire_engine": "Brandbil",
|
||||
"traffic_noise": "Trafikstøj",
|
||||
"rail_transport": "Godstogstransport",
|
||||
"train_whistle": "Togfløjt",
|
||||
"train_horn": "Toghorn",
|
||||
"railroad_car": "Togvogn",
|
||||
"train_wheels_squealing": "Skinneskrig",
|
||||
"subway": "Undergrund",
|
||||
"aircraft": "Fly",
|
||||
"aircraft_engine": "Flymotor",
|
||||
"jet_engine": "Jetmotor",
|
||||
"propeller": "Probel",
|
||||
"fixed-wing_aircraft": "Fastvingefly",
|
||||
"engine": "Motor",
|
||||
"light_engine": "Lille motor"
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
"ask_a": "Ist dieses Objekt ein <code>{{label}}</code>?",
|
||||
"ask_an": "Ist dieses Objekt ein <code>{{label}}</code>?",
|
||||
"ask_full": "Ist dieses Objekt ein <code>{{untranslatedLabel}}</code> ({{translatedLabel}})?"
|
||||
},
|
||||
"toast": {
|
||||
"error": "Übermittlung an Frigate+ fehlgeschlagen. Bitte überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut."
|
||||
}
|
||||
},
|
||||
"submitToPlus": {
|
||||
@@ -65,7 +68,8 @@
|
||||
"error": {
|
||||
"endTimeMustAfterStartTime": "Die Endzeit darf nicht vor der Startzeit liegen",
|
||||
"failed": "Fehler beim Export in die Warteschlange: {{error}}",
|
||||
"noVaildTimeSelected": "Kein gültiger Zeitraum ausgewählt"
|
||||
"noVaildTimeSelected": "Kein gültiger Zeitraum ausgewählt",
|
||||
"noValidTimeSelected": "Kein gültiger Zeitraum ausgewählt"
|
||||
},
|
||||
"success": "Export erfolgreich gestartet. Die Datei befindet sich auf der Exportseite.",
|
||||
"view": "Ansicht",
|
||||
|
||||
@@ -545,6 +545,9 @@
|
||||
"max_concurrent": {
|
||||
"label": "Maximale Anzahl gleichzeitiger Exporte",
|
||||
"description": "Maximale Anzahl der gleichzeitig zu verarbeitenden Exportaufträge."
|
||||
},
|
||||
"chapters": {
|
||||
"label": "Kapitel-Metadaten zur Einbettung in exportierte Aufzeichnungen"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
@@ -768,8 +771,8 @@
|
||||
"description": "Numerische Reihenfolge, nach der die Kamera in der Benutzeroberfläche sortiert wird (Standard-Dashboard und Listen); höhere Zahlen erscheinen später."
|
||||
},
|
||||
"dashboard": {
|
||||
"label": "In der Benutzeroberfläche anzeigen",
|
||||
"description": "Schalte ein, ob diese Kamera überall in der Benutzeroberfläche von „Frigate“ sichtbar ist. Wenn du diese Option deaktivierst, musst du die Konfiguration manuell bearbeiten, um diese Kamera wieder in der Benutzeroberfläche anzuzeigen."
|
||||
"label": "Im Live-Dashboard anzeigen",
|
||||
"description": "Legt fest, ob diese Kamera im Live-Dashboard ‚Alle Kameras‘ angezeigt wird. Die Kamera bleibt in allen anderen Bereichen der Benutzeroberfläche verfügbar, einschließlich Kamera-Gruppen und Einstellungen."
|
||||
},
|
||||
"review": {
|
||||
"label": "In der Überprüfung anzeigen",
|
||||
|
||||
@@ -1123,7 +1123,7 @@
|
||||
},
|
||||
"model_type": {
|
||||
"label": "Typ des Objekterkennungsmodells",
|
||||
"description": "Typ der Detektor-Modellarchitektur (ssd, yolox, yolonas), der von einigen Detektoren zur Optimierung verwendet wird."
|
||||
"description": "Typ der Detektor-Modellarchitektur (ssd, yolox, yolonas, yolo-generic, rfdetr, dfine), der von einigen Detektoren zur Optimierung verwendet wird."
|
||||
}
|
||||
},
|
||||
"model_path": {
|
||||
@@ -1400,6 +1400,9 @@
|
||||
"max_concurrent": {
|
||||
"label": "Maximale Anzahl gleichzeitiger Exporte",
|
||||
"description": "Maximale Anzahl der gleichzeitig zu verarbeitenden Exportaufträge."
|
||||
},
|
||||
"chapters": {
|
||||
"label": "Kapitel-Metadaten zur Einbettung in exportierte Aufzeichnungen"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
@@ -1729,7 +1732,7 @@
|
||||
},
|
||||
"model_type": {
|
||||
"label": "Typ des Objekterkennungsmodells",
|
||||
"description": "Typ der Detektor-Modellarchitektur (ssd, yolox, yolonas), der von einigen Detektoren zur Optimierung verwendet wird."
|
||||
"description": "Typ der Detektor-Modellarchitektur (ssd, yolox, yolonas, yolo-generic, rfdetr, dfine), der von einigen Detektoren zur Optimierung verwendet wird."
|
||||
}
|
||||
},
|
||||
"genai": {
|
||||
@@ -1932,8 +1935,8 @@
|
||||
"description": "Numerische Reihenfolge, nach der die Kamera in der Benutzeroberfläche sortiert wird (Standard-Dashboard und Listen); höhere Zahlen erscheinen später."
|
||||
},
|
||||
"dashboard": {
|
||||
"label": "In der Benutzeroberfläche anzeigen",
|
||||
"description": "Schalte ein, ob diese Kamera überall in der Benutzeroberfläche von „Frigate“ sichtbar ist. Wenn du diese Option deaktivierst, musst du die Konfiguration manuell bearbeiten, um diese Kamera wieder in der Benutzeroberfläche anzuzeigen."
|
||||
"label": "Im Live-Dashboard anzeigen",
|
||||
"description": "Legt fest, ob diese Kamera im Live-Dashboard ‚Alle Kameras‘ angezeigt wird. Die Kamera bleibt in allen anderen Bereichen der Benutzeroberfläche verfügbar, einschließlich Kamera-Gruppen und Einstellungen."
|
||||
},
|
||||
"review": {
|
||||
"label": "In der Überprüfung anzeigen",
|
||||
|
||||
@@ -125,5 +125,7 @@
|
||||
"baby": "Baby",
|
||||
"baby_stroller": "Kinderwagen",
|
||||
"rickshaw": "Rikscha",
|
||||
"rodent": "Nagetier"
|
||||
"rodent": "Nagetier",
|
||||
"garbage_truck": "Müllfahrzeug",
|
||||
"possum": "Possum"
|
||||
}
|
||||
|
||||
@@ -64,7 +64,20 @@
|
||||
"title": "Klassifikationsmodell bearbeiten",
|
||||
"descriptionState": "Bearbeite die Klassen für dieses Zustandsklassifikationsmodell. Änderungen erfordern ein erneutes Trainieren des Modells.",
|
||||
"descriptionObject": "Bearbeite den Objekttyp und Klassifizierungstyp für dieses Objektklassifikationsmodell.",
|
||||
"stateClassesInfo": "Hinweis: Die Änderung der Statusklassen erfordert ein erneutes Trainieren des Modells mit den aktualisierten Klassen."
|
||||
"stateClassesInfo": "Modell aktualisiert. Trainieren Sie das Modell neu, damit die Klassenänderungen wirksam werden.",
|
||||
"enabled": "Aktiviert",
|
||||
"enabledDesc": "Dieses Modell ausführen. Bei Deaktivierung wird es nicht mehr ausgeführt und führt keine Klassifizierung mehr durch.",
|
||||
"saveAttempts": "Versuche speichern",
|
||||
"saveAttemptsDesc": "Anzahl der für die Übersicht der letzten Klassifizierungen zu speichernden Bilder.",
|
||||
"motion": "Bei Bewegung ausführen",
|
||||
"motionDesc": "Klassifizierung bei Bewegung im konfigurierten Bildausschnitt ausführen.",
|
||||
"interval": "Intervall",
|
||||
"intervalDesc": "Sekundenabstand zwischen regelmäßigen Klassifizierungsläufen. Leer lassen, um die Klassifizierung nur bei Bewegung auszuführen.",
|
||||
"intervalPlaceholder": "Kein Intervall",
|
||||
"errors": {
|
||||
"saveAttemptsInvalid": "Sparversuche müssen eine ganze Zahl von 0 oder mehr sein",
|
||||
"intervalInvalid": "Intervall muss eine ganze Nummer sein größer als 0"
|
||||
}
|
||||
},
|
||||
"deleteDatasetImages": {
|
||||
"title": "Datensatz Bilder löschen",
|
||||
@@ -195,5 +208,6 @@
|
||||
},
|
||||
"none": "Keiner",
|
||||
"reclassifyImageAs": "Bild neu klassifizieren als:",
|
||||
"reclassifyImage": "Bild neu klassifizieren"
|
||||
"reclassifyImage": "Bild neu klassifizieren",
|
||||
"disabled": "Deaktiviert"
|
||||
}
|
||||
|
||||
@@ -570,7 +570,7 @@
|
||||
"openCameraWebUI": "Web-Benutzeroberfläche von {{camera}} öffnen",
|
||||
"audio": {
|
||||
"title": "Audio",
|
||||
"noAudioDetections": "Keine Audioerkennungen",
|
||||
"noAudioDetections": "Kein Audio-Ereignis erkannt",
|
||||
"score": "Punktzahl",
|
||||
"currentRMS": "Aktueller Effektivwert",
|
||||
"currentdbFS": "Aktuelle dbFS"
|
||||
@@ -1383,7 +1383,7 @@
|
||||
"webuiUrlHelp": "URL, um die Web-Benutzeroberfläche der Kamera direkt aus der Debug-Ansicht aufzurufen. Lassen Sie das Feld leer, um den Link zu deaktivieren.",
|
||||
"webuiUrlInvalid": "Es muss sich um eine gültige URL handeln (z. B. https://example.com).",
|
||||
"dashboardLabel": "Im Live-Dashboard anzeigen",
|
||||
"dashboardHelp": "Diese Kamera im Live-Dashboard anzeigen.",
|
||||
"dashboardHelp": "Zeige diese Kamera auf dem Standard Alle Kameras live dashboard. Es bleibt überall verfügbar, einschließlich Kameragruppen.",
|
||||
"reviewLabel": "In der Überprüfung anzeigen",
|
||||
"reviewHelp": "Zeige diese Kamera in der Übersicht an, einschließlich des Kamerafilters, der Bewegungsübersicht und der Verlaufsansicht."
|
||||
},
|
||||
@@ -1430,7 +1430,7 @@
|
||||
"deleteCameraDialog": {
|
||||
"title": "Kamera löschen",
|
||||
"description": "Durch das Löschen einer Kamera werden alle Aufzeichnungen, erfassten Objekte und Konfigurationseinstellungen für diese Kamera endgültig entfernt. Alle mit dieser Kamera verbundenen go2rtc-Streams müssen möglicherweise noch manuell entfernt werden.",
|
||||
"selectPlaceholder": "Kamera auswählen...",
|
||||
"selectPlaceholder": "Kamera auswählen…",
|
||||
"confirmTitle": "Bist du dir sicher?",
|
||||
"confirmWarning": "Das Löschen von <strong>{{cameraName}}</strong> kann nicht rückgängig gemacht werden.",
|
||||
"deleteExports": "Lösche auch die Exporte für diese Kamera",
|
||||
@@ -1720,7 +1720,14 @@
|
||||
"keyLabel": "Schlüssel",
|
||||
"valueLabel": "Wert",
|
||||
"keyPlaceholder": "Neuer Schlüssel",
|
||||
"remove": "Entfernen"
|
||||
"remove": "Entfernen",
|
||||
"providerNameLabel": "Name des Anbieters",
|
||||
"providerNamePlaceholder": "z.B. openai",
|
||||
"variableNameLabel": "Variabler Name",
|
||||
"variableNamePlaceholder": "z.B. MY_VARIABLE",
|
||||
"loggerNameLabel": "Name des Loggers",
|
||||
"loggerNamePlaceholder": "z.B. frigate.record",
|
||||
"keyPatternError": "Verwenden Sie nur Buchstaben, Zahlen, Bindestriche und Unterstriche (keine Leerzeichen)"
|
||||
},
|
||||
"timezone": {
|
||||
"defaultOption": "Zeitzone des Browsers verwenden"
|
||||
|
||||
@@ -859,8 +859,8 @@
|
||||
"description": "Numeric order used to sort the camera in the UI (default dashboard and lists); larger numbers appear later."
|
||||
},
|
||||
"dashboard": {
|
||||
"label": "Show in UI",
|
||||
"description": "Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again."
|
||||
"label": "Show on Live dashboard",
|
||||
"description": "Toggle whether this camera is visible on the default All Cameras live dashboard. The camera remains available everywhere else in the UI, including camera groups and settings."
|
||||
},
|
||||
"review": {
|
||||
"label": "Show in review",
|
||||
|
||||
@@ -357,22 +357,6 @@
|
||||
"description": "Optional API key for authenticated DeepStack services."
|
||||
}
|
||||
},
|
||||
"degirum": {
|
||||
"label": "DeGirum",
|
||||
"description": "DeGirum detector for running models via DeGirum cloud or local inference services.",
|
||||
"location": {
|
||||
"label": "Inference Location",
|
||||
"description": "Location of the DeGirim inference engine (e.g. '@cloud', '127.0.0.1')."
|
||||
},
|
||||
"zoo": {
|
||||
"label": "Model Zoo",
|
||||
"description": "Path or URL to the DeGirum model zoo."
|
||||
},
|
||||
"token": {
|
||||
"label": "DeGirum Cloud Token",
|
||||
"description": "Token for DeGirum Cloud access."
|
||||
}
|
||||
},
|
||||
"edgetpu": {
|
||||
"label": "EdgeTPU",
|
||||
"description": "EdgeTPU detector that runs TensorFlow Lite models compiled for Coral EdgeTPU using the EdgeTPU delegate.",
|
||||
@@ -1543,8 +1527,8 @@
|
||||
"description": "Numeric order used to sort the camera in the UI (default dashboard and lists); larger numbers appear later."
|
||||
},
|
||||
"dashboard": {
|
||||
"label": "Show in UI",
|
||||
"description": "Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again."
|
||||
"label": "Show on Live dashboard",
|
||||
"description": "Toggle whether this camera is visible on the default All Cameras live dashboard. The camera remains available everywhere else in the UI, including camera groups and settings."
|
||||
},
|
||||
"review": {
|
||||
"label": "Show in review",
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"menuItem": "View motion previews",
|
||||
"title": "Motion previews: {{camera}}",
|
||||
"mobileSettingsTitle": "Motion Preview Settings",
|
||||
"mobileSettingsDesc": "Adjust playback speed and dimming, and choose a date to review motion-only clips.",
|
||||
"mobileSettingsDesc": "Adjust playback speed, dimming, and cropping, and choose a date to review motion-only clips.",
|
||||
"dim": "Dim",
|
||||
"dimAria": "Adjust dimming intensity",
|
||||
"dimDesc": "Increase dimming to increase motion area visibility.",
|
||||
@@ -87,6 +87,9 @@
|
||||
"seekAria": "Seek {{camera}} player to {{time}}",
|
||||
"filter": "Filter",
|
||||
"filterDesc": "Select areas to only show clips with motion in those regions.",
|
||||
"filterClear": "Clear"
|
||||
"filterClear": "Clear",
|
||||
"crop": "Crop to filter",
|
||||
"cropAria": "Toggle cropping previews to the filtered areas",
|
||||
"cropDesc": "Zoom previews into the selected filter areas instead of showing the full frame."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,7 +463,7 @@
|
||||
"deleteCameraDialog": {
|
||||
"title": "Delete Camera",
|
||||
"description": "Deleting a camera will permanently remove all recordings, tracked objects, and configuration for that camera. Any go2rtc streams associated with this camera may still need to be manually removed.",
|
||||
"selectPlaceholder": "Choose camera...",
|
||||
"selectPlaceholder": "Choose camera…",
|
||||
"confirmTitle": "Are you sure?",
|
||||
"confirmWarning": "Deleting <strong>{{cameraName}}</strong> cannot be undone.",
|
||||
"deleteExports": "Also delete exports for this camera",
|
||||
@@ -499,7 +499,7 @@
|
||||
"webuiUrlHelp": "URL to visit the camera's web UI directly from the Debug view. Leave blank to disable the link.",
|
||||
"webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com).",
|
||||
"dashboardLabel": "Show on Live dashboard",
|
||||
"dashboardHelp": "Show this camera on the Live dashboard.",
|
||||
"dashboardHelp": "Show this camera on the default All Cameras live dashboard. It remains available everywhere else, including camera groups.",
|
||||
"reviewLabel": "Show in Review",
|
||||
"reviewHelp": "Show this camera in Review, including the camera filter, motion review, and the history view."
|
||||
}
|
||||
@@ -613,7 +613,6 @@
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"changeCount_zero": "No changes selected",
|
||||
"changeCount_one": "{{count}} change will be applied",
|
||||
"changeCount_other": "{{count}} changes will be applied",
|
||||
"restartNeeded": "Restart will be required for some changes.",
|
||||
|
||||
@@ -126,5 +126,7 @@
|
||||
"baby_stroller": "Lapsevanker",
|
||||
"rickshaw": "Rikša",
|
||||
"Rodent": "Näriline",
|
||||
"rodent": "Näriline"
|
||||
"rodent": "Näriline",
|
||||
"possum": "Opossum",
|
||||
"garbage_truck": "Prügiauto"
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"menuItem": "Vaata liikumiste eelvaateid",
|
||||
"title": "Liikumiste eelvaated: {{camera}}",
|
||||
"mobileSettingsTitle": "Liikumiste eelvaadete seadistused",
|
||||
"mobileSettingsDesc": "Kohenda taasesituse kiirust ja heledust ning vali kuupäev, et vaadata läbi ainult liikumist kajastavaid klipid.",
|
||||
"mobileSettingsDesc": "Kohenda taasesituse kiirust, heledust ja kadreerimist ning vali kuupäev, et vaadata läbi ainult liikumist kajastavaid klipid.",
|
||||
"dim": "Hämarus",
|
||||
"dimAria": "Muuda hämarust",
|
||||
"dimDesc": "Kohenda hämarust parandamaks liikumisala nähtavust.",
|
||||
@@ -87,6 +87,9 @@
|
||||
"seekAria": "Keri „{{camera}}“ kaamera vaade ajatempli juurde: {{time}}",
|
||||
"filter": "Filtreeri",
|
||||
"filterDesc": "Näitamaks ainult liikumisega klippe antud aladel, vali soovitud piirkonnad.",
|
||||
"filterClear": "Tühjenda"
|
||||
"filterClear": "Tühjenda",
|
||||
"crop": "Kadreeri filtreerimiseks",
|
||||
"cropAria": "Lülita filtreeritud alade kadreeritud eelvaated sisse/välja",
|
||||
"cropDesc": "Täiskaadri kuvamise asemel suumi eelvaated valitud filtritega märgitud alani."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,9 @@
|
||||
"error": {
|
||||
"updatedLPRFailed": "Sõiduki numbrimärgi uuendamine ei õnnestunud: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"share": "Jaga seda ülevaadatud objekti"
|
||||
}
|
||||
},
|
||||
"snapshotScore": {
|
||||
|
||||
@@ -538,7 +538,7 @@
|
||||
"licensePlateRecognition": {
|
||||
"title": "Sõidukite numbrimärkide tuvastus"
|
||||
},
|
||||
"title": "Andmerikastuse seaded",
|
||||
"title": "Andmerikastuse seadistused",
|
||||
"restart_required": "Taaskäivitamine on vajalik (Andmerikastuse seadeid on muudetud)",
|
||||
"toast": {
|
||||
"success": "Andmerikastamise seaded on salvestatud. Muudatuste rakendamiseks taaskäivitage Frigate.",
|
||||
|
||||
@@ -502,7 +502,7 @@
|
||||
},
|
||||
"model_type": {
|
||||
"label": "物体検知モデル種別",
|
||||
"description": "一部の検出器が最適化に使用する検知モデルのアーキテクチャ種別(ssd, yolox, yolonas)。"
|
||||
"description": "一部の検出器が最適化に使用する検知モデルのアーキテクチャ種別(ssd, yolox, yolonas, yolo-generic, rfdetr, dfine)。"
|
||||
}
|
||||
},
|
||||
"model_path": {
|
||||
@@ -675,7 +675,7 @@
|
||||
},
|
||||
"model_type": {
|
||||
"label": "物体検知モデル種別",
|
||||
"description": "一部の検出器が最適化に使用する検知モデルのアーキテクチャ種別(ssd, yolox, yolonas)。"
|
||||
"description": "一部の検出器が最適化に使用する検知モデルのアーキテクチャ種別(ssd, yolox, yolonas, yolo-generic, rfdetr, dfine)。"
|
||||
}
|
||||
},
|
||||
"genai": {
|
||||
@@ -1555,7 +1555,7 @@
|
||||
"description": "UI 内でのカメラの並び順に使用される数値(デフォルトダッシュボードとリスト)。値が大きいほど後ろに表示されます。"
|
||||
},
|
||||
"dashboard": {
|
||||
"label": "UI に表示",
|
||||
"label": "ライブダッシュボードに表示",
|
||||
"description": "このカメラを Frigate UI 全体に表示するかを切り替えます。無効化した場合、再表示するには設定ファイルを手動編集する必要があります。"
|
||||
},
|
||||
"review": {
|
||||
|
||||
@@ -147,7 +147,19 @@
|
||||
"deleteNow": "Trinti Dabar",
|
||||
"next": "Kitas",
|
||||
"off": "OFF",
|
||||
"continue": "Tęsti"
|
||||
"continue": "Tęsti",
|
||||
"add": "Pridėti",
|
||||
"applying": "Pritaikoma…",
|
||||
"undo": "Grąžinti",
|
||||
"copiedToClipboard": "Nukopijuotą į iškarpinę",
|
||||
"modified": "Modifikuota",
|
||||
"overridden": "Perrašytas",
|
||||
"resetToGlobal": "Sugąžinti į Globalius",
|
||||
"resetToDefault": "Sugrąžinti į Numatytuosius",
|
||||
"saveAll": "Išsaugoti Viską",
|
||||
"savingAll": "Viskas Išsaugoma…",
|
||||
"undoAll": "Grąžinti Viską",
|
||||
"retry": "Pakartoti"
|
||||
},
|
||||
"menu": {
|
||||
"system": "Sistema",
|
||||
@@ -199,7 +211,10 @@
|
||||
"bg": "Bulgarų",
|
||||
"gl": "Galician",
|
||||
"id": "Indonesian",
|
||||
"ur": "Urdu"
|
||||
"ur": "Urdu",
|
||||
"zhHant": "Tradicinė Kinijos",
|
||||
"hr": "Kroatų",
|
||||
"bs": "Bosnių"
|
||||
},
|
||||
"appearance": "Išvaizda",
|
||||
"darkMode": {
|
||||
@@ -249,7 +264,11 @@
|
||||
"setPassword": "Nustatyti Slaptažodi"
|
||||
},
|
||||
"uiPlayground": "UI Playground",
|
||||
"classification": "Klasifikavimas"
|
||||
"classification": "Klasifikavimas",
|
||||
"profiles": "Profiliai",
|
||||
"actions": "Veiksmai",
|
||||
"features": "Ypatybės",
|
||||
"chat": "Pokalbiai"
|
||||
},
|
||||
"toast": {
|
||||
"copyUrlToClipboard": "URL nukopijuotas į atmintį.",
|
||||
@@ -258,7 +277,8 @@
|
||||
"error": {
|
||||
"title": "Nepavyko išsaugoti konfiguracijos pakeitimų: {{errorMessage}}",
|
||||
"noMessage": "Nepavyko išsaugoti konfiguracijos pakeitimų"
|
||||
}
|
||||
},
|
||||
"success": "Konfiguracijos pokyčiai išsaugoti sėkmingai."
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
@@ -302,5 +322,10 @@
|
||||
"field": {
|
||||
"optional": "Pasirinktinis",
|
||||
"internalID": "Vidinį ID Frigate naudoja konfiguracijoje ir duombazėje"
|
||||
},
|
||||
"no_items": "Nėra įrašų",
|
||||
"validation_errors": "Tikrinimo Klaidos",
|
||||
"credentialField": {
|
||||
"savedPlaceholder": "Išsaugota - palikite tuščia kad išliktų esamas"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,10 @@
|
||||
}
|
||||
},
|
||||
"birdseye": "Birdseye"
|
||||
}
|
||||
},
|
||||
"showAll": "Rodyti visas kamerų grupes",
|
||||
"showLess": "Rodyti mažiau",
|
||||
"editGroups": "Koreguoti Kameru Grupes"
|
||||
},
|
||||
"debug": {
|
||||
"options": {
|
||||
@@ -82,6 +85,7 @@
|
||||
"zones": "Zonos",
|
||||
"mask": "Maskuotė",
|
||||
"motion": "Judesys",
|
||||
"regions": "Regionas"
|
||||
"regions": "Regionas",
|
||||
"paths": "Keliai"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
},
|
||||
"state": {
|
||||
"submitted": "Pateikta"
|
||||
},
|
||||
"toast": {
|
||||
"error": "Pateikti į Frigate+ nepavyko. Prašau patikrinkite savo tinklo nustatymus ir bandykite dar kartą."
|
||||
}
|
||||
},
|
||||
"submitToPlus": {
|
||||
@@ -79,7 +82,16 @@
|
||||
"noVaildTimeSelected": "Nėra pasirinkto tinkamo laikotarpio"
|
||||
},
|
||||
"view": "Žiūrėti"
|
||||
}
|
||||
},
|
||||
"case": {
|
||||
"newCaseOption": "Kurti naują užklausą",
|
||||
"newCaseNamePlaceholder": "Naujos užklausos pavadinimas",
|
||||
"newCaseDescriptionPlaceholder": "Užklausos aprašymas",
|
||||
"label": "Užklausa",
|
||||
"nonAdminHelp": "Nauja užklausa bus sukurta šiems išrašams",
|
||||
"placeholder": "Pasirinkti užklausą"
|
||||
},
|
||||
"queueing": "Sudaroma išrašų eilė...."
|
||||
},
|
||||
"recording": {
|
||||
"button": {
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"triggers": "Trigeriai",
|
||||
"roles": "Rolės",
|
||||
"cameraManagement": "Valdymas",
|
||||
"cameraReview": "Peržiūra"
|
||||
"cameraReview": "Peržiūra",
|
||||
"profiles": "Profiliai"
|
||||
},
|
||||
"dialog": {
|
||||
"unsavedChanges": {
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
"streaming": {
|
||||
"label": "Strøm",
|
||||
"restreaming": {
|
||||
"disabled": "Restrømming er ikke aktivert for dette kameraet.",
|
||||
"disabled": "Viderestrømming er ikke aktivert for dette kameraet.",
|
||||
"desc": {
|
||||
"readTheDocumentation": "Se dokumentasjonen",
|
||||
"title": "Konfigurer go2rtc for flere direktestrømmingsalternativer og lyd for dette kameraet."
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
"label": "Direkteavspilling",
|
||||
"streams": {
|
||||
"label": "Navn på direktestrømmer",
|
||||
"description": "Kobling mellom konfigurerte strøm-navn og restream/go2rtc-navn brukt for direkteavspilling."
|
||||
"description": "Kobling mellom konfigurerte strøm-navn og viderestrøm/go2rtc-navn brukt for direkteavspilling."
|
||||
},
|
||||
"height": {
|
||||
"label": "Direktevisningshøyde",
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"go2rtc": {
|
||||
"label": "go2rtc",
|
||||
"description": "Innstillinger for den integrerte go2rtc-tjenesten for videreformidling og oversettelse av direktestrømmer."
|
||||
"description": "Innstillinger for den integrerte go2rtc-tjenesten for viderestrømming og oversettelse av direktestrømmer."
|
||||
},
|
||||
"mqtt": {
|
||||
"label": "MQTT",
|
||||
@@ -586,7 +586,7 @@
|
||||
"description": "Modus for å inkludere kameraer i Fugleperspektiv: 'objects', 'motion' eller 'continuous'."
|
||||
},
|
||||
"restream": {
|
||||
"label": "Videreformidle RTSP",
|
||||
"label": "Viderestrøm RTSP",
|
||||
"description": "Videreformidle Fugleperspektiv-utdataen som en RTSP-strøm; aktivering av dette vil holde Fugleperspektiv kjørende kontinuerlig."
|
||||
},
|
||||
"width": {
|
||||
@@ -756,10 +756,10 @@
|
||||
},
|
||||
"live": {
|
||||
"label": "Direkteavspilling",
|
||||
"description": "Innstillinger for å kontrollere oppløsning og kvalitet på jsmpeg-direktestrømmer. Dette påvirker ikke kameraer som bruker go2rtc for direktevisning.",
|
||||
"description": "Innstillinger for å kontrollere oppløsning og kvalitet på jsmpeg-direktestrømmer. Dette påvirker ikke vderestrømmede kameraer som bruker go2rtc for direktevisning.",
|
||||
"streams": {
|
||||
"label": "Navn på direktestrømmer",
|
||||
"description": "Kobling mellom konfigurerte strøm-navn og restream/go2rtc-navn brukt for direkteavspilling."
|
||||
"description": "Kobling mellom konfigurerte strøm-navn og viderestrøm/go2rtc-navn brukt for direkteavspilling."
|
||||
},
|
||||
"height": {
|
||||
"label": "Direktevisningshøyde",
|
||||
|
||||
@@ -126,5 +126,7 @@
|
||||
"Rodent": "Gnager",
|
||||
"baby": "Baby",
|
||||
"rickshaw": "Rickshaw",
|
||||
"rodent": "Gnager"
|
||||
"rodent": "Gnager",
|
||||
"possum": "Pungrotte",
|
||||
"garbage_truck": "Avfallsbil"
|
||||
}
|
||||
|
||||
@@ -188,9 +188,23 @@
|
||||
"title": "Rediger klassifiseringsmodell",
|
||||
"descriptionState": "Rediger klassene for denne tilstandsklassifiseringsmodellen. Endringer vil kreve at modellen trenes på nytt.",
|
||||
"descriptionObject": "Rediger objekttypen og klassifiseringstypen for denne objektklassifiseringsmodellen.",
|
||||
"stateClassesInfo": "Modellen ble oppdatert. Tren modellen på nytt for at endringene i klassene skal bli iverksatt."
|
||||
"stateClassesInfo": "Modellen ble oppdatert. Tren modellen på nytt for at endringene i klassene skal bli iverksatt.",
|
||||
"enabled": "Aktivert",
|
||||
"enabledDesc": "Kjør denne modellen. Når den er deaktivert, stopper den og vil ikke lenger klassifisere.",
|
||||
"saveAttempts": "Lagringsforsøk",
|
||||
"saveAttemptsDesc": "Antall bilder fra klassifiseringsforsøk som skal lagres for visning av nylige klassifiseringer.",
|
||||
"motion": "Utfør ved bevegelse",
|
||||
"motionDesc": "Utfør klassifisering når bevegelse registreres innenfor det konfigurerte utsnittet.",
|
||||
"interval": "Intervall",
|
||||
"intervalDesc": "Sekunder mellom periodiske klassifiseringskjøringer. La stå tom for å kun kjøre ved bevegelse.",
|
||||
"intervalPlaceholder": "Ingen intervall",
|
||||
"errors": {
|
||||
"saveAttemptsInvalid": "Lagringsforsøk må være et heltall på 0 eller høyere",
|
||||
"intervalInvalid": "Intervall må være et heltall større enn 0"
|
||||
}
|
||||
},
|
||||
"none": "Ingen",
|
||||
"reclassifyImageAs": "Reklassifiser bilde som:",
|
||||
"reclassifyImage": "Reklassifiser bilde"
|
||||
"reclassifyImage": "Reklassifiser bilde",
|
||||
"disabled": "Deaktivert"
|
||||
}
|
||||
|
||||
@@ -1305,7 +1305,7 @@
|
||||
},
|
||||
"featuresPopover": {
|
||||
"title": "Strømfunksjoner",
|
||||
"description": "Bruk go2rtc-restrømming for å redusere antall tilkoblinger til kameraet."
|
||||
"description": "Bruk go2rtc-viderestrømming for å redusere antall tilkoblinger til kameraet."
|
||||
}
|
||||
},
|
||||
"step4": {
|
||||
@@ -1382,7 +1382,7 @@
|
||||
"saving": "Lagrer…",
|
||||
"saved": "Lagret",
|
||||
"label": "Kamerastatus",
|
||||
"description": "Angi driftsstatus for hvert kamera.<br /><br /><strong>På</strong>: Strømmer behandles normalt.<br /><strong>Av</strong>: Setter behandlingen midlertidig på pause. Vedvarer ikke etter omstart av Frigate.<br /><strong>Deaktivert</strong>: Stopper behandlingen og lagrer endringen i konfigurasjonen din. En omstart kreves for å aktivere et deaktivert kamera igjen.<br /><br /><em>Merk: Deaktivering påvirker ikke videreformidling (restream) i go2rtc.</em><br /><br />Dra i ikonet for å endre rekkefølgen på aktive kameraer slik de vises i grensesnittet, inkludert Live-dashbordet og rullegardinmenyene for kameravalg.",
|
||||
"description": "Angi driftsstatus for hvert kamera.<br /><br /><strong>På</strong>: Strømmer behandles normalt.<br /><strong>Av</strong>: Setter behandlingen midlertidig på pause. Vedvarer ikke etter omstart av Frigate.<br /><strong>Deaktivert</strong>: Stopper behandlingen og lagrer endringen i konfigurasjonen din. En omstart kreves for å aktivere et deaktivert kamera igjen.<br /><br /><em>Merk: Deaktivering påvirker ikke viderestrømming (restream) i go2rtc.</em><br /><br />Dra i ikonet for å endre rekkefølgen på aktive kameraer slik de vises i grensesnittet, inkludert Live-dashbordet og rullegardinmenyene for kameravalg.",
|
||||
"disabledSubheading": "Deaktivert i konfigurasjonen",
|
||||
"status": {
|
||||
"on": "På",
|
||||
@@ -1670,8 +1670,8 @@
|
||||
"preset-rtmp-generic": "RTMP (Generisk)",
|
||||
"preset-rtsp-blue-iris": "RTSP - Blue Iris",
|
||||
"preset-rtsp-udp": "RTSP - UDP",
|
||||
"preset-rtsp-restream": "RTSP - Videreformidling fra go2rtc",
|
||||
"preset-rtsp-restream-low-latency": "RTSP - Videreformidling fra go2rtc (lav forsinkelse)",
|
||||
"preset-rtsp-restream": "RTSP - Viderestrømming fra go2rtc",
|
||||
"preset-rtsp-restream-low-latency": "RTSP - Viderestrømming fra go2rtc (lav forsinkelse)",
|
||||
"preset-rtsp-generic": "RTSP (Generisk)",
|
||||
"preset-vaapi": "VAAPI (Intel/AMD GPU)"
|
||||
},
|
||||
@@ -1725,7 +1725,11 @@
|
||||
"valueLabel": "Verdi",
|
||||
"providerNamePlaceholder": "f.eks. openai",
|
||||
"variableNameLabel": "Variabelnavn",
|
||||
"variableNamePlaceholder": "f.eks MIN_VARIABEL"
|
||||
"variableNamePlaceholder": "f.eks MIN_VARIABEL",
|
||||
"providerNameLabel": "Tilbydernavn",
|
||||
"loggerNameLabel": "Loggernavn",
|
||||
"loggerNamePlaceholder": "f.eks. frigate.record",
|
||||
"keyPatternError": "Bruk kun bokstaver, tall, bindestreker og understreker (ingen mellomrom)"
|
||||
},
|
||||
"roleMap": {
|
||||
"remove": "Fjern",
|
||||
@@ -1775,7 +1779,7 @@
|
||||
"cameraInputs": {
|
||||
"itemTitle": "Strøm {{index}}",
|
||||
"sourceMode": {
|
||||
"restream": "Videreformidling / Restream (go2rtc)",
|
||||
"restream": "Viderestrøm (go2rtc)",
|
||||
"manual": "Manuell kildesti",
|
||||
"go2rtcStreamLabel": "go2rtc-strøm",
|
||||
"go2rtcStreamPlaceholder": "Velg en go2rtc-strøm",
|
||||
@@ -1860,7 +1864,7 @@
|
||||
}
|
||||
},
|
||||
"go2rtcStreams": {
|
||||
"description": "Administrer go2rtc-strømkonfigurasjoner for videreformidling av kamera-strømmer. Hver strøm har ett navn og én eller flere kilde-URL-er.",
|
||||
"description": "Administrer go2rtc-strømkonfigurasjoner for viderestrømming av kamera-strømmer. Hver strøm har ett navn og én eller flere kilde-URL-er.",
|
||||
"ffmpeg": {
|
||||
"hardwareAuto": "Automatisk (anbefalt)",
|
||||
"useFfmpegModule": "Bruk kompatibilitetsmodus (ffmpeg)",
|
||||
@@ -2010,7 +2014,8 @@
|
||||
"inputDimensionsNotDetectResolution": "Modellens inndata for bredde og høyde er dimensjonene til selve objektdeteksjonsmodellen, ikke kameraets deteksjonsoppløsning. De skal samsvare med dimensjonene til modellen du bruker – vanligvis en kvadratisk størrelse som 320x320 eller 640x640."
|
||||
},
|
||||
"ffmpeg": {
|
||||
"hwaccelManualNotRecommended": "Manuelle argumenter for maskinvareakselerasjon anbefales ikke. Med mindre det foreligger et spesifikt behov, bør du velge forhåndsinnstillingen som samsvarer med maskinvaren din."
|
||||
"hwaccelManualNotRecommended": "Manuelle argumenter for maskinvareakselerasjon anbefales ikke. Med mindre det foreligger et spesifikt behov, bør du velge forhåndsinnstillingen som samsvarer med maskinvaren din.",
|
||||
"inputsMissingGo2rtcStream": "En innstrøm nedenfor peker til en go2rtc-viderestrøm som ikke lenger eksisterer. Velg en eksisterende viderestrømeller oppgi kameraets URL manuelt, ellers vil ikke kameraet klarer å koble til."
|
||||
},
|
||||
"onvif": {
|
||||
"autotrackingNoZones": "Autosporing krever minst én sone. Definer en sone for dette kameraet under Masker / Soner, og angi den deretter som en påkrevd sone nedenfor."
|
||||
|
||||
@@ -65,13 +65,16 @@
|
||||
"select": "Selecteer",
|
||||
"toast": {
|
||||
"error": {
|
||||
"failed": "Exporteren is mislukt: {{error}}",
|
||||
"failed": "Exporter naar wachtrij mislukt: {{error}}",
|
||||
"noVaildTimeSelected": "Geen geldig tijdsbereik geselecteerd",
|
||||
"endTimeMustAfterStartTime": "Eindtijd moet na starttijd zijn"
|
||||
"endTimeMustAfterStartTime": "Eindtijd moet na starttijd zijn",
|
||||
"noValidTimeSelected": "Geen geldige tijd voor geselecteerd bereik"
|
||||
},
|
||||
"success": "Export is succesvol gestart. Bekijk het bestand op de exportpagina.",
|
||||
"view": "Weergeven",
|
||||
"queued": "Export staat in de wachtrij. Bekijk voortgang op de exports pagina."
|
||||
"queued": "Export staat in de wachtrij. Bekijk voortgang op de exports pagina.",
|
||||
"batchSuccess_one": "1 export gestart. Zaak wordt aangemaakt.",
|
||||
"batchSuccess_other": "{{count}} export gestart. Zaak wordt aangemaakt."
|
||||
},
|
||||
"fromTimeline": {
|
||||
"saveExport": "Export opslaan",
|
||||
@@ -125,7 +128,9 @@
|
||||
"startedNoCase_one": "1 export gestart.",
|
||||
"startedNoCase_other": "{{count}} Exports gestart.",
|
||||
"partial": "{{successful}} van {{total}} exports gestart. Mislukte items: {{failedItems}}",
|
||||
"failed": "Het starten van {{total}} exports is mislukt. Mislukt: {{failedItems}}"
|
||||
"failed": "Het starten van {{total}} exports is mislukt. Mislukt: {{failedItems}}",
|
||||
"started_one": "1 export gestart. Zaak wordt aangemaakt.",
|
||||
"started_other": "{{count}} exports gestart. Zaak wordt aangemaakt."
|
||||
},
|
||||
"exportingButton": "Exporteren...",
|
||||
"exportButton_one": "Exporteer 1 revisie",
|
||||
|
||||
@@ -867,8 +867,8 @@
|
||||
"description": "Ordine numerică folosită pentru sortarea camerei în interfață (dashboard și liste); numerele mai mari apar mai târziu."
|
||||
},
|
||||
"dashboard": {
|
||||
"label": "Arată în interfață",
|
||||
"description": "Comută vizibilitatea acestei camere peste tot în interfața Frigate. Dezactivarea acestei opțiuni va necesita editarea manuală a configurației pentru a vedea din nou camera în interfață."
|
||||
"label": "Afișează pe tabloul de bord Live",
|
||||
"description": "Comută dacă această cameră este vizibilă pe tabloul de bord Live implicit „Toate camerele”. Camera rămâne disponibilă în restul interfeței, inclusiv în grupurile de camere și în setări."
|
||||
},
|
||||
"review": {
|
||||
"label": "Arată în Revizuire",
|
||||
|
||||
@@ -2338,8 +2338,8 @@
|
||||
"description": "Ordinea numerică folosită pentru sortarea camerei în interfață (dashboard-ul implicit și liste); numerele mai mari apar mai târziu."
|
||||
},
|
||||
"dashboard": {
|
||||
"label": "Arată în interfață",
|
||||
"description": "Comută dacă această cameră este vizibilă peste tot în interfața Frigate. Dezactivarea acestei opțiuni va necesita editarea manuală a config-ului pentru a vedea din nou camera în interfață."
|
||||
"label": "Afișează pe tabloul de bord Live",
|
||||
"description": "Comută dacă această cameră este vizibilă pe tabloul de bord Live implicit „Toate camerele”. Camera rămâne disponibilă în restul interfeței, inclusiv în grupurile de camere și în setări."
|
||||
},
|
||||
"review": {
|
||||
"label": "Arată în Revizuire",
|
||||
|
||||
@@ -126,5 +126,7 @@
|
||||
"baby_stroller": "Cărucior de copii",
|
||||
"rickshaw": "Ricșă",
|
||||
"Rodent": "Rozătoare",
|
||||
"rodent": "Rozătoare"
|
||||
"rodent": "Rozătoare",
|
||||
"garbage_truck": "Mașină de gunoi",
|
||||
"possum": "Oposum"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user