mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-07 06:03:01 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3725ad142f | ||
|
|
ee80129086 |
@@ -181,7 +181,6 @@ noninteractive
|
||||
noprint
|
||||
Norfair
|
||||
nptype
|
||||
NTFS
|
||||
NTSC
|
||||
numpy
|
||||
nvenc
|
||||
|
||||
@@ -42,7 +42,7 @@ runs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -55,9 +55,6 @@ updates:
|
||||
# Needs Python 3.12 and numpy 2
|
||||
- dependency-name: "librosa"
|
||||
versions: [">=1.0"]
|
||||
# pandas 3.1 requires numpy 2
|
||||
- dependency-name: "pandas"
|
||||
versions: [">=3.1"]
|
||||
# Needs Python 3.12
|
||||
- dependency-name: "scipy"
|
||||
versions: [">=1.18"]
|
||||
|
||||
@@ -590,7 +590,7 @@ jobs:
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -124,7 +124,5 @@ jobs:
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m mypy --config-file frigate/mypy.ini frigate"
|
||||
- name: Check API spec is up to date
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check"
|
||||
- name: Check analytics schema is up to date
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_analytics_schema.py --check"
|
||||
- name: Run unit tests in devcontainer
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m frigate.test"
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest"
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -36,7 +36,7 @@ When reviewing code, do NOT comment on:
|
||||
- **Formatting**: Ruff (configured in `pyproject.toml`)
|
||||
- **Linting**: Ruff with rules defined in project config
|
||||
- **Type Checking**: Use type hints consistently
|
||||
- **Testing**: unittest framework - use `python3 -u -m frigate.test` to run the suite in parallel groups
|
||||
- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests
|
||||
- **Language**: American English for all code, comments, and documentation
|
||||
- **Punctuation**: Do not use em dashes in documentation, comments, or strings; reword with standard punctuation (commas, colons, parentheses, or separate sentences)
|
||||
|
||||
@@ -196,7 +196,7 @@ Key rules enforced:
|
||||
### Backend Testing
|
||||
|
||||
- **Framework**: Python unittest
|
||||
- **Run Command**: `python3 -u -m frigate.test` (parallel groups; plain `python3 -u -m unittest` runs serially)
|
||||
- **Run Command**: `python3 -u -m unittest`
|
||||
- **Location**: `frigate/test/`
|
||||
- **Coverage**: Aim for comprehensive test coverage of core functionality
|
||||
- **Pattern**: Use `TestCase` classes with descriptive test method names
|
||||
@@ -219,8 +219,8 @@ Key rules enforced:
|
||||
### Python Backend
|
||||
|
||||
```bash
|
||||
# Run all tests in parallel groups (-j to set the number of jobs)
|
||||
python3 -u -m frigate.test
|
||||
# Run all tests
|
||||
python3 -u -m unittest
|
||||
|
||||
# Run specific test file
|
||||
python3 -u -m unittest frigate.test.test_ffmpeg_presets
|
||||
|
||||
+2
-2
@@ -64,7 +64,7 @@ Pull requests that appear to be unreviewed AI output will be closed without revi
|
||||
- **Python** — use modern language features (type hints, pattern matching, f-strings, dataclasses)
|
||||
- **Formatting**: Ruff (configured in `pyproject.toml`)
|
||||
- **Linting**: Ruff
|
||||
- **Testing**: `python3 -u -m frigate.test` (runs the suite in parallel groups)
|
||||
- **Testing**: `python3 -u -m unittest`
|
||||
- **Logging**: Use module-level `logger = logging.getLogger(__name__)` with lazy formatting
|
||||
- **Async**: All external I/O must be async. No blocking calls in async functions.
|
||||
- **Error handling**: Use specific exception types. Keep try blocks minimal.
|
||||
@@ -82,7 +82,7 @@ Pull requests that appear to be unreviewed AI output will be closed without revi
|
||||
|
||||
```bash
|
||||
# Python
|
||||
python3 -u -m frigate.test # Run all tests in parallel groups
|
||||
python3 -u -m unittest # Run all tests
|
||||
python3 -u -m unittest frigate.test.test_ffmpeg_presets # Run specific test
|
||||
ruff format frigate # Format
|
||||
ruff check frigate # Lint
|
||||
|
||||
@@ -54,7 +54,7 @@ run: local
|
||||
|
||||
run_tests: local
|
||||
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \
|
||||
python3 -u -m frigate.test
|
||||
python3 -u -m unittest
|
||||
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \
|
||||
python3 -u -m mypy --config-file frigate/mypy.ini frigate
|
||||
|
||||
|
||||
@@ -383,7 +383,6 @@ FROM deps AS frigate
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=standard
|
||||
|
||||
# Pre-compile bytecode so a read-only rootfs doesn't force re-parsing the
|
||||
# source tree on every boot (pip-installed packages are already compiled)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ruff == 0.17.3
|
||||
ruff == 0.17.1
|
||||
|
||||
# types
|
||||
types-peewee == 4.0.*
|
||||
|
||||
@@ -2,9 +2,9 @@ aiofiles == 25.1.*
|
||||
click == 8.5.*
|
||||
# FastAPI
|
||||
aiohttp == 3.14.*
|
||||
starlette == 1.10.*
|
||||
starlette == 1.9.*
|
||||
starlette-context == 0.5.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.162.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.159.*
|
||||
uvicorn == 0.54.*
|
||||
slowapi == 0.1.*
|
||||
joserfc == 1.7.*
|
||||
@@ -16,7 +16,7 @@ python-multipart == 0.0.32
|
||||
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
||||
tensorflow-cpu == 2.21.* ; platform_machine == 'x86_64'
|
||||
# General
|
||||
mypy == 2.4.1
|
||||
mypy == 2.4.0
|
||||
onvif-zeep-async == 4.3.*
|
||||
paho-mqtt == 2.1.*
|
||||
pandas == 3.0.*
|
||||
@@ -25,7 +25,7 @@ peewee_migrate == 2.3.*
|
||||
psutil == 7.2.*
|
||||
pydantic == 2.14.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
tzdata == 2026.6
|
||||
tzdata == 2026.5
|
||||
pyzmq == 27.2.*
|
||||
ruamel.yaml == 0.19.*
|
||||
tzlocal == 5.4.5
|
||||
@@ -45,11 +45,11 @@ scipy == 1.17.*
|
||||
openvino == 2026.4.*
|
||||
onnxruntime == 1.31.*
|
||||
# Embeddings
|
||||
transformers == 5.22.*
|
||||
transformers == 5.20.*
|
||||
# Generative AI
|
||||
google-genai == 2.34.*
|
||||
google-genai == 2.32.*
|
||||
ollama == 0.6.*
|
||||
openai == 3.41.*
|
||||
openai == 3.36.*
|
||||
# push notifications
|
||||
py-vapid == 1.9.6
|
||||
pywebpush == 2.5.*
|
||||
@@ -68,7 +68,7 @@ prometheus-client == 0.26.*
|
||||
tflite_runtime @ https://github.com/frigate-nvr/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_x86_64.whl; platform_machine == 'x86_64'
|
||||
tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_aarch64.whl; platform_machine == 'aarch64'
|
||||
# audio transcription
|
||||
sherpa-onnx==2.0.*
|
||||
sherpa-onnx==1.13.*
|
||||
faster-whisper==1.2.*
|
||||
librosa==0.11.*
|
||||
soundfile==0.14.*
|
||||
|
||||
@@ -25,7 +25,6 @@ RUN --mount=type=bind,from=rk-wheels,source=/rk-wheels,target=/deps/rk-wheels \
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=rk
|
||||
COPY docker/rockchip/COCO /COCO
|
||||
COPY docker/rockchip/conv2rknn.py /opt/conv2rknn.py
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ RUN --mount=type=bind,source=docker/rocm/prune_rocm.sh,target=/deps/prune_rocm.s
|
||||
|
||||
WORKDIR /opt/frigate
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=rocm
|
||||
|
||||
#######################################################################
|
||||
FROM deps-prelim AS rocm-prelim-hsa-override0
|
||||
|
||||
@@ -15,4 +15,3 @@ ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:${INCLUDED_FFMPEG_VERSIO
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=rpi
|
||||
|
||||
@@ -22,7 +22,6 @@ pip3 install --no-deps -U /deps/synap-wheels/*.whl
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=synaptics
|
||||
|
||||
COPY --from=synap1680-wheels /rootfs/usr/local/lib/*.so /usr/lib
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels
|
||||
&& pip3 install -U /deps/trt-wheels/*.whl
|
||||
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=tensorrt
|
||||
COPY docker/tensorrt/detector/rootfs/etc/ld.so.conf.d /etc/ld.so.conf.d
|
||||
RUN ldconfig
|
||||
|
||||
|
||||
@@ -151,7 +151,6 @@ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=tensorrt-jp6
|
||||
|
||||
# Fixes "Error importing detector runtime: /usr/lib/aarch64-linux-gnu/libstdc++.so.6: cannot allocate memory in static TLS block"
|
||||
ENV LD_PRELOAD /usr/lib/aarch64-linux-gnu/libstdc++.so.6
|
||||
@@ -1,2 +1,2 @@
|
||||
onnx == 1.14.0; platform_machine == 'aarch64'
|
||||
protobuf == 5.29.6; platform_machine == 'aarch64'
|
||||
protobuf == 3.20.3; platform_machine == 'aarch64'
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
id: analytics
|
||||
title: Anonymous Analytics
|
||||
---
|
||||
|
||||
import AnalyticsFields from "@site/src/components/AnalyticsFields";
|
||||
import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Frigate can send one anonymous usage report a day. The reports show the maintainers which hardware to support, which features people use, and how releases perform. Sharing is off until you turn it on.
|
||||
|
||||
## Turning it on
|
||||
|
||||
Enable **Share anonymous analytics** at <NavPath path="Settings > System > Telemetry" />, or set it in your config:
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
analytics: true
|
||||
```
|
||||
|
||||
The same page has a **Preview the report** button that shows exactly what the next report contains.
|
||||
|
||||
## How it's sent
|
||||
|
||||
- Once a day, as a JSON POST to `https://analytics.frigate.video/report`
|
||||
- The server looks up your country and region from your IP address and never stores the address
|
||||
- Raw reports are kept for 60 days; only aggregate totals are published
|
||||
- A random install ID, stored in `/config/.analytics.json`, keeps your install from being counted twice. Turning sharing off deletes it
|
||||
|
||||
## What's never sent
|
||||
|
||||
- Camera, zone, group, profile, or user names
|
||||
- Object labels, face names, or license plate text
|
||||
- IP addresses, hostnames, URLs, or stream paths
|
||||
- Credentials or API keys
|
||||
- Events, recordings, or anything from them
|
||||
|
||||
## Every field
|
||||
|
||||
Fields marked public appear in the published totals. The machine-readable schema is [frigate-analytics-schema.json](pathname:///frigate-analytics-schema.json).
|
||||
|
||||
<AnalyticsFields />
|
||||
@@ -504,6 +504,9 @@ review:
|
||||
# Presets adjust the tone and level of detail of the user-facing title,
|
||||
# summary, and scene description; "default" leaves the built-in prompt unchanged.
|
||||
response_style: default
|
||||
# Optional: Let the model think before writing the description (default: shown below)
|
||||
# Only applies to models with a per-request thinking toggle (ollama and llamacpp providers).
|
||||
enable_thinking: False
|
||||
# Optional: Save thumbnails sent to the GenAI provider for review/debugging purposes (default: shown below)
|
||||
debug_save_thumbnails: False
|
||||
|
||||
@@ -578,10 +581,6 @@ notifications:
|
||||
email: "admin@example.com"
|
||||
# Optional: Cooldown time for notifications in seconds (default: shown below)
|
||||
cooldown: 0
|
||||
# Optional: Delivery of notifications sent while an alert is active (default: shown below)
|
||||
# Options are immediate (deliver right away or drop) and reliable (retry for up to 30 seconds)
|
||||
# NOTE: reliable can deliver late notifications that still link to the live view
|
||||
ttl: immediate
|
||||
|
||||
# Optional: Record configuration
|
||||
# NOTE: Can be overridden at the camera level
|
||||
@@ -1218,9 +1217,6 @@ ui:
|
||||
|
||||
# Optional: Telemetry configuration
|
||||
telemetry:
|
||||
# Optional: Share one anonymous usage report a day (default: shown below)
|
||||
# NOTE: See https://docs.frigate.video/configuration/advanced/analytics for what is sent
|
||||
analytics: False
|
||||
# Optional: Enabled network interfaces for bandwidth stats monitoring (default: empty list, let nethogs search all)
|
||||
network_interfaces:
|
||||
- eth
|
||||
|
||||
@@ -75,12 +75,11 @@ Running Generative AI models on CPU is not recommended, as high inference times
|
||||
|
||||
You must use a vision-capable model with Frigate. The following models are recommended for local deployment of the `descriptions` and `chat` roles:
|
||||
|
||||
| Model | Review [frame mode](/configuration/genai/genai_review#frame-mode) | Review [response style](/configuration/genai/genai_review#response-style) | Notes |
|
||||
| ------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `qwen3-vl` | `frames` | `natural` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. Follows a sequence of frames on its own. |
|
||||
| `qwen3.6`/`qwen3.8` | `frames` | `natural` | 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` | `annotated_frames` | `default` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. Loses track of activity that repeats or reverses, so it benefits from annotated frames. |
|
||||
| `lfm3-vl` | `annotated_frames` | `concise` | Very efficient with fast inference, making it a good choice for less powerful hardware or high volumes of review items. |
|
||||
| Model | Review [frame mode](/configuration/genai/genai_review#frame-mode) | Notes |
|
||||
| ------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `qwen3-vl` | `frames` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. Follows a sequence of frames on its own. |
|
||||
| `qwen3.6`/`qwen3.8` | `frames` | 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` | `annotated_frames` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. Loses track of activity that repeats or reverses, so it benefits from annotated frames. |
|
||||
|
||||
#### Embedding models
|
||||
|
||||
@@ -96,7 +95,7 @@ The `embeddings` role needs a different kind of model. Text queries are matched
|
||||
The `transcribe` role needs a model that accepts audio input. A text-only or vision-only model cannot serve this role. The following are recommended for local deployment of the `transcribe` role:
|
||||
|
||||
| Model | Notes |
|
||||
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `qwen3-asr` | Dedicated speech recognition model covering 30 languages, and the better choice for transcription quality. It only transcribes, so it cannot be shared with the `descriptions` or `chat` roles. |
|
||||
| `gemma4` | General multimodal model that accepts audio as well as images, so one served model can cover `transcribe` alongside the other roles. Transcript quality is below `qwen3-asr`, particularly on noisy audio. |
|
||||
|
||||
@@ -120,7 +119,7 @@ Vision-language models come in **instruct** variants (fine-tuned to follow instr
|
||||
|
||||
Frigate manages reasoning per task automatically:
|
||||
|
||||
- **Description tasks** (object descriptions, review descriptions, review summaries) are synthesis-only and benefit from concise, direct output, so Frigate disables thinking for these calls when the model exposes a per-request toggle.
|
||||
- **Description tasks** (object descriptions, review descriptions, review summaries) are synthesis-only and benefit from concise, direct output, so Frigate disables thinking for these calls when the model exposes a per-request toggle. Review descriptions can opt back in with [`enable_thinking`](/configuration/genai/genai_review#thinking) for models trained to reason over video.
|
||||
- **Chat** lets you toggle thinking on or off from the composer when the configured model supports it.
|
||||
|
||||
You can use a pure instruct, hybrid, or thinking-capable model with Frigate. No extra configuration is required to disable thinking for descriptions.
|
||||
@@ -344,7 +343,7 @@ To start using Gemini, you must first get an API key from [Google AI Studio](htt
|
||||
1. Navigate to <NavPath path="Settings > Enrichments > Generative AI" />.
|
||||
- Set **Provider** to `gemini`
|
||||
- Set **API key** to your Gemini API key (or use an environment variable such as `{FRIGATE_GEMINI_API_KEY}`)
|
||||
- Set **Model** to the desired model (e.g., `gemini-3.5-flash-lite`)
|
||||
- Set **Model** to the desired model (e.g., `gemini-2.5-flash`)
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -354,18 +353,12 @@ genai:
|
||||
my_provider:
|
||||
provider: gemini
|
||||
api_key: "{FRIGATE_GEMINI_API_KEY}"
|
||||
model: gemini-3.5-flash-lite
|
||||
model: gemini-2.5-flash
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
:::tip
|
||||
|
||||
Gemini 3.x models charge a (roughly) flat ~1,100 tokens per image regardless of resolution, so a 180p preview frame costs the same as a 480p recording frame. Set the review [image source](/configuration/genai/genai_review#image-source) to `recordings` to send higher quality frames at no extra cost, as long as recordings are enabled for the camera.
|
||||
|
||||
:::
|
||||
|
||||
:::note
|
||||
|
||||
To use a different Gemini-compatible API endpoint, set the `provider_options` with the `base_url` key to your provider's API URL. For example:
|
||||
@@ -517,7 +510,7 @@ Frigate's Generative AI features are configured and enabled separately. [Review
|
||||
|
||||
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 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. When the provider returns a reasoning trace (for example with [thinking](/configuration/genai/genai_review#thinking) enabled), it is saved to `reasoning.txt`.
|
||||
- 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.
|
||||
|
||||
@@ -264,6 +264,36 @@ review:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### Thinking
|
||||
|
||||
By default, Frigate turns thinking off for review descriptions, since most models write better descriptions without spending tokens on a reasoning trace. Some models are trained to reason over video, and may follow complex scenes more accurately when allowed to think first. The `enable_thinking` option lets you turn thinking on for review descriptions to evaluate this with your model.
|
||||
|
||||
`enable_thinking` only applies to models that expose a per-request thinking toggle with the `ollama` or `llamacpp` providers. For llama.cpp, the model's chat template must use the `enable_thinking` variable. Other models keep their default behavior. With `openai`, use `runtime_options` (for example `reasoning_effort`) to control reasoning instead.
|
||||
|
||||
Thinking makes each review description take longer and use more tokens. If responses come back empty, the model may be using its whole output budget on reasoning.
|
||||
|
||||
When **Save thumbnails** (`debug_save_thumbnails`) is enabled, the reasoning trace is saved to `reasoning.txt` alongside `prompt.txt` and `response.txt`, so you can see what the model considered before answering. The trace is saved even when the model returns no answer. To compare a saved example with thinking on and off, use `testing-scripts/genai_review_tester.py` in the Frigate repository.
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Review" />.
|
||||
|
||||
- Enable **GenAI config > Enable thinking**. This option is only shown when the provider assigned the descriptions role is `ollama` or `llamacpp`.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
```yaml {4}
|
||||
review:
|
||||
genai:
|
||||
enabled: true
|
||||
enable_thinking: true
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
## Review Reports
|
||||
|
||||
Along with individual review item summaries, Generative AI can also produce a single report of review items from all cameras marked "suspicious" over a specified time period (for example, a daily summary of suspicious activity while you're on vacation).
|
||||
|
||||
@@ -43,7 +43,7 @@ Notifications will be prevented if either:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Notifications" />.
|
||||
1. Navigate to <NavPath path="Settings > Notifications > Notifications" />.
|
||||
- Set **Email** to your email address
|
||||
- Enable notifications for the desired cameras
|
||||
|
||||
@@ -108,29 +108,6 @@ Currently, only Chrome supports images in notifications. Safari and Firefox will
|
||||
|
||||
Different platforms handle notifications differently, some settings changes may be required to get optimal notification delivery.
|
||||
|
||||
### Notification TTL
|
||||
|
||||
By default, Frigate sends notifications for an active alert with a `ttl` of `immediate`, so the push service either delivers them immediately or drops them. If your device is briefly unreachable (asleep, switching from Wi-Fi to cellular, or on a weak signal), those notifications are lost and you only receive the notification sent when the alert ends, which is always kept for up to an hour.
|
||||
|
||||
Setting `ttl` to `reliable` tells the push service to keep retrying these notifications for up to 30 seconds. This makes delivery more reliable, but a notification delivered late will still say an object is detected, even if the object has already left.
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Notifications" />.
|
||||
- Expand **Advanced Settings** and set **Notification TTL** to **Reliable**
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
```yaml
|
||||
notifications:
|
||||
ttl: reliable # retry an undelivered notification for up to 30 seconds
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -361,7 +361,7 @@ The NPU firmware is loaded by the host kernel and is not part of the Frigate ima
|
||||
|
||||
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 can be used under Home Assistant OS 18.4 or newer.
|
||||
Intel NPUs cannot be used under Home Assistant OS, which does not include the NPU firmware.
|
||||
|
||||
### Configuration {#configuration-openvino}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ After closing VS Code, you may still have containers running. To close everythin
|
||||
GitHub will execute unit tests on new PRs. You must ensure that all tests pass.
|
||||
|
||||
```shell
|
||||
python3 -u -m frigate.test
|
||||
python3 -u -m unittest
|
||||
```
|
||||
|
||||
#### FFMPEG Hardware Acceleration
|
||||
@@ -207,7 +207,7 @@ npm run lint
|
||||
- Ensure the backend [unit tests](#unit-tests) pass. Your PR cannot be merged unless tests pass.
|
||||
|
||||
```shell
|
||||
python3 -u -m frigate.test
|
||||
python3 -u -m unittest
|
||||
```
|
||||
|
||||
- Ensure the end-to-end tests pass. They run in Playwright against a production build with mocked API data, so they don't need a running Frigate instance. Add or update tests in `web/e2e/specs/` when you change UI behavior.
|
||||
|
||||
@@ -763,17 +763,20 @@ The community supported docker image tags for the current stable version are:
|
||||
|
||||
:::warning
|
||||
|
||||
As of Home Assistant Operating System 10.2 and Home Assistant 2023.6 defining separate network storage for media is supported.
|
||||
|
||||
There are important limitations in HA OS to be aware of:
|
||||
|
||||
- Separate local storage for media is not yet supported by Home Assistant
|
||||
- AMD GPUs are not supported because HA OS does not include the mesa driver.
|
||||
- Intel NPUs require Home Assistant OS 18.4 or newer. Earlier releases do not include the NPU firmware.
|
||||
- Intel NPUs are not supported because HA OS does not include the NPU firmware.
|
||||
- Nvidia GPUs are not supported because HA Apps do not support the Nvidia runtime.
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
Frigate's media can be kept on separate storage, either a network share (Home Assistant 2023.6 or newer) or a local disk such as a second internal drive or a USB drive (Home Assistant 2026.11 or newer). See [the storage guide](/guides/ha_network_storage.md) for setup instructions.
|
||||
See [the network storage guide](/guides/ha_network_storage.md) for instructions to setup network storage for frigate.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -135,15 +135,6 @@ telemetry:
|
||||
version_check: false
|
||||
```
|
||||
|
||||
### Anonymous Analytics
|
||||
|
||||
If [anonymous analytics](/configuration/advanced/analytics) sharing is turned on, Frigate sends one report a day to `https://analytics.frigate.video`. It's off by default, so no outbound connection happens unless you enable it:
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
analytics: true
|
||||
```
|
||||
|
||||
### Push Notifications
|
||||
|
||||
When [notifications](/configuration/notifications) are enabled and users have registered for push notifications in the web UI, Frigate sends push messages through the browser vendor's push service (e.g., Google FCM, Mozilla autopush). This requires internet access from the Frigate server to these push endpoints.
|
||||
@@ -180,7 +171,7 @@ To run Frigate in an air-gapped or offline environment:
|
||||
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, and leave anonymous analytics off (its default).
|
||||
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.
|
||||
|
||||
@@ -1,48 +1,40 @@
|
||||
---
|
||||
id: ha_network_storage
|
||||
title: Home Assistant storage
|
||||
title: Home Assistant network storage
|
||||
---
|
||||
|
||||
Frigate's media (`/media/frigate`) can be stored on a network share or a separate local disk (internal or USB) instead of the Home Assistant system disk.
|
||||
As of Home Assistant 2023.6, Network Mounted Storage is supported for Apps.
|
||||
|
||||
## Prerequisites
|
||||
## Setting Up Remote Storage For Frigate
|
||||
|
||||
### Network storage
|
||||
### Prerequisites
|
||||
|
||||
- Home Assistant 2023.6 or newer is installed
|
||||
- Running Home Assistant Operating System 10.2 or newer OR Running Supervised with latest os-agent installed (this is required for supervised install)
|
||||
|
||||
### Local disk
|
||||
|
||||
- Home Assistant 2026.11 or newer is installed
|
||||
- The disk is already formatted, ideally as ext4 (Home Assistant does not format disks). Avoid FAT32, exFAT, and NTFS, which do not support Linux file permissions.
|
||||
|
||||
## Setting Up Storage For Frigate
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. Stop the Frigate App
|
||||
|
||||
### Move current data
|
||||
|
||||
Keeping the current data is optional, but `/media/frigate` must be moved or deleted so Home Assistant can mount the storage there. The database and config are stored elsewhere and are not affected.
|
||||
Keeping the current data is optional, but the data will need to be moved regardless so the share can be created successfully.
|
||||
|
||||
#### If you want to keep the current data
|
||||
|
||||
1. Rename the /media/frigate folder to /media/frigate_tmp
|
||||
1. Move the frigate.db, frigate.db-shm, frigate.db-wal files to the /config directory
|
||||
2. Rename the /media/frigate folder to /media/frigate_tmp
|
||||
|
||||
#### If you don't want to keep the current data
|
||||
|
||||
1. Delete the /media/frigate folder and all of its contents
|
||||
|
||||
### Add the storage
|
||||
### Create the media share
|
||||
|
||||
1. Go to **Settings -> System -> Storage -> Add storage**
|
||||
2. Name the storage `frigate` (this is required)
|
||||
3. Choose usage `media`
|
||||
4. Choose the type:
|
||||
- For network storage, choose `Samba/Windows (CIFS)` or `Network File System (NFS)` and fill out the additional required info for your particular NAS
|
||||
- For a local disk, choose `Local disk` and select the disk to use
|
||||
1. Go to **Settings -> System -> Storage -> Add Network Storage**
|
||||
2. Name the share `frigate` (this is required)
|
||||
3. Choose type `media`
|
||||
4. Fill out the additional required info for your particular NAS
|
||||
5. Connect
|
||||
6. Move files from `/media/frigate_tmp` to `/media/frigate` if they were kept in previous step
|
||||
7. Start the Frigate App
|
||||
|
||||
@@ -130,7 +130,6 @@ const sidebars: SidebarsConfig = {
|
||||
label: "Advanced Configuration",
|
||||
items: [
|
||||
"configuration/advanced/system",
|
||||
"configuration/advanced/analytics",
|
||||
"configuration/advanced/reference",
|
||||
{
|
||||
type: "link",
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from "react";
|
||||
import schema from "@site/static/frigate-analytics-schema.json";
|
||||
|
||||
function resolve(node) {
|
||||
if (!node) return node;
|
||||
if (node.$ref) return schema.$defs[node.$ref.split("/").pop()];
|
||||
if (node.anyOf) {
|
||||
const inner = node.anyOf.find((option) => option.type !== "null");
|
||||
return inner ? resolve(inner) : node;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function rows(properties, prefix = "") {
|
||||
return Object.entries(properties).flatMap(([name, field]) => {
|
||||
const path = prefix ? `${prefix}.${name}` : name;
|
||||
const row = {
|
||||
path,
|
||||
description: field.description,
|
||||
isPublic: field["x-public"],
|
||||
};
|
||||
const target = resolve(field);
|
||||
|
||||
if (target?.properties) return [row, ...rows(target.properties, path)];
|
||||
|
||||
const values = resolve(target?.additionalProperties);
|
||||
if (values?.properties)
|
||||
return [row, ...rows(values.properties, `${path}.<key>`)];
|
||||
|
||||
const items = resolve(target?.items);
|
||||
if (items?.properties) return [row, ...rows(items.properties, `${path}[]`)];
|
||||
|
||||
return [row];
|
||||
});
|
||||
}
|
||||
|
||||
export default function AnalyticsFields() {
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Description</th>
|
||||
<th>Public</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows(schema.properties).map((row) => (
|
||||
<tr key={row.path}>
|
||||
<td>
|
||||
<code>{row.path}</code>
|
||||
</td>
|
||||
<td>{row.description}</td>
|
||||
<td>{row.isPublic ? "Yes" : "No"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
-1492
File diff suppressed because it is too large
Load Diff
Vendored
-21
@@ -10,25 +10,6 @@ servers:
|
||||
- url: https://demo.frigate.video/api
|
||||
- url: http://localhost:5001/api
|
||||
paths:
|
||||
/analytics/preview:
|
||||
get:
|
||||
tags:
|
||||
- Analytics
|
||||
summary: Get Analytics Preview
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get the analytics report Frigate would send next, without sending it.
|
||||
operationId: get_analytics_preview_analytics_preview_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/auth/first_time_login:
|
||||
get:
|
||||
tags:
|
||||
@@ -6390,7 +6371,6 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Padding to apply to the vod.
|
||||
default: 0
|
||||
title: Padding
|
||||
@@ -7596,7 +7576,6 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 30
|
||||
title: Scale
|
||||
responses:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Opt-in anonymous analytics reports."""
|
||||
@@ -1 +0,0 @@
|
||||
"""One collector per report section."""
|
||||
@@ -1,238 +0,0 @@
|
||||
"""Cameras section: counts and histograms across cameras, never per camera."""
|
||||
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from frigate.analytics.collectors.common import closed, histogram
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import (
|
||||
CamerasSection,
|
||||
ConnectionQuality,
|
||||
FpsBucket,
|
||||
HeightBucket,
|
||||
HwaccelKey,
|
||||
InputPresetKey,
|
||||
RetainBucket,
|
||||
RetainDays,
|
||||
)
|
||||
from frigate.config import CameraConfig
|
||||
from frigate.config.camera.camera import CameraTypeEnum
|
||||
from frigate.config.camera.ffmpeg import CameraInput, CameraRoleEnum
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
|
||||
# the upper edge of every height bucket but the last
|
||||
HEIGHT_BUCKETS = (
|
||||
(360, HeightBucket.le_360),
|
||||
(540, HeightBucket.h480),
|
||||
(900, HeightBucket.h720),
|
||||
(1260, HeightBucket.h1080),
|
||||
(1800, HeightBucket.h1440),
|
||||
)
|
||||
RESTREAM_HOSTS = frozenset({"127.0.0.1", "localhost"})
|
||||
RESTREAM_PORT = 8554
|
||||
QUALITIES = frozenset(ConnectionQuality)
|
||||
|
||||
|
||||
def height_bucket(height: int) -> HeightBucket:
|
||||
"""The nearest standard resolution bucket for a detect height."""
|
||||
for limit, bucket in HEIGHT_BUCKETS:
|
||||
if height <= limit:
|
||||
return bucket
|
||||
|
||||
return HeightBucket.ge_2160
|
||||
|
||||
|
||||
def fps_bucket(fps: int) -> FpsBucket:
|
||||
"""The bucket for a detect fps."""
|
||||
if fps <= 5:
|
||||
return FpsBucket.le_5
|
||||
|
||||
if fps <= 10:
|
||||
return FpsBucket.f6_10
|
||||
|
||||
return FpsBucket.gt_10
|
||||
|
||||
|
||||
def retain_bucket(days: float) -> RetainBucket:
|
||||
"""The bucket for a retention period in days."""
|
||||
if days <= 0:
|
||||
return RetainBucket.zero
|
||||
|
||||
if days <= 7:
|
||||
return RetainBucket.d1_7
|
||||
|
||||
if days <= 30:
|
||||
return RetainBucket.d8_30
|
||||
|
||||
return RetainBucket.gt_30
|
||||
|
||||
|
||||
def preset_key(args: str | list[str], enum: Any) -> Any:
|
||||
"""The preset's name, custom for hand written args, or none."""
|
||||
if not args:
|
||||
return enum("none")
|
||||
|
||||
if isinstance(args, str) and args.startswith("preset-"):
|
||||
return closed(enum, args.removeprefix("preset-"), enum("custom"))
|
||||
|
||||
return enum("custom")
|
||||
|
||||
|
||||
def is_restream(path: str) -> bool:
|
||||
"""Whether an input reads from the local go2rtc restream."""
|
||||
try:
|
||||
url = urlsplit(path)
|
||||
return url.hostname in RESTREAM_HOSTS and url.port == RESTREAM_PORT
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def role_input(camera: CameraConfig, role: CameraRoleEnum) -> CameraInput | None:
|
||||
"""The camera's first input with the given role."""
|
||||
return next((i for i in camera.ffmpeg.inputs if role in i.roles), None)
|
||||
|
||||
|
||||
def object_masks(camera: CameraConfig) -> int:
|
||||
"""Camera and per-label object masks, minus the global_<id> filter copies."""
|
||||
own = sum(1 for mask in camera.objects.mask.values() if mask is not None)
|
||||
per_label = sum(
|
||||
1
|
||||
for label_filter in camera.objects.filters.values()
|
||||
for mask_id, mask in label_filter.mask.items()
|
||||
if mask is not None and not mask_id.startswith("global_")
|
||||
)
|
||||
return own + per_label
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> CamerasSection:
|
||||
"""Count camera settings across the cameras enabled in config."""
|
||||
cameras = {
|
||||
name: camera
|
||||
for name, camera in ctx.config.cameras.items()
|
||||
if not name.startswith(REPLAY_CAMERA_PREFIX)
|
||||
}
|
||||
camera_stats = ctx.stats.get("cameras", {})
|
||||
|
||||
# cameras disabled in config are parked, so they'd skew feature adoption
|
||||
active = [
|
||||
(name, camera) for name, camera in cameras.items() if camera.enabled_in_config
|
||||
]
|
||||
|
||||
flags: Counter[str] = Counter()
|
||||
types: Counter[CameraTypeEnum] = Counter()
|
||||
heights: Counter[HeightBucket] = Counter()
|
||||
fps: Counter[FpsBucket] = Counter()
|
||||
hwaccel: Counter[Any] = Counter()
|
||||
input_presets: Counter[Any] = Counter()
|
||||
quality: Counter[ConnectionQuality] = Counter()
|
||||
retain: dict[str, Counter[RetainBucket]] = {
|
||||
period: Counter() for period in ("continuous", "motion", "alerts", "detections")
|
||||
}
|
||||
|
||||
for name, camera in active:
|
||||
detect_input = role_input(camera, CameraRoleEnum.detect)
|
||||
record_input = role_input(camera, CameraRoleEnum.record)
|
||||
|
||||
# config validation requires a detect input, so this never skips
|
||||
if detect_input is None:
|
||||
continue
|
||||
|
||||
types[camera.type] += 1
|
||||
fps[fps_bucket(camera.detect.fps)] += 1
|
||||
hwaccel[
|
||||
preset_key(
|
||||
detect_input.hwaccel_args or camera.ffmpeg.hwaccel_args, HwaccelKey
|
||||
)
|
||||
] += 1
|
||||
input_presets[
|
||||
preset_key(
|
||||
detect_input.input_args or camera.ffmpeg.input_args, InputPresetKey
|
||||
)
|
||||
] += 1
|
||||
|
||||
if camera.detect.height:
|
||||
heights[height_bucket(camera.detect.height)] += 1
|
||||
|
||||
state = camera_stats.get(name, {}).get("connection_quality")
|
||||
|
||||
# a camera turned off at runtime reports unusable
|
||||
if camera.enabled and state in QUALITIES:
|
||||
quality[ConnectionQuality(state)] += 1
|
||||
|
||||
if camera.record.enabled:
|
||||
retain["continuous"][retain_bucket(camera.record.continuous.days)] += 1
|
||||
retain["motion"][retain_bucket(camera.record.motion.days)] += 1
|
||||
retain["alerts"][retain_bucket(camera.record.alerts.retain.days)] += 1
|
||||
retain["detections"][
|
||||
retain_bucket(camera.record.detections.retain.days)
|
||||
] += 1
|
||||
|
||||
zones = len(camera.zones)
|
||||
flags["go2rtc_restream"] += any(
|
||||
is_restream(i.path) for i in camera.ffmpeg.inputs
|
||||
)
|
||||
flags["separate_detect_stream"] += (
|
||||
record_input is not None and record_input.path != detect_input.path
|
||||
)
|
||||
flags["detect"] += camera.detect.enabled
|
||||
flags["record"] += camera.record.enabled
|
||||
flags["sub_stream_record"] += (
|
||||
camera.record.enabled and camera.record.sub.enabled
|
||||
)
|
||||
flags["snapshots"] += camera.snapshots.enabled
|
||||
flags["audio"] += camera.audio.enabled
|
||||
flags["audio_transcription"] += (
|
||||
camera.audio.enabled and camera.audio_transcription.enabled
|
||||
)
|
||||
flags["birdseye"] += camera.birdseye.enabled
|
||||
flags["onvif"] += bool(camera.onvif.host)
|
||||
flags["autotracking"] += camera.onvif.autotracking.enabled
|
||||
flags["face_recognition"] += camera.face_recognition.enabled
|
||||
flags["lpr"] += camera.lpr.enabled
|
||||
flags["review_genai"] += camera.review.genai.enabled
|
||||
flags["object_genai"] += camera.objects.genai.enabled
|
||||
flags["notifications"] += camera.notifications.enabled
|
||||
flags["zones"] += zones
|
||||
flags["cameras_with_zones"] += zones > 0
|
||||
flags["motion_masks"] += sum(
|
||||
1 for mask in camera.motion.mask.values() if mask is not None
|
||||
)
|
||||
flags["object_masks"] += object_masks(camera)
|
||||
|
||||
return CamerasSection(
|
||||
total=len(cameras),
|
||||
enabled=len(active),
|
||||
types=histogram(types),
|
||||
detect_height=histogram(heights),
|
||||
detect_fps=histogram(fps),
|
||||
hwaccel=histogram(hwaccel),
|
||||
input_preset=histogram(input_presets),
|
||||
go2rtc_restream=flags["go2rtc_restream"],
|
||||
separate_detect_stream=flags["separate_detect_stream"],
|
||||
detect=flags["detect"],
|
||||
record=flags["record"],
|
||||
sub_stream_record=flags["sub_stream_record"],
|
||||
snapshots=flags["snapshots"],
|
||||
audio=flags["audio"],
|
||||
audio_transcription=flags["audio_transcription"],
|
||||
birdseye=flags["birdseye"],
|
||||
onvif=flags["onvif"],
|
||||
autotracking=flags["autotracking"],
|
||||
face_recognition=flags["face_recognition"],
|
||||
lpr=flags["lpr"],
|
||||
review_genai=flags["review_genai"],
|
||||
object_genai=flags["object_genai"],
|
||||
notifications=flags["notifications"],
|
||||
zones=flags["zones"],
|
||||
cameras_with_zones=flags["cameras_with_zones"],
|
||||
motion_masks=flags["motion_masks"],
|
||||
object_masks=flags["object_masks"],
|
||||
connection_quality=histogram(quality),
|
||||
retain_days=RetainDays(
|
||||
continuous=histogram(retain["continuous"]),
|
||||
motion=histogram(retain["motion"]),
|
||||
alerts=histogram(retain["alerts"]),
|
||||
detections=histogram(retain["detections"]),
|
||||
),
|
||||
)
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Helpers the section collectors share."""
|
||||
|
||||
import math
|
||||
from collections import Counter
|
||||
from enum import Enum
|
||||
from typing import Any, TypeVar
|
||||
|
||||
E = TypeVar("E", bound=Enum)
|
||||
|
||||
|
||||
def closed(enum: type[E], value: Any, fallback: E) -> E:
|
||||
"""The member for a value, or the fallback for one the enum doesn't know."""
|
||||
try:
|
||||
return enum(str(value))
|
||||
except ValueError:
|
||||
return fallback
|
||||
|
||||
|
||||
def rate(value: Any) -> float:
|
||||
"""A finite, non-negative number rounded to 2 decimals, else 0.
|
||||
|
||||
A NaN would serialize as null and fail the schema's number type.
|
||||
"""
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
if not math.isfinite(number):
|
||||
return 0.0
|
||||
|
||||
return round(max(number, 0.0), 2)
|
||||
|
||||
|
||||
def histogram(counter: "Counter[E]") -> dict[E, int]:
|
||||
"""Drop the empty buckets, since an absent key means zero."""
|
||||
return {key: total for key, total in counter.items() if total > 0}
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Detection section: models, the detectors they run on, and inference speed."""
|
||||
|
||||
import os
|
||||
|
||||
from frigate.analytics.collectors.common import rate
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import DetectionModel, DetectionSection, ModelSource
|
||||
from frigate.config.config import DEFAULT_MODEL
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.detectors.detector_config import DEFAULT_SCENE
|
||||
from frigate.detectors.detector_types import DetectorTypeEnum
|
||||
from frigate.detectors.device import runner_names
|
||||
|
||||
# the paths FrigateConfig fills in for a model that sets none
|
||||
BUNDLED_MODEL_PATHS = frozenset(
|
||||
{"/cpu_model.tflite", "/edgetpu_model.tflite", str(DEFAULT_MODEL["path"])}
|
||||
)
|
||||
|
||||
|
||||
def model_source(path: str | None) -> ModelSource:
|
||||
"""Default, Frigate+ (a cached model next to its info file), or custom.
|
||||
|
||||
Parsing rewrites plus://<id> to the model cache, so the prefix is gone by now.
|
||||
"""
|
||||
if path is None or path in BUNDLED_MODEL_PATHS:
|
||||
return ModelSource.default
|
||||
|
||||
if path.startswith(f"{MODEL_CACHE_DIR}/") and os.path.isfile(f"{path}.json"):
|
||||
return ModelSource.plus
|
||||
|
||||
return ModelSource.custom
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> DetectionSection:
|
||||
"""Describe each configured model and its measured inference speed."""
|
||||
config = ctx.config
|
||||
detectors = ctx.stats.get("detectors", {})
|
||||
model_specs = [(model, config.devices_for_model(model)) for model in config.models]
|
||||
|
||||
# FrigateApp.start_detectors names the processes in this same order
|
||||
names = iter(runner_names([spec for _, specs in model_specs for spec in specs]))
|
||||
models: list[DetectionModel] = []
|
||||
|
||||
for model, specs in model_specs:
|
||||
speeds: list[float] = []
|
||||
|
||||
for _ in specs:
|
||||
speed = detectors.get(next(names), {}).get("inference_speed")
|
||||
|
||||
if isinstance(speed, int | float) and speed > 0:
|
||||
speeds.append(float(speed))
|
||||
|
||||
models.append(
|
||||
DetectionModel(
|
||||
default=model.scene == DEFAULT_SCENE,
|
||||
detector=DetectorTypeEnum(specs[0].detector),
|
||||
devices=len(specs),
|
||||
model_type=model.model_type,
|
||||
input=f"{model.width}x{model.height}",
|
||||
source=model_source(model.path),
|
||||
inference_ms=rate(sum(speeds) / len(speeds)) if speeds else None,
|
||||
)
|
||||
)
|
||||
|
||||
return DetectionSection(
|
||||
models=models,
|
||||
detection_fps=rate(ctx.stats.get("detection_fps")),
|
||||
skipped_fps=rate(ctx.stats.get("skipped_fps")),
|
||||
)
|
||||
@@ -1,150 +0,0 @@
|
||||
"""Features section: enrichments, GenAI, integrations, and users."""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from frigate.analytics.collectors.common import closed, histogram
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import (
|
||||
BirdseyeUsage,
|
||||
ClassificationUsage,
|
||||
EnrichmentDevice,
|
||||
EnrichmentUsage,
|
||||
FeaturesSection,
|
||||
GenAIUsage,
|
||||
SemanticSearchModel,
|
||||
SemanticSearchUsage,
|
||||
TranscriptionModel,
|
||||
TranscriptionUsage,
|
||||
UserRole,
|
||||
)
|
||||
from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import User
|
||||
|
||||
RUNTIME_DEVICES = {
|
||||
"cpu": EnrichmentDevice.cpu,
|
||||
"cuda": EnrichmentDevice.cuda,
|
||||
"tensorrt": EnrichmentDevice.tensorrt,
|
||||
"migraphx": EnrichmentDevice.migraphx,
|
||||
}
|
||||
OPENVINO_DEVICES = {
|
||||
"cpu": EnrichmentDevice.openvino_cpu,
|
||||
"gpu": EnrichmentDevice.openvino_gpu,
|
||||
"npu": EnrichmentDevice.openvino_npu,
|
||||
}
|
||||
|
||||
|
||||
def enrichment_device(label: object) -> EnrichmentDevice | None:
|
||||
"""Map a runner's device label, like "CUDA" or "OpenVINO GPU.0,CPU"."""
|
||||
if not isinstance(label, str) or not label:
|
||||
return None
|
||||
|
||||
runtime, _, target = label.partition(" ")
|
||||
|
||||
if runtime == "OpenVINO":
|
||||
# AUTO reports "(CPU)" while it warms up on the CPU
|
||||
first = target.split(",")[0].split(".")[0].strip("() ").lower()
|
||||
return OPENVINO_DEVICES.get(first, EnrichmentDevice.other)
|
||||
|
||||
return RUNTIME_DEVICES.get(label.lower(), EnrichmentDevice.other)
|
||||
|
||||
|
||||
def model_name(model: object) -> str | None:
|
||||
"""The model's config value as a string."""
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
return str(getattr(model, "value", model))
|
||||
|
||||
|
||||
def semantic_model(model: object) -> SemanticSearchModel | None:
|
||||
"""A built-in Jina model, or genai for any GenAI provider name."""
|
||||
name = model_name(model)
|
||||
|
||||
if name is None:
|
||||
return None
|
||||
|
||||
if name in ("jinav1", "jinav2"):
|
||||
return SemanticSearchModel(name)
|
||||
|
||||
return SemanticSearchModel.genai
|
||||
|
||||
|
||||
def transcription_model(model: object) -> TranscriptionModel | None:
|
||||
"""Whisper, or genai for any GenAI provider name."""
|
||||
name = model_name(model)
|
||||
|
||||
if name is None:
|
||||
return None
|
||||
|
||||
return TranscriptionModel.whisper if name == "whisper" else TranscriptionModel.genai
|
||||
|
||||
|
||||
def users() -> dict[UserRole, int]:
|
||||
"""Count users by role, with any custom role counted as custom."""
|
||||
roles: Counter[UserRole] = Counter(
|
||||
closed(UserRole, user.role, UserRole.custom) for user in User.select(User.role)
|
||||
)
|
||||
return histogram(roles)
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> FeaturesSection:
|
||||
"""Report which enrichments and integrations are configured."""
|
||||
config = ctx.config
|
||||
devices = ctx.stats.get("embeddings", {}).get("devices", {})
|
||||
cameras = [
|
||||
camera
|
||||
for name, camera in config.cameras.items()
|
||||
if not name.startswith(REPLAY_CAMERA_PREFIX)
|
||||
]
|
||||
providers: Counter[GenAIProviderEnum] = Counter(
|
||||
genai.provider for genai in config.genai.values()
|
||||
)
|
||||
roles: Counter[GenAIRoleEnum] = Counter(
|
||||
role for genai in config.genai.values() for role in genai.roles
|
||||
)
|
||||
custom = list(config.classification.custom.values())
|
||||
|
||||
return FeaturesSection(
|
||||
face_recognition=EnrichmentUsage(
|
||||
enabled=config.face_recognition.enabled,
|
||||
model_size=config.face_recognition.model_size,
|
||||
device=enrichment_device(devices.get("face_recognition")),
|
||||
),
|
||||
lpr=EnrichmentUsage(
|
||||
enabled=config.lpr.enabled,
|
||||
model_size=config.lpr.model_size,
|
||||
device=enrichment_device(devices.get("lpr")),
|
||||
),
|
||||
semantic_search=SemanticSearchUsage(
|
||||
enabled=config.semantic_search.enabled,
|
||||
model=semantic_model(config.semantic_search.model),
|
||||
model_size=config.semantic_search.model_size,
|
||||
device=enrichment_device(devices.get("semantic_search")),
|
||||
triggers=sum(len(camera.semantic_search.triggers) for camera in cameras),
|
||||
),
|
||||
audio_transcription=TranscriptionUsage(
|
||||
enabled=config.audio_transcription.enabled,
|
||||
model=transcription_model(config.audio_transcription.model),
|
||||
model_size=config.audio_transcription.model_size,
|
||||
),
|
||||
genai=GenAIUsage(providers=histogram(providers), roles=histogram(roles)),
|
||||
classification_models=ClassificationUsage(
|
||||
state=sum(1 for model in custom if model.state_config is not None),
|
||||
object=sum(1 for model in custom if model.object_config is not None),
|
||||
),
|
||||
birdseye=BirdseyeUsage(
|
||||
enabled=config.birdseye.enabled,
|
||||
modes=list(config.birdseye.modes),
|
||||
restream=config.birdseye.restream,
|
||||
),
|
||||
mqtt=config.mqtt.enabled,
|
||||
notifications=config.notifications.enabled,
|
||||
auth=config.auth.enabled,
|
||||
proxy_auth=config.proxy.header_map.user is not None,
|
||||
tls=config.tls.enabled,
|
||||
users=users(),
|
||||
camera_groups=len(config.camera_groups),
|
||||
profiles=len(config.profiles),
|
||||
plus_api_key=config.plus_api.is_active(),
|
||||
)
|
||||
@@ -1,114 +0,0 @@
|
||||
"""Hardware section: CPU, memory, GPUs, decode and detection hardware, storage."""
|
||||
|
||||
import os
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
import psutil
|
||||
|
||||
from frigate.analytics.collectors.common import closed, histogram
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import (
|
||||
DecodeFamily,
|
||||
GpuInfo,
|
||||
GpuVendor,
|
||||
HardwareKey,
|
||||
HardwareSection,
|
||||
StorageInfo,
|
||||
)
|
||||
from frigate.const import RECORD_DIR
|
||||
from frigate.detectors.hardware import hardware_prober
|
||||
from frigate.util.hwaccel import hwaccel_options
|
||||
|
||||
DEVICE_TREE_MODEL = "/proc/device-tree/model"
|
||||
CPUINFO = "/proc/cpuinfo"
|
||||
|
||||
|
||||
def cpu_model() -> str:
|
||||
"""The board model on ARM boards, else the CPU's model name."""
|
||||
try:
|
||||
with open(DEVICE_TREE_MODEL) as f:
|
||||
board = f.read().strip("\x00\n ")
|
||||
|
||||
if board:
|
||||
return board[:64]
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
with open(CPUINFO) as f:
|
||||
for line in f:
|
||||
key, _, value = line.partition(":")
|
||||
|
||||
if key.strip() == "model name" and value.strip():
|
||||
return value.strip()[:64]
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def gpus(stats: dict[str, Any]) -> list[GpuInfo]:
|
||||
"""GPUs with a stats entry, by vendor and capped name."""
|
||||
found: list[GpuInfo] = []
|
||||
|
||||
for name, entry in stats.get("gpu_usages", {}).items():
|
||||
vendor = entry.get("vendor") if isinstance(entry, dict) else None
|
||||
found.append(
|
||||
GpuInfo(
|
||||
vendor=closed(GpuVendor, vendor, GpuVendor.other),
|
||||
name=str(name)[:64],
|
||||
)
|
||||
)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def decode_families() -> list[DecodeFamily]:
|
||||
"""Hardware decode families the host supports."""
|
||||
_, available = hwaccel_options()
|
||||
families = [
|
||||
closed(DecodeFamily, family.key, DecodeFamily.other) for family in available
|
||||
]
|
||||
return list(dict.fromkeys(families))
|
||||
|
||||
|
||||
def detection_hardware() -> dict[HardwareKey, int]:
|
||||
"""Detection accelerators found by the hardware probe, by unit count."""
|
||||
units: Counter[HardwareKey] = Counter()
|
||||
|
||||
for found in hardware_prober.probe():
|
||||
units[closed(HardwareKey, found.key, HardwareKey.other)] += found.count
|
||||
|
||||
return histogram(units)
|
||||
|
||||
|
||||
def storage(stats: dict[str, Any]) -> StorageInfo:
|
||||
"""Filesystem, size, and usage of the recordings volume.
|
||||
|
||||
Stats report sizes in MB.
|
||||
"""
|
||||
entry = stats.get("service", {}).get("storage", {}).get(RECORD_DIR) or {}
|
||||
total_mb = float(entry.get("total") or 0)
|
||||
used_mb = float(entry.get("used") or 0)
|
||||
|
||||
return StorageInfo(
|
||||
record_fs=str(entry.get("mount_type") or "unknown")[:16],
|
||||
record_total_gb=round(total_mb / 1024),
|
||||
record_used_pct=min(round(used_mb / total_mb * 100), 100)
|
||||
if total_mb > 0
|
||||
else 0,
|
||||
)
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> HardwareSection:
|
||||
"""Describe the host: CPU, memory, GPUs, decoders, detectors, storage."""
|
||||
return HardwareSection(
|
||||
cpu_model=cpu_model(),
|
||||
cpu_cores=os.cpu_count() or 0,
|
||||
memory_gb=round(psutil.virtual_memory().total / 2**30),
|
||||
gpus=gpus(ctx.stats),
|
||||
decode_families=decode_families(),
|
||||
detection_hardware=detection_hardware(),
|
||||
storage=storage(ctx.stats),
|
||||
)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Health section: uptime, CPU, enrichment speed, and notice counts."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from frigate.analytics.collectors.common import rate
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import (
|
||||
EnrichmentTiming,
|
||||
HealthSection,
|
||||
NoticeCounts,
|
||||
NoticeKindKey,
|
||||
)
|
||||
|
||||
TIMING_STATS = {
|
||||
EnrichmentTiming.face: "face_recognition_speed",
|
||||
EnrichmentTiming.lpr: "plate_recognition_speed",
|
||||
EnrichmentTiming.plate_detection: "yolov9_plate_detection_speed",
|
||||
EnrichmentTiming.image_embedding: "image_embedding_speed",
|
||||
EnrichmentTiming.text_embedding: "text_embedding_speed",
|
||||
EnrichmentTiming.review_description: "review_description_speed",
|
||||
EnrichmentTiming.object_description: "object_description_speed",
|
||||
}
|
||||
REPORTABLE_KINDS = frozenset(key.value for key in NoticeKindKey)
|
||||
|
||||
|
||||
def notice_counts(notice_stats: list[dict[str, Any]]) -> dict[Any, NoticeCounts]:
|
||||
"""Counts since the install ID was created, per reportable kind."""
|
||||
counts: dict[Any, NoticeCounts] = {}
|
||||
|
||||
for row in notice_stats:
|
||||
if row["kind"] not in REPORTABLE_KINDS:
|
||||
continue
|
||||
|
||||
occurrences = max(row["occurrences"] - row["reported_occurrences"], 0)
|
||||
acknowledgements = max(
|
||||
row["acknowledgements"] - row["reported_acknowledgements"], 0
|
||||
)
|
||||
mutes = max(row["mutes"] - row["reported_mutes"], 0)
|
||||
|
||||
if occurrences or acknowledgements or mutes:
|
||||
counts[NoticeKindKey(row["kind"])] = NoticeCounts(
|
||||
occurrences=occurrences, acknowledgements=acknowledgements, mutes=mutes
|
||||
)
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> HealthSection:
|
||||
"""Read health figures from the latest stats and notice counts."""
|
||||
service = ctx.stats.get("service", {})
|
||||
embeddings = ctx.stats.get("embeddings", {})
|
||||
cpu = ctx.stats.get("cpu_usages", {}).get("frigate.full_system", {}).get("cpu")
|
||||
timings = {
|
||||
timing: rate(embeddings.get(key)) for timing, key in TIMING_STATS.items()
|
||||
}
|
||||
|
||||
return HealthSection(
|
||||
uptime_hours=int(rate(service.get("uptime")) // 3600),
|
||||
cpu_percent=min(round(rate(cpu)), 100),
|
||||
enrichment_ms={timing: value for timing, value in timings.items() if value > 0},
|
||||
retention_unmet=bool(service.get("retention_unmet", False)),
|
||||
notices=notice_counts(ctx.notice_stats),
|
||||
)
|
||||
@@ -1,66 +0,0 @@
|
||||
"""Install section: version, image variant, install type, platform."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
|
||||
from frigate.analytics.collectors.common import closed
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import Arch, ImageVariant, InstallSection, InstallType
|
||||
from frigate.version import VERSION
|
||||
|
||||
KERNEL_PATTERN = re.compile(r"^(\d{1,3})\.(\d{1,3})")
|
||||
|
||||
|
||||
def image_variant(value: str | None) -> ImageVariant:
|
||||
"""The published image from the build-time FRIGATE_IMAGE_VARIANT, dev when unset."""
|
||||
if not value:
|
||||
return ImageVariant.dev
|
||||
|
||||
return closed(ImageVariant, value, ImageVariant.other)
|
||||
|
||||
|
||||
def install_type() -> InstallType:
|
||||
"""How Frigate runs; the add-on is a container too, so it's checked first."""
|
||||
if os.path.isfile("/data/options.json"):
|
||||
return InstallType.ha_addon
|
||||
|
||||
if os.environ.get("KUBERNETES_SERVICE_HOST"):
|
||||
return InstallType.kubernetes
|
||||
|
||||
if os.path.exists("/run/.containerenv"):
|
||||
return InstallType.podman
|
||||
|
||||
if os.path.exists("/.dockerenv"):
|
||||
return InstallType.docker
|
||||
|
||||
return InstallType.unknown
|
||||
|
||||
|
||||
def arch(machine: str) -> Arch:
|
||||
"""The CPU architecture for a platform.machine() value."""
|
||||
match machine.lower():
|
||||
case "x86_64" | "amd64":
|
||||
return Arch.x86_64
|
||||
case "aarch64" | "arm64":
|
||||
return Arch.aarch64
|
||||
case _:
|
||||
return Arch.other
|
||||
|
||||
|
||||
def kernel(release: str) -> str:
|
||||
"""The major.minor of a kernel release string, or unknown."""
|
||||
match = KERNEL_PATTERN.match(release)
|
||||
return f"{match.group(1)}.{match.group(2)}" if match else "unknown"
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> InstallSection:
|
||||
"""Describe the Frigate version, image, install method, and platform."""
|
||||
return InstallSection(
|
||||
version=VERSION[:32],
|
||||
image_variant=image_variant(os.environ.get("FRIGATE_IMAGE_VARIANT")),
|
||||
install_type=install_type(),
|
||||
arch=arch(platform.machine()),
|
||||
kernel=kernel(platform.release()),
|
||||
run_as_root=os.geteuid() == 0,
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
"""What the collectors read, gathered once per report."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportContext:
|
||||
config: FrigateConfig
|
||||
stats: dict[str, Any]
|
||||
notice_stats: list[dict[str, Any]] = field(default_factory=list)
|
||||
@@ -1,91 +0,0 @@
|
||||
"""Build a report from the section collectors."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from frigate.analytics.collectors import (
|
||||
cameras,
|
||||
detection,
|
||||
features,
|
||||
hardware,
|
||||
health,
|
||||
install,
|
||||
)
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import SCHEMA_VERSION, AnalyticsReport
|
||||
from frigate.analytics.state import load_state
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COLLECTORS: dict[str, Callable[[ReportContext], BaseModel | None]] = {
|
||||
"install": install.collect,
|
||||
"hardware": hardware.collect,
|
||||
"detection": detection.collect,
|
||||
"cameras": cameras.collect,
|
||||
"features": features.collect,
|
||||
"health": health.collect,
|
||||
}
|
||||
|
||||
# shown in the preview until the first report creates a real ID
|
||||
PREVIEW_INSTALL_ID = "0" * 32
|
||||
|
||||
|
||||
def build_report(
|
||||
ctx: ReportContext, install_id: str, sent_at: int | None = None
|
||||
) -> AnalyticsReport:
|
||||
"""Run every collector; one that fails sends its section as null."""
|
||||
sections: dict[str, Any] = {}
|
||||
|
||||
for name, collect in COLLECTORS.items():
|
||||
try:
|
||||
sections[name] = collect(ctx)
|
||||
except Exception:
|
||||
# a collector bug must cost one section, never the whole report
|
||||
logger.debug("Analytics %s section failed", name, exc_info=True)
|
||||
sections[name] = None
|
||||
|
||||
return AnalyticsReport.model_validate(
|
||||
{
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"install_id": install_id,
|
||||
"report_id": str(uuid4()),
|
||||
"sent_at": int(time.time()) if sent_at is None else sent_at,
|
||||
**sections,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def gather_context(
|
||||
config: FrigateConfig,
|
||||
stats_emitter: "StatsEmitter | None",
|
||||
notice_registry: "NoticeRegistry | None",
|
||||
) -> ReportContext:
|
||||
"""Snapshot the config, latest stats, and notice counts for one report."""
|
||||
return ReportContext(
|
||||
config=config,
|
||||
stats=stats_emitter.get_latest_stats() if stats_emitter is not None else {},
|
||||
notice_stats=notice_registry.stats() if notice_registry is not None else [],
|
||||
)
|
||||
|
||||
|
||||
def preview_report(
|
||||
config: FrigateConfig,
|
||||
stats_emitter: "StatsEmitter | None",
|
||||
notice_registry: "NoticeRegistry | None",
|
||||
) -> AnalyticsReport:
|
||||
"""The report the next send would carry, without sending it."""
|
||||
state = load_state()
|
||||
|
||||
# an opt-in resets the baseline, so before one there are no notices to show
|
||||
ctx = gather_context(config, stats_emitter, notice_registry if state else None)
|
||||
return build_report(ctx, state.install_id if state else PREVIEW_INSTALL_ID)
|
||||
@@ -1,218 +0,0 @@
|
||||
"""Send one analytics report a day while the admin has opted in."""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import ValidationError
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.error import YAMLError
|
||||
|
||||
from frigate.analytics.report import build_report, gather_context
|
||||
from frigate.analytics.state import (
|
||||
STATE_PATH,
|
||||
AnalyticsState,
|
||||
delete_state,
|
||||
load_state,
|
||||
new_state,
|
||||
save_state,
|
||||
)
|
||||
from frigate.analytics.transport import SendOutcome, send_report
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.holder import ConfigHolder
|
||||
from frigate.config.telemetry import TelemetryConfig
|
||||
from frigate.const import ANALYTICS_URL
|
||||
from frigate.notices import raise_notice, resolve_notice
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WAKE_S = 10 * 60
|
||||
INTERVAL_S = 24 * 60 * 60
|
||||
JITTER_S = 60 * 60
|
||||
FIRST_DELAY_S = (15 * 60, 45 * 60)
|
||||
PROMPT_KIND = "analytics_prompt"
|
||||
|
||||
|
||||
def saved_consent(path: str) -> bool | None:
|
||||
"""telemetry.analytics as saved in the config file, or None if unreadable."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
raw = YAML(typ="safe").load(f)
|
||||
except (OSError, ValueError, YAMLError):
|
||||
return None
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
|
||||
# the live config's model, so yes, on, and 1 read as consent there too
|
||||
try:
|
||||
return TelemetryConfig.model_validate(raw.get("telemetry") or {}).analytics
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
class AnalyticsReporter(threading.Thread):
|
||||
"""Follows the live config, so a settings save needs no restart."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_holder: ConfigHolder,
|
||||
stats_emitter: "StatsEmitter",
|
||||
notice_registry: "NoticeRegistry",
|
||||
stop_event: MpEvent | threading.Event,
|
||||
*,
|
||||
state_path: str = STATE_PATH,
|
||||
config_path: str | None = None,
|
||||
url: str = ANALYTICS_URL,
|
||||
send: Callable[[str, str], SendOutcome] = send_report,
|
||||
clock: Callable[[], float] = time.time,
|
||||
rng: random.Random | None = None,
|
||||
) -> None:
|
||||
super().__init__(name="analytics_reporter", daemon=True)
|
||||
self.config_holder = config_holder
|
||||
self.stats_emitter = stats_emitter
|
||||
self.notice_registry = notice_registry
|
||||
self.stop_event = stop_event
|
||||
self.state_path = state_path
|
||||
self.config_path = config_path or find_config_file()
|
||||
self.url = url
|
||||
self.send = send
|
||||
self.clock = clock
|
||||
self.rng = rng or random.Random()
|
||||
self.first_due = clock() + self.rng.uniform(*FIRST_DELAY_S)
|
||||
self.interval = self._next_interval()
|
||||
self.opted_in: bool | None = None
|
||||
self.warned_unwritable = False
|
||||
# a settings save runs on an API thread while a wake may be mid-attempt
|
||||
self._lock = threading.Lock()
|
||||
config_holder.subscribe(self._on_config)
|
||||
|
||||
def _next_interval(self) -> float:
|
||||
return INTERVAL_S + self.rng.uniform(-JITTER_S, JITTER_S)
|
||||
|
||||
def run(self) -> None:
|
||||
"""Tick every wake until stopped; a failed tick never ends the thread."""
|
||||
while True:
|
||||
try:
|
||||
self.tick()
|
||||
except Exception:
|
||||
logger.exception("Analytics reporter failed")
|
||||
|
||||
if self.stop_event.wait(WAKE_S):
|
||||
break
|
||||
|
||||
def _on_config(self, config: FrigateConfig) -> None:
|
||||
# a save can turn sharing off and back on between two wakes, so an
|
||||
# opt-out is handled when it's saved rather than at the next wake
|
||||
with self._lock:
|
||||
if not config.safe_mode:
|
||||
self._apply_consent(config.telemetry.analytics)
|
||||
|
||||
def _apply_consent(self, opted_in: bool) -> None:
|
||||
# called with the lock held
|
||||
if opted_in == self.opted_in:
|
||||
return
|
||||
|
||||
self.opted_in = opted_in
|
||||
|
||||
if opted_in:
|
||||
resolve_notice(PROMPT_KIND)
|
||||
|
||||
# the opt-in sets the notice baseline, so only notices raised with
|
||||
# consent are counted; tick() retries a failed write
|
||||
if load_state(self.state_path) is None:
|
||||
save_state(self._new_identity(), self.state_path)
|
||||
else:
|
||||
raise_notice(PROMPT_KIND)
|
||||
delete_state(self.state_path)
|
||||
|
||||
def _new_identity(self) -> AnalyticsState:
|
||||
# called with the lock held; the baseline resets before the state file
|
||||
# names the new ID, so a failed write only means another reset
|
||||
self.notice_registry.reset_analytics_baseline()
|
||||
return new_state()
|
||||
|
||||
def tick(self) -> None:
|
||||
"""Apply consent and send a report when one is due."""
|
||||
with self._lock:
|
||||
config = self.config_holder.config
|
||||
|
||||
# safe mode parses a default config where analytics reads as off,
|
||||
# and handling that as an opt-out would delete the install ID
|
||||
if config.safe_mode:
|
||||
return
|
||||
|
||||
self._apply_consent(config.telemetry.analytics)
|
||||
|
||||
if not config.telemetry.analytics:
|
||||
return
|
||||
|
||||
now = self.clock()
|
||||
|
||||
if now < self.first_due:
|
||||
return
|
||||
|
||||
state = load_state(self.state_path)
|
||||
|
||||
if state is not None and not self._due(state.last_attempt_at, now):
|
||||
return
|
||||
|
||||
# a YAML editor save or a hand edit reaches the live config only on
|
||||
# restart, so an opt-out saved that way is read from the file
|
||||
if saved_consent(self.config_path) is False:
|
||||
delete_state(self.state_path)
|
||||
return
|
||||
|
||||
state = AnalyticsState(
|
||||
(state or self._new_identity()).install_id, last_attempt_at=now
|
||||
)
|
||||
|
||||
# saved before sending, so a failing endpoint or a crash mid-send
|
||||
# still waits a full interval; without saved state every boot would
|
||||
# send under a new install ID
|
||||
if not save_state(state, self.state_path):
|
||||
if not self.warned_unwritable:
|
||||
logger.warning(
|
||||
"Analytics is on, but %s isn't writable, so no report is sent",
|
||||
self.state_path,
|
||||
)
|
||||
self.warned_unwritable = True
|
||||
|
||||
return
|
||||
|
||||
self.interval = self._next_interval()
|
||||
|
||||
# the lock stays free during the request, so a save never waits on it
|
||||
self._send(state.install_id, now)
|
||||
|
||||
def _due(self, last_attempt_at: float, now: float) -> bool:
|
||||
# a last attempt stamped in the future came from a wrong clock
|
||||
return (
|
||||
now >= last_attempt_at + self.interval or last_attempt_at > now + INTERVAL_S
|
||||
)
|
||||
|
||||
def _send(self, install_id: str, now: float) -> None:
|
||||
ctx = gather_context(
|
||||
self.config_holder.config, self.stats_emitter, self.notice_registry
|
||||
)
|
||||
report = build_report(ctx, install_id, sent_at=int(now))
|
||||
body = report.model_dump_json()
|
||||
|
||||
# consent can be withdrawn while the report builds
|
||||
if not self.config_holder.config.telemetry.analytics:
|
||||
return
|
||||
|
||||
if self.send(self.url, body) is not SendOutcome.accepted:
|
||||
return
|
||||
|
||||
logger.info("Sent the daily analytics report")
|
||||
logger.debug("Analytics report: %s", body)
|
||||
@@ -1,419 +0,0 @@
|
||||
"""Models for the analytics report, the contract with the ingest endpoint.
|
||||
|
||||
Every field carries a description and an x-public flag, and no field accepts
|
||||
user-entered text, so a report can't carry camera names or other free text.
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from frigate.config.camera.birdseye import BirdseyeModeEnum
|
||||
from frigate.config.camera.camera import CameraTypeEnum
|
||||
from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum
|
||||
from frigate.config.classification import ModelSizeEnum
|
||||
from frigate.detectors.detector_config import ModelTypeEnum
|
||||
from frigate.detectors.detector_types import DetectorTypeEnum
|
||||
from frigate.ffmpeg_presets import PRESETS_HW_ACCEL_DECODE, PRESETS_INPUT
|
||||
from frigate.notices.types import NOTICE_KINDS
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class ImageVariant(StrEnum):
|
||||
standard = "standard"
|
||||
rpi = "rpi"
|
||||
tensorrt = "tensorrt"
|
||||
tensorrt_jp6 = "tensorrt-jp6"
|
||||
rocm = "rocm"
|
||||
rk = "rk"
|
||||
synaptics = "synaptics"
|
||||
dev = "dev"
|
||||
other = "other"
|
||||
|
||||
|
||||
class InstallType(StrEnum):
|
||||
ha_addon = "ha_addon"
|
||||
docker = "docker"
|
||||
podman = "podman"
|
||||
kubernetes = "kubernetes"
|
||||
unknown = "unknown"
|
||||
|
||||
|
||||
class Arch(StrEnum):
|
||||
x86_64 = "x86_64"
|
||||
aarch64 = "aarch64"
|
||||
other = "other"
|
||||
|
||||
|
||||
class GpuVendor(StrEnum):
|
||||
intel = "intel"
|
||||
amd = "amd"
|
||||
nvidia = "nvidia"
|
||||
rockchip = "rockchip"
|
||||
rpi = "rpi"
|
||||
apple_silicon = "apple-silicon"
|
||||
other = "other"
|
||||
|
||||
|
||||
class DecodeFamily(StrEnum):
|
||||
nvidia = "nvidia"
|
||||
vaapi = "vaapi"
|
||||
rkmpp = "rkmpp"
|
||||
intel_qsv = "intel-qsv"
|
||||
jetson = "jetson"
|
||||
rpi = "rpi"
|
||||
apple_silicon = "apple-silicon"
|
||||
other = "other"
|
||||
|
||||
|
||||
class HardwareKey(StrEnum):
|
||||
edgetpu_pci = "edgetpu:pci"
|
||||
edgetpu_usb = "edgetpu:usb"
|
||||
openvino_gpu = "openvino:GPU"
|
||||
openvino_npu = "openvino:NPU"
|
||||
onnx_amd = "onnx:amd"
|
||||
onnx_nvidia = "onnx:nvidia"
|
||||
onnx_lighter = "onnx:lighter"
|
||||
tensorrt = "tensorrt"
|
||||
hailo = "hailo"
|
||||
memryx = "memryx"
|
||||
deepx = "deepx"
|
||||
rknn = "rknn"
|
||||
axengine = "axengine"
|
||||
axelera = "axelera"
|
||||
synaptics = "synaptics"
|
||||
cpu = "cpu"
|
||||
other = "other"
|
||||
|
||||
|
||||
class ModelSource(StrEnum):
|
||||
default = "default"
|
||||
plus = "plus"
|
||||
custom = "custom"
|
||||
|
||||
|
||||
class HeightBucket(StrEnum):
|
||||
le_360 = "le_360"
|
||||
h480 = "480"
|
||||
h720 = "720"
|
||||
h1080 = "1080"
|
||||
h1440 = "1440"
|
||||
ge_2160 = "ge_2160"
|
||||
|
||||
|
||||
class FpsBucket(StrEnum):
|
||||
le_5 = "le_5"
|
||||
f6_10 = "6_10"
|
||||
gt_10 = "gt_10"
|
||||
|
||||
|
||||
class RetainBucket(StrEnum):
|
||||
zero = "0"
|
||||
d1_7 = "1_7"
|
||||
d8_30 = "8_30"
|
||||
gt_30 = "gt_30"
|
||||
|
||||
|
||||
class ConnectionQuality(StrEnum):
|
||||
excellent = "excellent"
|
||||
fair = "fair"
|
||||
poor = "poor"
|
||||
unusable = "unusable"
|
||||
|
||||
|
||||
class EnrichmentDevice(StrEnum):
|
||||
cpu = "cpu"
|
||||
cuda = "cuda"
|
||||
tensorrt = "tensorrt"
|
||||
migraphx = "migraphx"
|
||||
openvino_cpu = "openvino_cpu"
|
||||
openvino_gpu = "openvino_gpu"
|
||||
openvino_npu = "openvino_npu"
|
||||
other = "other"
|
||||
|
||||
|
||||
class SemanticSearchModel(StrEnum):
|
||||
jinav1 = "jinav1"
|
||||
jinav2 = "jinav2"
|
||||
genai = "genai"
|
||||
|
||||
|
||||
class TranscriptionModel(StrEnum):
|
||||
whisper = "whisper"
|
||||
genai = "genai"
|
||||
|
||||
|
||||
class UserRole(StrEnum):
|
||||
admin = "admin"
|
||||
viewer = "viewer"
|
||||
custom = "custom"
|
||||
|
||||
|
||||
class EnrichmentTiming(StrEnum):
|
||||
face = "face"
|
||||
lpr = "lpr"
|
||||
plate_detection = "plate_detection"
|
||||
image_embedding = "image_embedding"
|
||||
text_embedding = "text_embedding"
|
||||
review_description = "review_description"
|
||||
object_description = "object_description"
|
||||
|
||||
|
||||
def _preset_keys(presets: dict[str, Any]) -> dict[str, str]:
|
||||
keys = [name.removeprefix("preset-") for name in presets]
|
||||
return {key: key for key in [*keys, "custom", "none"]}
|
||||
|
||||
|
||||
# built from the registries they mirror, so a new preset or notice kind reaches
|
||||
# the schema through generate_analytics_schema.py instead of a hand edit
|
||||
HwaccelKey = StrEnum("HwaccelKey", _preset_keys(PRESETS_HW_ACCEL_DECODE)) # type: ignore[misc]
|
||||
InputPresetKey = StrEnum("InputPresetKey", _preset_keys(PRESETS_INPUT)) # type: ignore[misc]
|
||||
NoticeKindKey = StrEnum( # type: ignore[misc]
|
||||
"NoticeKindKey",
|
||||
{key: key for key, kind in NOTICE_KINDS.items() if kind.reportable},
|
||||
)
|
||||
|
||||
|
||||
class AnalyticsModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
def metric(description: str, *, public: bool = True, **kwargs: Any) -> Any:
|
||||
"""Declare a report field; the description and flag land in the schema."""
|
||||
return Field(
|
||||
description=description, json_schema_extra={"x-public": public}, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def count(description: str) -> Any:
|
||||
"""Declare a non-negative count field."""
|
||||
return metric(description, ge=0)
|
||||
|
||||
|
||||
class InstallSection(AnalyticsModel):
|
||||
version: str = metric("Frigate version string", max_length=32)
|
||||
image_variant: ImageVariant = metric("Published image the install runs")
|
||||
install_type: InstallType = metric("How Frigate is installed")
|
||||
arch: Arch = metric("CPU architecture")
|
||||
kernel: str = metric(
|
||||
"Host kernel as major.minor", pattern=r"^(\d{1,3}\.\d{1,3}|unknown)$"
|
||||
)
|
||||
run_as_root: bool = metric("Whether the main process runs as root")
|
||||
|
||||
|
||||
class GpuInfo(AnalyticsModel):
|
||||
vendor: GpuVendor = metric("GPU vendor")
|
||||
name: str = metric("GPU name as the hardware reports it", max_length=64)
|
||||
|
||||
|
||||
class StorageInfo(AnalyticsModel):
|
||||
record_fs: str = metric("Filesystem of the recordings volume", max_length=16)
|
||||
record_total_gb: int = count("Size of the recordings volume in GB")
|
||||
record_used_pct: int = metric(
|
||||
"Percent of the recordings volume in use", ge=0, le=100
|
||||
)
|
||||
|
||||
|
||||
class HardwareSection(AnalyticsModel):
|
||||
cpu_model: str = metric(
|
||||
"CPU or board model as the hardware reports it", max_length=64
|
||||
)
|
||||
cpu_cores: int = count("Logical CPU count")
|
||||
memory_gb: int = count("Total memory in GB")
|
||||
gpus: list[GpuInfo] = metric("GPUs the stats collector found")
|
||||
decode_families: list[DecodeFamily] = metric(
|
||||
"Hardware decode families this system can use"
|
||||
)
|
||||
detection_hardware: dict[HardwareKey, int] = metric(
|
||||
"Detection hardware found, as unit counts by kind"
|
||||
)
|
||||
storage: StorageInfo = metric("Recordings storage")
|
||||
|
||||
|
||||
class DetectionModel(AnalyticsModel):
|
||||
default: bool = metric("Whether the model serves cameras that set no scene")
|
||||
detector: DetectorTypeEnum = metric("Detector type the model runs on")
|
||||
devices: int = count("Devices the model runs on")
|
||||
model_type: ModelTypeEnum = metric("Model architecture")
|
||||
input: str = metric("Model input size as WxH", pattern=r"^\d{1,5}x\d{1,5}$")
|
||||
source: ModelSource = metric("Where the model came from", public=False)
|
||||
inference_ms: float | None = metric(
|
||||
"Mean inference time across the model's detector processes, null before the first stats",
|
||||
ge=0,
|
||||
)
|
||||
|
||||
|
||||
class DetectionSection(AnalyticsModel):
|
||||
models: list[DetectionModel] = metric("Detection models")
|
||||
detection_fps: float = metric("Detections per second across cameras", ge=0)
|
||||
skipped_fps: float = metric("Frames per second skipped across cameras", ge=0)
|
||||
|
||||
|
||||
class RetainDays(AnalyticsModel):
|
||||
continuous: dict[RetainBucket, int] = metric(
|
||||
"Recording cameras by continuous retention in days"
|
||||
)
|
||||
motion: dict[RetainBucket, int] = metric(
|
||||
"Recording cameras by motion retention in days"
|
||||
)
|
||||
alerts: dict[RetainBucket, int] = metric(
|
||||
"Recording cameras by alert retention in days"
|
||||
)
|
||||
detections: dict[RetainBucket, int] = metric(
|
||||
"Recording cameras by detection retention in days"
|
||||
)
|
||||
|
||||
|
||||
class CamerasSection(AnalyticsModel):
|
||||
total: int = count("Configured cameras")
|
||||
enabled: int = count(
|
||||
"Cameras enabled in config; every count below covers only these"
|
||||
)
|
||||
types: dict[CameraTypeEnum, int] = metric("Cameras by type")
|
||||
detect_height: dict[HeightBucket, int] = metric(
|
||||
"Cameras by detect resolution height"
|
||||
)
|
||||
detect_fps: dict[FpsBucket, int] = metric("Cameras by detect fps")
|
||||
hwaccel: dict[HwaccelKey, int] = metric(
|
||||
"Cameras by the resolved hwaccel preset of the detect input"
|
||||
)
|
||||
input_preset: dict[InputPresetKey, int] = metric(
|
||||
"Cameras by the input preset of the detect input"
|
||||
)
|
||||
go2rtc_restream: int = count("Cameras with an input from the go2rtc restream")
|
||||
separate_detect_stream: int = count(
|
||||
"Cameras whose detect input differs from their record input"
|
||||
)
|
||||
detect: int = count("Cameras with detection on")
|
||||
record: int = count("Cameras with recording on")
|
||||
sub_stream_record: int = count("Cameras with sub stream recording on")
|
||||
snapshots: int = count("Cameras with snapshots on")
|
||||
audio: int = count("Cameras with audio detection on")
|
||||
audio_transcription: int = count(
|
||||
"Cameras with audio detection and transcription on"
|
||||
)
|
||||
birdseye: int = count("Cameras in birdseye")
|
||||
onvif: int = count("Cameras with an ONVIF host")
|
||||
autotracking: int = count("Cameras with PTZ autotracking on")
|
||||
face_recognition: int = count("Cameras with face recognition on")
|
||||
lpr: int = count("Cameras with license plate recognition on")
|
||||
review_genai: int = count("Cameras with GenAI review summaries on")
|
||||
object_genai: int = count("Cameras with GenAI object descriptions on")
|
||||
notifications: int = count("Cameras with notifications on")
|
||||
zones: int = count("Zones across cameras")
|
||||
cameras_with_zones: int = count("Cameras with at least one zone")
|
||||
motion_masks: int = count("Motion masks across cameras")
|
||||
object_masks: int = count("Object masks across cameras")
|
||||
connection_quality: dict[ConnectionQuality, int] = metric(
|
||||
"Cameras by connection quality at send time"
|
||||
)
|
||||
retain_days: RetainDays = metric("Recording retention")
|
||||
|
||||
|
||||
class EnrichmentUsage(AnalyticsModel):
|
||||
enabled: bool = metric("Whether the enrichment is on")
|
||||
model_size: ModelSizeEnum = metric("Configured model size")
|
||||
device: EnrichmentDevice | None = metric(
|
||||
"Device the model loaded on, null when it isn't loaded"
|
||||
)
|
||||
|
||||
|
||||
class SemanticSearchUsage(AnalyticsModel):
|
||||
enabled: bool = metric("Whether semantic search is on")
|
||||
model: SemanticSearchModel | None = metric(
|
||||
"Embedding model, genai for a GenAI provider"
|
||||
)
|
||||
model_size: ModelSizeEnum = metric("Configured model size")
|
||||
device: EnrichmentDevice | None = metric(
|
||||
"Device the model loaded on, null when it isn't loaded"
|
||||
)
|
||||
triggers: int = count("Semantic search triggers across cameras")
|
||||
|
||||
|
||||
class TranscriptionUsage(AnalyticsModel):
|
||||
enabled: bool = metric("Whether audio transcription is on")
|
||||
model: TranscriptionModel | None = metric(
|
||||
"Transcription model, genai for a GenAI provider"
|
||||
)
|
||||
model_size: ModelSizeEnum = metric("Configured model size")
|
||||
|
||||
|
||||
class GenAIUsage(AnalyticsModel):
|
||||
providers: dict[GenAIProviderEnum, int] = metric(
|
||||
"Configured GenAI providers by type"
|
||||
)
|
||||
roles: dict[GenAIRoleEnum, int] = metric("Configured GenAI providers by role")
|
||||
|
||||
|
||||
class ClassificationUsage(AnalyticsModel):
|
||||
state: int = count("Custom state classification models")
|
||||
object: int = count("Custom object classification models")
|
||||
|
||||
|
||||
class BirdseyeUsage(AnalyticsModel):
|
||||
enabled: bool = metric("Whether birdseye is on")
|
||||
modes: list[BirdseyeModeEnum] = metric("Birdseye modes")
|
||||
restream: bool = metric("Whether birdseye is restreamed")
|
||||
|
||||
|
||||
class FeaturesSection(AnalyticsModel):
|
||||
face_recognition: EnrichmentUsage = metric("Face recognition")
|
||||
lpr: EnrichmentUsage = metric("License plate recognition")
|
||||
semantic_search: SemanticSearchUsage = metric("Semantic search")
|
||||
audio_transcription: TranscriptionUsage = metric("Audio transcription")
|
||||
genai: GenAIUsage = metric("Generative AI providers")
|
||||
classification_models: ClassificationUsage = metric("Custom classification models")
|
||||
birdseye: BirdseyeUsage = metric("Birdseye")
|
||||
mqtt: bool = metric("Whether MQTT is on")
|
||||
notifications: bool = metric("Whether web push notifications are on")
|
||||
auth: bool = metric("Whether authentication is on")
|
||||
proxy_auth: bool = metric("Whether a proxy supplies the user header")
|
||||
tls: bool = metric("Whether TLS is on")
|
||||
users: dict[UserRole, int] = metric("Users by role")
|
||||
camera_groups: int = count("Camera groups")
|
||||
profiles: int = count("Profiles")
|
||||
plus_api_key: bool = metric("Whether a Frigate+ API key is set", public=False)
|
||||
|
||||
|
||||
class NoticeCounts(AnalyticsModel):
|
||||
occurrences: int = count("Occurrences since the install ID was created")
|
||||
acknowledgements: int = count("Acknowledgements since the install ID was created")
|
||||
mutes: int = count("Mutes since the install ID was created")
|
||||
|
||||
|
||||
class HealthSection(AnalyticsModel):
|
||||
uptime_hours: int = count("Hours since Frigate started")
|
||||
cpu_percent: int = metric("System CPU use at send time", ge=0, le=100)
|
||||
enrichment_ms: dict[EnrichmentTiming, float] = metric(
|
||||
"Mean enrichment inference times in milliseconds"
|
||||
)
|
||||
retention_unmet: bool = metric(
|
||||
"Whether storage can't keep the configured retention"
|
||||
)
|
||||
notices: dict[NoticeKindKey, NoticeCounts] = metric(
|
||||
"Cumulative notice counts by kind since the install ID was created",
|
||||
public=False,
|
||||
)
|
||||
|
||||
|
||||
class AnalyticsReport(AnalyticsModel):
|
||||
schema_version: int = metric("Report format version", ge=1)
|
||||
install_id: str = metric(
|
||||
"Random install identifier", public=False, pattern=r"^[0-9a-f]{32}$"
|
||||
)
|
||||
report_id: str = metric(
|
||||
"Random identifier of this report",
|
||||
public=False,
|
||||
pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
)
|
||||
sent_at: int = count("Unix time the report was built")
|
||||
install: InstallSection | None = metric("Install, null if its collector failed")
|
||||
hardware: HardwareSection | None = metric("Hardware, null if its collector failed")
|
||||
detection: DetectionSection | None = metric(
|
||||
"Object detection, null if its collector failed"
|
||||
)
|
||||
cameras: CamerasSection | None = metric("Cameras, null if its collector failed")
|
||||
features: FeaturesSection | None = metric("Features, null if its collector failed")
|
||||
health: HealthSection | None = metric("Health, null if its collector failed")
|
||||
@@ -1,86 +0,0 @@
|
||||
"""The install's analytics identity and last attempt time, kept under /config."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from uuid import uuid4
|
||||
|
||||
from frigate.const import CONFIG_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_PATH = os.path.join(CONFIG_DIR, ".analytics.json")
|
||||
INSTALL_ID_PATTERN = re.compile(r"[0-9a-f]{32}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnalyticsState:
|
||||
install_id: str
|
||||
last_attempt_at: float
|
||||
|
||||
|
||||
def new_state() -> AnalyticsState:
|
||||
"""A fresh install ID that has never attempted a report."""
|
||||
return AnalyticsState(install_id=uuid4().hex, last_attempt_at=0.0)
|
||||
|
||||
|
||||
def load_state(path: str = STATE_PATH) -> AnalyticsState | None:
|
||||
"""The saved state, or None when the file is missing or unusable."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, ValueError):
|
||||
logger.debug("Ignoring unreadable analytics state at %s", path)
|
||||
return None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
install_id = data.get("install_id")
|
||||
last_attempt_at = data.get("last_attempt_at")
|
||||
|
||||
if (
|
||||
not isinstance(install_id, str)
|
||||
or not INSTALL_ID_PATTERN.fullmatch(install_id)
|
||||
or isinstance(last_attempt_at, bool)
|
||||
or not isinstance(last_attempt_at, int | float)
|
||||
):
|
||||
logger.debug("Ignoring invalid analytics state at %s", path)
|
||||
return None
|
||||
|
||||
return AnalyticsState(install_id=install_id, last_attempt_at=float(last_attempt_at))
|
||||
|
||||
|
||||
def save_state(state: AnalyticsState, path: str = STATE_PATH) -> bool:
|
||||
"""Write the state atomically, returning False when it can't be written."""
|
||||
temp_path = f"{path}.tmp"
|
||||
|
||||
try:
|
||||
with open(temp_path, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"install_id": state.install_id,
|
||||
"last_attempt_at": state.last_attempt_at,
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
os.replace(temp_path, path)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def delete_state(path: str = STATE_PATH) -> None:
|
||||
"""Forget the install ID, so a later opt-in starts a fresh identity."""
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
logger.warning("Unable to delete analytics state at %s", path)
|
||||
@@ -1,54 +0,0 @@
|
||||
"""POST a report to the ingest endpoint."""
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
|
||||
import requests
|
||||
|
||||
from frigate.version import VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT_S = 30
|
||||
|
||||
|
||||
class SendOutcome(Enum):
|
||||
accepted = "accepted"
|
||||
rejected = "rejected"
|
||||
rate_limited = "rate_limited"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
def send_report(url: str, body: str) -> SendOutcome:
|
||||
"""Send one report. Never raises; the outcome says what happened."""
|
||||
try:
|
||||
# a redirect would turn the POST into a GET, so it counts as a failure
|
||||
response = requests.post(
|
||||
url,
|
||||
data=body.encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"Frigate/{VERSION}",
|
||||
},
|
||||
timeout=TIMEOUT_S,
|
||||
allow_redirects=False,
|
||||
)
|
||||
except requests.RequestException as err:
|
||||
logger.debug("Unable to send the analytics report: %s", err)
|
||||
return SendOutcome.failed
|
||||
|
||||
status = response.status_code
|
||||
|
||||
if 200 <= status < 300:
|
||||
return SendOutcome.accepted
|
||||
|
||||
if status == 400:
|
||||
logger.debug("The analytics report was rejected: %s", response.text[:200])
|
||||
return SendOutcome.rejected
|
||||
|
||||
if status == 429:
|
||||
logger.debug("The analytics endpoint is rate limiting this install")
|
||||
return SendOutcome.rate_limited
|
||||
|
||||
logger.debug("The analytics endpoint returned %s", status)
|
||||
return SendOutcome.failed
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Analytics APIs."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from frigate.analytics.report import preview_report
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.analytics])
|
||||
|
||||
|
||||
@router.get("/analytics/preview", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_analytics_preview(request: Request) -> JSONResponse:
|
||||
"""Get the analytics report Frigate would send next, without sending it."""
|
||||
report = preview_report(
|
||||
request.app.frigate_config,
|
||||
request.app.stats_emitter,
|
||||
request.app.notice_registry,
|
||||
)
|
||||
return JSONResponse(content=report.model_dump(mode="json"))
|
||||
+1
-6
@@ -934,8 +934,7 @@ def profile(request: Request):
|
||||
)
|
||||
def logout(request: Request):
|
||||
auth_config: AuthConfig = request.app.frigate_config.auth
|
||||
base_path = request.headers.get("x-ingress-path", "").rstrip("/")
|
||||
response = RedirectResponse(f"{base_path}/login", status_code=303)
|
||||
response = RedirectResponse("/login", status_code=303)
|
||||
response.delete_cookie(auth_config.cookie_name)
|
||||
return response
|
||||
|
||||
@@ -966,10 +965,6 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
try:
|
||||
db_user: User = User.get_by_id(user)
|
||||
except DoesNotExist:
|
||||
# Hash anyway so response timing doesn't reveal which usernames exist
|
||||
hash_password(
|
||||
password, iterations=request.app.frigate_config.auth.hash_iterations
|
||||
)
|
||||
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
|
||||
failed_logins.record(user, time.time())
|
||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||
|
||||
+1
-3
@@ -1,6 +1,5 @@
|
||||
"""Chat and LLM tool calling APIs."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
@@ -1654,8 +1653,7 @@ async def chat_completion(
|
||||
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s) in conversation"
|
||||
)
|
||||
response = await asyncio.to_thread(
|
||||
genai_client.chat_with_tools,
|
||||
response = genai_client.chat_with_tools(
|
||||
messages=conversation,
|
||||
tools=llm_tools if llm_tools else None,
|
||||
tool_choice="auto",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class MediaRecordingsAvailabilityQueryParams(BaseModel):
|
||||
cameras: str = "all"
|
||||
before: float | SkipJsonSchema[None] = None
|
||||
after: float | SkipJsonSchema[None] = None
|
||||
scale: int = Field(default=30, ge=1)
|
||||
scale: int = 30
|
||||
|
||||
|
||||
class RecordingsDeleteQueryParams(BaseModel):
|
||||
|
||||
@@ -2,7 +2,6 @@ from enum import Enum
|
||||
|
||||
|
||||
class Tags(Enum):
|
||||
analytics = "Analytics"
|
||||
app = "App"
|
||||
auth = "Auth"
|
||||
camera = "Camera"
|
||||
|
||||
@@ -291,12 +291,12 @@ def events(
|
||||
clauses.append(
|
||||
reduce(
|
||||
operator.or_,
|
||||
[(start_hour_fun >= time_after), (start_hour_fun < time_before)],
|
||||
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
|
||||
)
|
||||
)
|
||||
# all other cases should be and operator
|
||||
else:
|
||||
clauses.append(start_hour_fun >= time_after)
|
||||
clauses.append(start_hour_fun > time_after)
|
||||
clauses.append(start_hour_fun < time_before)
|
||||
|
||||
if has_clip is not None:
|
||||
@@ -749,12 +749,12 @@ def events_search(
|
||||
event_filters.append(
|
||||
reduce(
|
||||
operator.or_,
|
||||
[(start_hour_fun >= time_after), (start_hour_fun < time_before)],
|
||||
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
|
||||
)
|
||||
)
|
||||
# all other cases should be and operator
|
||||
else:
|
||||
event_filters.append(start_hour_fun >= time_after)
|
||||
event_filters.append(start_hour_fun > time_after)
|
||||
event_filters.append(start_hour_fun < time_before)
|
||||
|
||||
# Perform semantic search
|
||||
|
||||
@@ -1227,7 +1227,7 @@ def bulk_delete_exports(body: ExportBulkDeleteBody):
|
||||
files_in_use = _get_files_in_use()
|
||||
|
||||
for export in exports:
|
||||
if export.in_progress or export.video_path.split("/")[-1] in files_in_use:
|
||||
if export.video_path.split("/")[-1] in files_in_use:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
|
||||
@@ -13,8 +13,8 @@ from slowapi.middleware import SlowAPIMiddleware
|
||||
from starlette_context import middleware, plugins
|
||||
from starlette_context.plugins import Plugin
|
||||
|
||||
from frigate.api import app as main_app
|
||||
from frigate.api import (
|
||||
analytics,
|
||||
auth,
|
||||
camera,
|
||||
chat,
|
||||
@@ -31,7 +31,6 @@ from frigate.api import (
|
||||
record,
|
||||
review,
|
||||
)
|
||||
from frigate.api import app as main_app
|
||||
from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default
|
||||
from frigate.comms.dispatcher import Dispatcher
|
||||
from frigate.comms.event_metadata_updater import (
|
||||
@@ -144,7 +143,6 @@ def create_fastapi_app(
|
||||
|
||||
# Routes
|
||||
# Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters
|
||||
app.include_router(analytics.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(camera.router)
|
||||
app.include_router(chat.router)
|
||||
|
||||
@@ -862,7 +862,7 @@ async def vod_hour(
|
||||
async def vod_event(
|
||||
request: Request,
|
||||
event_id: str,
|
||||
padding: int = Query(0, ge=0, description="Padding to apply to the vod."),
|
||||
padding: int = Query(0, description="Padding to apply to the vod."),
|
||||
):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
@@ -1521,7 +1521,6 @@ async def preview_gif(
|
||||
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
|
||||
)
|
||||
.where(Previews.camera == camera_name)
|
||||
.order_by(Previews.start_time.asc())
|
||||
.limit(1)
|
||||
.get()
|
||||
)
|
||||
@@ -1531,8 +1530,7 @@ async def preview_gif(
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
clip_start = max(start_ts, preview.start_time)
|
||||
diff = clip_start - preview.start_time
|
||||
diff = start_ts - preview.start_time
|
||||
minutes = int(diff / 60)
|
||||
seconds = int(diff % 60)
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
@@ -1544,7 +1542,7 @@ async def preview_gif(
|
||||
"-ss",
|
||||
f"00:{minutes}:{seconds}",
|
||||
"-t",
|
||||
f"{end_ts - clip_start}",
|
||||
f"{end_ts - start_ts}",
|
||||
"-i",
|
||||
preview.path,
|
||||
"-r",
|
||||
@@ -1711,7 +1709,6 @@ async def preview_mp4(
|
||||
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
|
||||
)
|
||||
.where(Previews.camera == camera_name)
|
||||
.order_by(Previews.start_time.asc())
|
||||
.limit(1)
|
||||
.get()
|
||||
)
|
||||
@@ -1724,8 +1721,7 @@ async def preview_mp4(
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
clip_start = max(start_ts, preview.start_time)
|
||||
diff = clip_start - preview.start_time
|
||||
diff = start_ts - preview.start_time
|
||||
minutes = int(diff / 60)
|
||||
seconds = int(diff % 60)
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
@@ -1738,7 +1734,7 @@ async def preview_mp4(
|
||||
"-ss",
|
||||
f"00:{minutes}:{seconds}",
|
||||
"-t",
|
||||
f"{end_ts - clip_start}",
|
||||
f"{end_ts - start_ts}",
|
||||
"-i",
|
||||
preview.path,
|
||||
"-r",
|
||||
|
||||
@@ -163,7 +163,8 @@ async def review(
|
||||
|
||||
# Apply ordering and limit
|
||||
review_query = (
|
||||
review_query.order_by(ReviewSegment.start_time.desc())
|
||||
review_query.order_by(ReviewSegment.severity.asc())
|
||||
.order_by(ReviewSegment.start_time.desc())
|
||||
.limit(limit)
|
||||
.dicts()
|
||||
.iterator()
|
||||
|
||||
@@ -15,7 +15,6 @@ import uvicorn
|
||||
from peewee import SqliteDatabase
|
||||
from peewee_migrate import Router
|
||||
|
||||
from frigate.analytics.reporter import AnalyticsReporter
|
||||
from frigate.api.auth import hash_password
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
from frigate.camera import CameraMetrics, PTZMetrics
|
||||
@@ -525,15 +524,6 @@ class FrigateApp:
|
||||
)
|
||||
self.stats_emitter.start()
|
||||
|
||||
def start_analytics_reporter(self) -> None:
|
||||
self.analytics_reporter = AnalyticsReporter(
|
||||
self.config_holder,
|
||||
self.stats_emitter,
|
||||
self.notice_registry,
|
||||
self.stop_event,
|
||||
)
|
||||
self.analytics_reporter.start()
|
||||
|
||||
def start_watchdog(self) -> None:
|
||||
self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event)
|
||||
|
||||
@@ -707,7 +697,6 @@ class FrigateApp:
|
||||
self.start_audio_processor()
|
||||
self.start_storage_maintainer()
|
||||
self.start_stats_emitter()
|
||||
self.start_analytics_reporter()
|
||||
self.start_timeline_processor()
|
||||
self.start_event_processor()
|
||||
self.start_event_cleanup()
|
||||
@@ -809,8 +798,6 @@ class FrigateApp:
|
||||
self.event_cleanup.join()
|
||||
self.record_cleanup.join()
|
||||
self.stats_emitter.join()
|
||||
# a send in flight can hold the thread for the whole request timeout
|
||||
self.analytics_reporter.join(timeout=5)
|
||||
self.frigate_watchdog.join()
|
||||
self.camera_maintainer.join()
|
||||
self.db.stop()
|
||||
|
||||
@@ -7,7 +7,6 @@ import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
@@ -23,7 +22,6 @@ from frigate.comms.base_communicator import Communicator
|
||||
from frigate.comms.config_updater import ConfigSubscriber
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.auth import AuthConfig
|
||||
from frigate.config.camera.notification import NOTIFICATION_TTL_SECONDS
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
@@ -186,13 +184,11 @@ class WebPushClient(Communicator):
|
||||
}
|
||||
self.suspension_broadcaster: Callable[[str, Any, bool], None] | None = None
|
||||
self.config_lock = threading.Lock()
|
||||
# monotonic times, so a backward wall clock step can't hold every
|
||||
# notification in cooldown. -inf means none has been sent yet
|
||||
self.last_camera_notification_time: dict[str, float] = {
|
||||
c.name: float("-inf") # type: ignore[misc]
|
||||
c.name: 0 # type: ignore[misc]
|
||||
for c in self.config.cameras.values()
|
||||
}
|
||||
self.last_notification_time: float = float("-inf")
|
||||
self.last_notification_time: float = 0
|
||||
self.user_cameras: dict[str, set[str]] = {}
|
||||
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
|
||||
self.notification_thread: threading.Thread | None = None
|
||||
@@ -347,7 +343,7 @@ class WebPushClient(Communicator):
|
||||
if "add" in updates:
|
||||
for camera in updates["add"]:
|
||||
self.suspended_cameras[camera] = 0
|
||||
self.last_camera_notification_time[camera] = float("-inf")
|
||||
self.last_camera_notification_time[camera] = 0
|
||||
|
||||
self._refresh_user_cameras()
|
||||
|
||||
@@ -502,7 +498,7 @@ class WebPushClient(Communicator):
|
||||
return camera in allowed
|
||||
|
||||
def _within_cooldown(self, camera: str) -> bool:
|
||||
now = time.monotonic()
|
||||
now = datetime.datetime.now().timestamp()
|
||||
if now - self.last_notification_time < self.config.notifications.cooldown:
|
||||
logger.debug(
|
||||
f"Skipping notification for {camera} - in global cooldown period"
|
||||
@@ -547,7 +543,7 @@ class WebPushClient(Communicator):
|
||||
camera_name: str = getattr(
|
||||
self.config.cameras[camera], "friendly_name", None
|
||||
) or titlecase(camera.replace("_", " "))
|
||||
current_time = time.monotonic()
|
||||
current_time = datetime.datetime.now().timestamp()
|
||||
|
||||
if self._within_cooldown(camera):
|
||||
return
|
||||
@@ -627,7 +623,7 @@ class WebPushClient(Communicator):
|
||||
|
||||
# if event is ongoing open to live view otherwise open to recordings view
|
||||
direct_url = f"/review?id={reviewId}" if ended else f"/#{camera}"
|
||||
ttl = 3600 if ended else NOTIFICATION_TTL_SECONDS[self.config.notifications.ttl]
|
||||
ttl = 3600 if ended else 0
|
||||
|
||||
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
|
||||
|
||||
@@ -660,7 +656,7 @@ class WebPushClient(Communicator):
|
||||
camera_name: str = getattr(
|
||||
self.config.cameras[camera], "friendly_name", None
|
||||
) or titlecase(camera.replace("_", " "))
|
||||
current_time = time.monotonic()
|
||||
current_time = datetime.datetime.now().timestamp()
|
||||
|
||||
if self._within_cooldown(camera):
|
||||
return
|
||||
|
||||
@@ -57,6 +57,11 @@ def birdseye_modes_to_mqtt_payload(modes: list[BirdseyeModeEnum]) -> str:
|
||||
return payload or MQTT_NO_MODES
|
||||
|
||||
|
||||
def default_birdseye_modes() -> list[BirdseyeModeEnum]:
|
||||
"""Return the default Birdseye activity modes."""
|
||||
return [BirdseyeModeEnum.all_objects]
|
||||
|
||||
|
||||
class BirdseyeLayoutConfig(FrigateBaseModel):
|
||||
scaling_factor: float = Field(
|
||||
default=2.0,
|
||||
@@ -79,7 +84,7 @@ class BirdseyeConfig(FrigateBaseModel):
|
||||
description="Enable or disable the Birdseye view feature.",
|
||||
)
|
||||
modes: list[BirdseyeModeEnum] = Field(
|
||||
default=[BirdseyeModeEnum.all_objects],
|
||||
default_factory=default_birdseye_modes,
|
||||
title="Activity types",
|
||||
description="Activity types that include cameras in Birdseye.",
|
||||
)
|
||||
@@ -134,7 +139,7 @@ class BirdseyeCameraConfig(BaseModel):
|
||||
description="Enable or disable the Birdseye view feature.",
|
||||
)
|
||||
modes: list[BirdseyeModeEnum] = Field(
|
||||
default=[BirdseyeModeEnum.all_objects],
|
||||
default_factory=default_birdseye_modes,
|
||||
title="Activity types",
|
||||
description="Activity types that include cameras in Birdseye.",
|
||||
)
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from frigate.util.env import EnvString
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
|
||||
__all__ = ["NotificationConfig", "NotificationTTLEnum", "NOTIFICATION_TTL_SECONDS"]
|
||||
|
||||
|
||||
class NotificationTTLEnum(str, Enum):
|
||||
immediate = "immediate"
|
||||
reliable = "reliable"
|
||||
|
||||
|
||||
NOTIFICATION_TTL_SECONDS = {
|
||||
NotificationTTLEnum.immediate: 0,
|
||||
NotificationTTLEnum.reliable: 30,
|
||||
}
|
||||
__all__ = ["NotificationConfig"]
|
||||
|
||||
|
||||
class NotificationConfig(FrigateBaseModel):
|
||||
@@ -37,11 +24,6 @@ class NotificationConfig(FrigateBaseModel):
|
||||
title="Cooldown period",
|
||||
description="Cooldown (seconds) between notifications to avoid spamming recipients.",
|
||||
)
|
||||
ttl: NotificationTTLEnum = Field(
|
||||
default=NotificationTTLEnum.immediate,
|
||||
title="Notification TTL",
|
||||
description="How the push service handles a notification sent while an alert is active if your device can't be reached. Immediate delivers it right away or drops it. Reliable keeps retrying for up to 30 seconds. The notification sent when an alert ends is always kept for up to an hour.",
|
||||
)
|
||||
enabled_in_config: bool | None = Field(
|
||||
default=None,
|
||||
title="Original notifications state",
|
||||
|
||||
@@ -171,6 +171,11 @@ class GenAIReviewConfig(FrigateBaseModel):
|
||||
title="Response style",
|
||||
description="Writing style preset for generated review descriptions. Presets adjust the tone and level of detail of the user-facing title, summary, and scene description; 'default' leaves the built-in prompt unchanged.",
|
||||
)
|
||||
enable_thinking: bool = Field(
|
||||
default=False,
|
||||
title="Enable thinking",
|
||||
description="Let the model reason before writing the review description. Only applies to models with a per-request thinking toggle (Ollama and llama.cpp providers); other models keep their default behavior. Thinking increases response time and token usage. When thumbnail saving is enabled, the reasoning is saved alongside the response.",
|
||||
)
|
||||
activity_context_prompt: str = Field(
|
||||
default="""### Normal Activity Indicators (Level 0)
|
||||
- Known/verified people in any zone at any time
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
"""Shared handle on the config object that is current for this instance."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from .config import FrigateConfig
|
||||
|
||||
__all__ = ["ConfigHolder"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigHolder:
|
||||
"""Indirection for the most recently parsed config.
|
||||
@@ -28,24 +23,12 @@ class ConfigHolder:
|
||||
|
||||
def __init__(self, config: FrigateConfig) -> None:
|
||||
self._config = config
|
||||
self._listeners: list[Callable[[FrigateConfig], None]] = []
|
||||
|
||||
@property
|
||||
def config(self) -> FrigateConfig:
|
||||
"""The config as of the most recent successful save."""
|
||||
return self._config
|
||||
|
||||
def subscribe(self, listener: Callable[[FrigateConfig], None]) -> None:
|
||||
"""Call listener on the saving thread with each config installed later."""
|
||||
self._listeners.append(listener)
|
||||
|
||||
def set(self, config: FrigateConfig) -> None:
|
||||
"""Install a freshly parsed config as the current one."""
|
||||
self._config = config
|
||||
|
||||
for listener in self._listeners:
|
||||
try:
|
||||
listener(config)
|
||||
except Exception:
|
||||
# a listener bug must not fail the save that has already applied
|
||||
logger.exception("Config listener failed")
|
||||
|
||||
@@ -29,11 +29,6 @@ class StatsConfig(FrigateBaseModel):
|
||||
|
||||
|
||||
class TelemetryConfig(FrigateBaseModel):
|
||||
analytics: bool = Field(
|
||||
default=False,
|
||||
title="Share anonymous analytics",
|
||||
description="Send one anonymous usage report a day to help the Frigate maintainers decide what to support. Nothing is sent until this is on.",
|
||||
)
|
||||
network_interfaces: list[str] = Field(
|
||||
default=[],
|
||||
title="Network interfaces",
|
||||
|
||||
@@ -102,9 +102,6 @@ MAX_WAL_SIZE = 10 # MB
|
||||
|
||||
DEFAULT_FFMPEG_VERSION = os.environ.get("DEFAULT_FFMPEG_VERSION", "")
|
||||
INCLUDED_FFMPEG_VERSIONS = os.environ.get("INCLUDED_FFMPEG_VERSIONS", "").split(":")
|
||||
ANALYTICS_URL = os.environ.get(
|
||||
"FRIGATE_ANALYTICS_URL", "https://analytics.frigate.video/report"
|
||||
)
|
||||
LIBAVFORMAT_VERSION_MAJOR = int(os.environ.get("LIBAVFORMAT_VERSION_MAJOR", "59"))
|
||||
FFMPEG_HWACCEL_NVIDIA = "preset-nvidia"
|
||||
FFMPEG_HWACCEL_VAAPI = "preset-vaapi"
|
||||
|
||||
@@ -80,22 +80,24 @@ def describe_heading(dx: float, dy: float) -> str:
|
||||
|
||||
|
||||
def event_name(event: dict[str, Any]) -> str:
|
||||
"""Name an object for the notes, e.g. 'a person' or 'waste bin "Compost"'.
|
||||
"""Name an object for the notes, e.g. 'a person' or 'Nick'.
|
||||
|
||||
A recognized sub_label is used on its own, in the same title case as
|
||||
"Objects in Scene", so the notes name the subject the way the response
|
||||
should. The object type is left out: leading with it makes models copy
|
||||
"person" and drop the name from titles. An unrecognized face is stored as
|
||||
"unknown" and stays unnamed.
|
||||
|
||||
Objects are never numbered or given track identifiers. Frigate opens a new
|
||||
tracked object whenever a subject is re-detected, so the tracking data
|
||||
cannot say whether two entries are the same subject, and the notes stay
|
||||
ambiguous rather than implying either answer.
|
||||
|
||||
Names are formatted the same way as in the prompt's "Objects in Scene"
|
||||
list. Small models otherwise copy the raw lowercase sub label into their
|
||||
response instead of the name they were told to use.
|
||||
"""
|
||||
label = str(event["label"]).replace("_", " ").replace("-verified", "")
|
||||
sub_label = event.get("sub_label")
|
||||
sub_label = str(event.get("sub_label") or "").strip()
|
||||
|
||||
if sub_label:
|
||||
return f'{label} "{titlecase(str(sub_label).replace("_", " "))}"'
|
||||
if sub_label and sub_label.lower() != "unknown":
|
||||
return titlecase(sub_label.replace("_", " "))
|
||||
|
||||
article = "an" if label[:1].lower() in "aeiou" else "a"
|
||||
return f"{article} {label}"
|
||||
@@ -219,7 +221,6 @@ def build_timeline(
|
||||
events: list[dict[str, Any]],
|
||||
span_end: float,
|
||||
state_changes: Sequence[dict[str, Any]] = (),
|
||||
span_start: float | None = None,
|
||||
) -> list[tuple[float, str]]:
|
||||
"""All annotated moments across every event, in time order.
|
||||
|
||||
@@ -229,12 +230,6 @@ def build_timeline(
|
||||
describe nothing the model can see. A track ending means the object
|
||||
stopped being detected, which may or may not mean it left the frame.
|
||||
|
||||
`span_start` is the timestamp of the first sampled frame. An object
|
||||
tracked since before it gets a single "already in view" note instead of
|
||||
its earlier history: those moments would otherwise land on the first
|
||||
frame and read as happening during the clip, turning a car that parked
|
||||
minutes ago into one pulling in.
|
||||
|
||||
`state_changes` are timeline rows (timestamp, source_id, class_type); the
|
||||
stationary and active ones become "has stopped moving" / "starts moving
|
||||
again". Frigate only marks an object stationary after it has been still
|
||||
@@ -262,13 +257,6 @@ def build_timeline(
|
||||
if event["start_time"] > span_end:
|
||||
continue
|
||||
|
||||
if (
|
||||
span_start is not None
|
||||
and event["end_time"]
|
||||
and event["end_time"] < span_start
|
||||
):
|
||||
continue
|
||||
|
||||
points = path_points(event.get("path_data") or [])
|
||||
legs = path_legs(points) if len(points) >= 2 else []
|
||||
name = event_name(event)
|
||||
@@ -279,33 +267,8 @@ def build_timeline(
|
||||
)
|
||||
remaining = list(enumerate(legs))
|
||||
moments: list[tuple[float, str]] = []
|
||||
event_changes = sorted(
|
||||
(timestamp, phrase)
|
||||
for timestamp, phrase in changes_by_event.get(event["id"], [])
|
||||
if timestamp >= detected_at
|
||||
)
|
||||
|
||||
if span_start is not None and detected_at < span_start:
|
||||
# Only whether it had come to rest is noted; where it is and which
|
||||
# way it is heading are visible in the first frame.
|
||||
earlier = [phrase for t, phrase in event_changes if t < span_start]
|
||||
not_moving = (
|
||||
bool(earlier) and earlier[-1] == STATE_CHANGE_PHRASES["stationary"]
|
||||
)
|
||||
timeline.append(
|
||||
(
|
||||
span_start,
|
||||
f"{name} already in view when the sequence begins"
|
||||
+ (", not moving" if not_moving else ""),
|
||||
)
|
||||
)
|
||||
remaining = [
|
||||
(index, leg)
|
||||
for index, leg in remaining
|
||||
if leg_start_time(points, leg) >= span_start
|
||||
]
|
||||
event_changes = [(t, p) for t, p in event_changes if t >= span_start]
|
||||
elif merges:
|
||||
if merges:
|
||||
heading = leg_heading(points, legs[0])
|
||||
timeline.append(
|
||||
(detected_at, f"{name} first detected at {where}, moving {heading}")
|
||||
@@ -322,7 +285,11 @@ def build_timeline(
|
||||
)
|
||||
)
|
||||
|
||||
moments.extend(event_changes)
|
||||
moments.extend(
|
||||
(timestamp, phrase)
|
||||
for timestamp, phrase in changes_by_event.get(event["id"], [])
|
||||
if timestamp >= detected_at
|
||||
)
|
||||
|
||||
for timestamp, phrase in moments:
|
||||
if timestamp <= span_end:
|
||||
@@ -428,14 +395,10 @@ def build_frame_captions(
|
||||
|
||||
# audio and manual review items can have state changes but no tracked objects
|
||||
if events:
|
||||
# No source prefix. A tag placed before the name is read as the
|
||||
# subject's identity.
|
||||
timeline.extend(
|
||||
(timestamp, f"[tracker] {note}")
|
||||
for timestamp, note in build_timeline(
|
||||
events,
|
||||
span_end,
|
||||
get_state_changes(detection_ids),
|
||||
span_start=frame_times[0],
|
||||
)
|
||||
build_timeline(events, span_end, get_state_changes(detection_ids))
|
||||
)
|
||||
|
||||
timeline.extend(
|
||||
|
||||
@@ -34,7 +34,7 @@ from frigate.const import (
|
||||
from frigate.data_processing.types import PostProcessDataEnum
|
||||
from frigate.genai import GenAIClient
|
||||
from frigate.genai.manager import GenAIClientManager
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.models import Recordings, ReviewSegment
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
|
||||
from frigate.util.image import get_image_from_recording
|
||||
from frigate.util.recording_coverage import recording_overlap
|
||||
@@ -439,22 +439,13 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
if camera_config.review.genai.debug_save_thumbnails:
|
||||
self.save_debug_recording_frames(review_id, frames)
|
||||
|
||||
self.start_analysis(
|
||||
camera_config,
|
||||
final_data,
|
||||
frames,
|
||||
get_named_objects(
|
||||
final_data["data"].get("detections") or [],
|
||||
self.config.all_attributes,
|
||||
),
|
||||
)
|
||||
self.start_analysis(camera_config, final_data, frames)
|
||||
|
||||
def start_analysis(
|
||||
self,
|
||||
camera_config: CameraConfig,
|
||||
final_data: dict[str, Any],
|
||||
frames: list[tuple[bytes, float]],
|
||||
named_objects: list[tuple[str, str]] | None = None,
|
||||
) -> None:
|
||||
"""Kick off description generation for a review item in the background."""
|
||||
thumbs = [frame for frame, _ in frames]
|
||||
@@ -490,7 +481,6 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
camera_config.review.genai,
|
||||
sorted(self.config.all_labels),
|
||||
self.config.all_attributes,
|
||||
named_objects,
|
||||
),
|
||||
).start()
|
||||
|
||||
@@ -751,29 +741,6 @@ def format_classification_state_changes(
|
||||
return lines
|
||||
|
||||
|
||||
def get_named_objects(
|
||||
detection_ids: list[str], attribute_labels: list[str]
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Get the label and sub label of each named tracked object in a review item.
|
||||
|
||||
Editing a sub label after a review item ends rebuilds the stored sub_labels
|
||||
without verified_objects, so the stored lists can't be paired by position.
|
||||
"""
|
||||
events = (
|
||||
Event.select(Event.label, Event.sub_label)
|
||||
.where(Event.id.in_(detection_ids), Event.sub_label.is_null(False))
|
||||
.order_by(Event.start_time)
|
||||
)
|
||||
|
||||
# attribute sub labels (eg, delivery logos) are not names, which matches
|
||||
# how the review maintainer records them
|
||||
return [
|
||||
(event.label, event.sub_label)
|
||||
for event in events
|
||||
if event.sub_label and event.sub_label not in attribute_labels
|
||||
]
|
||||
|
||||
|
||||
def run_analysis(
|
||||
requestor: InterProcessRequestor,
|
||||
genai_client: GenAIClient,
|
||||
@@ -785,7 +752,6 @@ def run_analysis(
|
||||
genai_config: GenAIReviewConfig,
|
||||
labelmap_objects: list[str],
|
||||
attribute_labels: list[str],
|
||||
named_objects: list[tuple[str, str]] | None,
|
||||
) -> None:
|
||||
start = datetime.datetime.now().timestamp()
|
||||
|
||||
@@ -810,20 +776,11 @@ def run_analysis(
|
||||
unified_objects = []
|
||||
|
||||
objects_list = final_data["data"]["objects"]
|
||||
sub_labels_list = final_data["data"]["sub_labels"]
|
||||
|
||||
if named_objects is None:
|
||||
# live review items record a sub label for every verified object
|
||||
named_objects = [
|
||||
(verified_label.replace("-verified", ""), sub_label)
|
||||
for verified_label, sub_label in zip(
|
||||
final_data["data"]["verified_objects"],
|
||||
final_data["data"]["sub_labels"],
|
||||
)
|
||||
]
|
||||
|
||||
for label, sub_label in named_objects:
|
||||
object_type = label.replace("_", " ")
|
||||
name = titlecase(sub_label.replace("_", " "))
|
||||
for i, verified_label in enumerate(final_data["data"]["verified_objects"]):
|
||||
object_type = verified_label.replace("-verified", "").replace("_", " ")
|
||||
name = titlecase(sub_labels_list[i].replace("_", " "))
|
||||
unified_objects.append(f"{name} ← {object_type}")
|
||||
|
||||
for label in objects_list:
|
||||
@@ -856,6 +813,7 @@ def run_analysis(
|
||||
genai_config.activity_context_prompt,
|
||||
genai_config.response_style,
|
||||
frame_captions,
|
||||
genai_config.enable_thinking,
|
||||
)
|
||||
review_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
@@ -68,8 +67,7 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
||||
else:
|
||||
self.inference_speed = None
|
||||
|
||||
# monotonic so a backward wall clock step can't pause classification
|
||||
self.last_run = time.monotonic()
|
||||
self.last_run = datetime.datetime.now().timestamp()
|
||||
self.__build_detector()
|
||||
|
||||
def __build_detector(self) -> None:
|
||||
@@ -217,12 +215,11 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
||||
should_run = False
|
||||
|
||||
now = datetime.datetime.now().timestamp()
|
||||
mono_now = time.monotonic()
|
||||
if (
|
||||
self.model_config.state_config.interval
|
||||
and mono_now > self.last_run + self.model_config.state_config.interval
|
||||
and now > self.last_run + self.model_config.state_config.interval
|
||||
):
|
||||
self.last_run = mono_now
|
||||
self.last_run = now
|
||||
should_run = True
|
||||
|
||||
if (
|
||||
@@ -231,8 +228,8 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
||||
and any([box_overlaps(crop, mb) for mb in frame_data.get("motion", [])])
|
||||
):
|
||||
# classification should run at most once per second
|
||||
if mono_now > self.last_run + 1:
|
||||
self.last_run = mono_now
|
||||
if now > self.last_run + 1:
|
||||
self.last_run = now
|
||||
should_run = True
|
||||
|
||||
# Shortcut: always run if we have a pending state verification to complete
|
||||
@@ -240,9 +237,9 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
||||
not should_run
|
||||
and camera in self.state_history
|
||||
and self.state_history[camera]["pending_state"] is not None
|
||||
and mono_now > self.last_run + 0.5
|
||||
and now > self.last_run + 0.5
|
||||
):
|
||||
self.last_run = mono_now
|
||||
self.last_run = now
|
||||
should_run = True
|
||||
logger.debug(
|
||||
f"Running verification check for pending state: {self.state_history[camera]['pending_state']} ({self.state_history[camera]['consecutive_count']}/3)"
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from frigate.detectors.detector_config import ModelConfig, ModelTypeEnum
|
||||
from frigate.util.model import xyxy_to_xywh_for_nms
|
||||
|
||||
try:
|
||||
from tflite_runtime.interpreter import Interpreter, load_delegate
|
||||
except ModuleNotFoundError:
|
||||
@@ -17,324 +12,39 @@ except ModuleNotFoundError:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TFLiteDetectionModel:
|
||||
"""Object detection pre and post processing for a TFLite interpreter.
|
||||
def tflite_init(self, interpreter):
|
||||
self.interpreter = interpreter
|
||||
|
||||
Every TFLite detector shares this, differing only in how it builds the
|
||||
interpreter (on the CPU, or with the EdgeTPU or Teflon delegate).
|
||||
"""
|
||||
self.interpreter.allocate_tensors()
|
||||
|
||||
supported_models = [
|
||||
ModelTypeEnum.ssd,
|
||||
ModelTypeEnum.yologeneric,
|
||||
]
|
||||
self.tensor_input_details = self.interpreter.get_input_details()
|
||||
self.tensor_output_details = self.interpreter.get_output_details()
|
||||
|
||||
def __init__(
|
||||
self, interpreter: Interpreter, model_config: ModelConfig, detector_key: str
|
||||
):
|
||||
"""Prepare the interpreter and work out the model's output layout.
|
||||
|
||||
Args:
|
||||
interpreter: The TFLite interpreter, with any delegate loaded
|
||||
model_config: The model being run
|
||||
detector_key: The detector's type, for error messages
|
||||
"""
|
||||
self.model_type = model_config.model_type
|
||||
def tflite_detect_raw(self, tensor_input):
|
||||
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
|
||||
self.interpreter.invoke()
|
||||
|
||||
if self.model_type not in self.supported_models:
|
||||
raise ValueError(
|
||||
f"{self.model_type.value} is currently not supported for {detector_key}. See the docs for more info on supported models."
|
||||
)
|
||||
boxes = self.interpreter.tensor(self.tensor_output_details[0]["index"])()[0]
|
||||
class_ids = self.interpreter.tensor(self.tensor_output_details[1]["index"])()[0]
|
||||
scores = self.interpreter.tensor(self.tensor_output_details[2]["index"])()[0]
|
||||
count = int(self.interpreter.tensor(self.tensor_output_details[3]["index"])()[0])
|
||||
|
||||
self.interpreter = interpreter
|
||||
detections = np.zeros((20, 6), np.float32)
|
||||
|
||||
try:
|
||||
self.interpreter.allocate_tensors()
|
||||
except RuntimeError as e:
|
||||
# an EdgeTPU compiled model wraps its graph in a custom op that only
|
||||
# the EdgeTPU delegate can run
|
||||
if "edgetpu-custom-op" in str(e):
|
||||
logger.error(
|
||||
"%s is compiled for the EdgeTPU and can only be run by an edgetpu detector",
|
||||
model_config.path,
|
||||
)
|
||||
raise
|
||||
for i in range(count):
|
||||
if scores[i] < 0.4 or i == 20:
|
||||
break
|
||||
detections[i] = [
|
||||
class_ids[i],
|
||||
float(scores[i]),
|
||||
boxes[i][0],
|
||||
boxes[i][1],
|
||||
boxes[i][2],
|
||||
boxes[i][3],
|
||||
]
|
||||
|
||||
self.tensor_input_details = self.interpreter.get_input_details()
|
||||
self.tensor_output_details = self.interpreter.get_output_details()
|
||||
self.model_width = model_config.width
|
||||
self.model_height = model_config.height
|
||||
|
||||
self.min_score = 0.4
|
||||
self.max_detections = 20
|
||||
|
||||
self.model_requires_int8 = self.tensor_input_details[0]["dtype"] == np.int8
|
||||
|
||||
if self.model_type == ModelTypeEnum.yologeneric:
|
||||
logger.debug("Using YOLO preprocessing/postprocessing")
|
||||
|
||||
if len(self.tensor_output_details) not in [2, 3]:
|
||||
raise ValueError(
|
||||
f"Invalid count of output tensors in YOLO model. Found {len(self.tensor_output_details)}, expecting 2 or 3."
|
||||
)
|
||||
|
||||
self.reg_max = 16 # = 64 dfl_channels // 4 # YOLO standard
|
||||
self.min_logit_value = np.log(
|
||||
self.min_score / (1 - self.min_score)
|
||||
) # for filtering
|
||||
self._generate_anchors_and_strides() # decode bounding box DFL
|
||||
self.project = np.arange(
|
||||
self.reg_max, dtype=np.float32
|
||||
) # for decoding bounding box DFL information
|
||||
|
||||
# Determine YOLO tensor indices and quantization scales for
|
||||
# boxes and class_scores the tensor ordering and names are
|
||||
# not reliable, so use tensor shape to detect which tensor
|
||||
# holds boxes or class scores.
|
||||
# The tensors have shapes (B, N, C)
|
||||
# where N is the number of candidates (=2100 for 320x320)
|
||||
# this may guess wrong if the number of classes is exactly 64
|
||||
output_boxes_index = None
|
||||
output_classes_index = None
|
||||
for i, x in enumerate(self.tensor_output_details):
|
||||
# the nominal index seems to start at 1 instead of 0
|
||||
if len(x["shape"]) == 3 and x["shape"][2] == 64:
|
||||
output_boxes_index = i
|
||||
elif len(x["shape"]) == 3 and x["shape"][2] > 1:
|
||||
# require the number of classes to be more than 1
|
||||
# to differentiate from (not used) max score tensor
|
||||
output_classes_index = i
|
||||
if output_boxes_index is None or output_classes_index is None:
|
||||
logger.warning("Unrecognized model output, unexpected tensor shapes.")
|
||||
output_classes_index = (
|
||||
0
|
||||
if (output_boxes_index is None or output_classes_index == 1)
|
||||
else 1
|
||||
) # 0 is default guess
|
||||
output_boxes_index = 1 if (output_boxes_index == 0) else 0
|
||||
|
||||
scores_details = self.tensor_output_details[output_classes_index]
|
||||
boxes_details = self.tensor_output_details[output_boxes_index]
|
||||
|
||||
# post processing works on the raw quantized values, which a float
|
||||
# model doesn't have
|
||||
if np.issubdtype(scores_details["dtype"], np.floating) or np.issubdtype(
|
||||
boxes_details["dtype"], np.floating
|
||||
):
|
||||
raise ValueError(
|
||||
f"{model_config.path} has float outputs. YOLO models for TFLite must be int8 quantized."
|
||||
)
|
||||
|
||||
self.scores_tensor_index = scores_details["index"]
|
||||
self.scores_scale, self.scores_zero_point = scores_details["quantization"]
|
||||
# calculate the quantized version of the min_score
|
||||
self.min_score_quantized = int(
|
||||
(self.min_logit_value / self.scores_scale) + self.scores_zero_point
|
||||
)
|
||||
self.logit_shift_to_positive_values = (
|
||||
max(0, math.ceil((128 + self.scores_zero_point) * self.scores_scale))
|
||||
+ 1
|
||||
) # round up
|
||||
|
||||
self.boxes_tensor_index = boxes_details["index"]
|
||||
self.boxes_scale, self.boxes_zero_point = boxes_details["quantization"]
|
||||
|
||||
elif self.model_type == ModelTypeEnum.ssd:
|
||||
logger.debug("Using SSD preprocessing/postprocessing")
|
||||
|
||||
# SSD models end in TFLite_Detection_PostProcess, which outputs
|
||||
# boxes, class ids, scores and count
|
||||
if len(self.tensor_output_details) < 4:
|
||||
raise ValueError(
|
||||
f"Invalid count of output tensors in SSD model. Found {len(self.tensor_output_details)}, expecting 4. YOLO models need a model_type of yolo-generic."
|
||||
)
|
||||
|
||||
def _generate_anchors_and_strides(self):
|
||||
# for decoding the bounding box DFL information into xy coordinates
|
||||
all_anchors = []
|
||||
all_strides = []
|
||||
strides = (8, 16, 32) # YOLO's small, medium, large detection heads
|
||||
|
||||
for stride in strides:
|
||||
feat_h, feat_w = self.model_height // stride, self.model_width // stride
|
||||
|
||||
grid_y, grid_x = np.meshgrid(
|
||||
np.arange(feat_h, dtype=np.float32),
|
||||
np.arange(feat_w, dtype=np.float32),
|
||||
indexing="ij",
|
||||
)
|
||||
|
||||
grid_coords = np.stack((grid_x.flatten(), grid_y.flatten()), axis=1)
|
||||
anchor_points = grid_coords + 0.5
|
||||
|
||||
all_anchors.append(anchor_points)
|
||||
all_strides.append(np.full((feat_h * feat_w, 1), stride, dtype=np.float32))
|
||||
|
||||
self.anchors = np.concatenate(all_anchors, axis=0)
|
||||
self.anchor_strides = np.concatenate(all_strides, axis=0)
|
||||
|
||||
def pre_process(self, tensor_input):
|
||||
if self.model_requires_int8:
|
||||
tensor_input = np.bitwise_xor(tensor_input, 128).view(
|
||||
np.int8
|
||||
) # shift by -128
|
||||
return tensor_input
|
||||
|
||||
def detect_raw(self, tensor_input):
|
||||
"""Run the model on a frame.
|
||||
|
||||
Args:
|
||||
tensor_input: The frame, already resized to the model's input
|
||||
|
||||
Returns:
|
||||
Up to 20 detections as [class_id, score, ymin, xmin, ymax, xmax],
|
||||
padded with zeros
|
||||
"""
|
||||
tensor_input = self.pre_process(tensor_input)
|
||||
|
||||
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
|
||||
self.interpreter.invoke()
|
||||
|
||||
if self.model_type == ModelTypeEnum.yologeneric:
|
||||
return self._post_process_yolo()
|
||||
|
||||
return self._post_process_ssd()
|
||||
|
||||
def _post_process_yolo(self):
|
||||
# Multi-tensor YOLO model with (non-standard B(H*W)C output format).
|
||||
# (the comments indicate the shape of tensors,
|
||||
# using "2100" as the anchor count (for image size of 320x320),
|
||||
# "NC" as number of classes,
|
||||
# "N" as the count that survive after min-score filtering)
|
||||
# TENSOR A) class scores (1, 2100, NC) with logit values
|
||||
# TENSOR B) box coordinates (1, 2100, 64) encoded as dfl scores
|
||||
# Recommend that the model clamp the logit values in tensor (A)
|
||||
# to the range [-4,+4] to preserve precision from [2%,98%]
|
||||
# and because NMS requires the min_score parameter to be >= 0
|
||||
|
||||
# don't dequantize scores data yet, wait until the low-confidence
|
||||
# candidates are filtered out from the overall result set.
|
||||
# This reduces the work and makes post-processing faster.
|
||||
# this method works with raw quantized numbers when possible,
|
||||
# which relies on the value of the scale factor to be >0.
|
||||
# This speeds up max and argmax operations.
|
||||
# Get max confidence for each detection and create the mask
|
||||
detections = np.zeros(
|
||||
(self.max_detections, 6), np.float32
|
||||
) # initialize zero results
|
||||
scores_output_quantized = self.interpreter.get_tensor(self.scores_tensor_index)[
|
||||
0
|
||||
] # (2100, NC)
|
||||
max_scores_quantized = np.max(scores_output_quantized, axis=1) # (2100,)
|
||||
mask = max_scores_quantized >= self.min_score_quantized # (2100,)
|
||||
|
||||
if not np.any(mask):
|
||||
return detections # empty results
|
||||
|
||||
max_scores_filtered_shiftedpositive = (
|
||||
(max_scores_quantized[mask] - self.scores_zero_point) * self.scores_scale
|
||||
) + self.logit_shift_to_positive_values # (N,1) shifted logit values
|
||||
scores_output_quantized_filtered = scores_output_quantized[mask]
|
||||
|
||||
# dequantize boxes. NMS needs them to be in float format
|
||||
# remove candidates with probabilities < threshold
|
||||
boxes_output_quantized_filtered = (
|
||||
self.interpreter.get_tensor(self.boxes_tensor_index)[0]
|
||||
)[mask] # (N, 64)
|
||||
boxes_output_filtered = (
|
||||
boxes_output_quantized_filtered.astype(np.float32) - self.boxes_zero_point
|
||||
) * self.boxes_scale
|
||||
|
||||
# 2. Decode DFL to distances (ltrb)
|
||||
dfl_distributions = boxes_output_filtered.reshape(
|
||||
-1, 4, self.reg_max
|
||||
) # (N, 4, 16)
|
||||
|
||||
# Softmax over the 16 bins
|
||||
dfl_max = np.max(dfl_distributions, axis=2, keepdims=True)
|
||||
dfl_exp = np.exp(dfl_distributions - dfl_max)
|
||||
dfl_probs = dfl_exp / np.sum(dfl_exp, axis=2, keepdims=True) # (N, 4, 16)
|
||||
|
||||
# Weighted sum: (N, 4, 16) * (16,) -> (N, 4)
|
||||
distances = np.einsum("pcr,r->pc", dfl_probs, self.project)
|
||||
|
||||
# Calculate box corners in pixel coordinates
|
||||
anchors_filtered = self.anchors[mask]
|
||||
anchor_strides_filtered = self.anchor_strides[mask]
|
||||
x1y1 = (
|
||||
anchors_filtered - distances[:, [0, 1]]
|
||||
) * anchor_strides_filtered # (N, 2)
|
||||
x2y2 = (
|
||||
anchors_filtered + distances[:, [2, 3]]
|
||||
) * anchor_strides_filtered # (N, 2)
|
||||
boxes_filtered_decoded = np.concatenate((x1y1, x2y2), axis=-1) # (N, 4)
|
||||
|
||||
# 9. Apply NMS. Use logit scores here to defer sigmoid()
|
||||
# until after filtering out redundant boxes
|
||||
# Shift the logit scores to be non-negative (required by cv2)
|
||||
indices = cv2.dnn.NMSBoxes(
|
||||
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
|
||||
),
|
||||
nms_threshold=0.4, # should this be a model config setting?
|
||||
)
|
||||
num_detections = len(indices)
|
||||
if num_detections == 0:
|
||||
return detections # empty results
|
||||
|
||||
nms_indices = np.array(indices, dtype=np.int32).ravel() # or .flatten()
|
||||
if num_detections > self.max_detections:
|
||||
nms_indices = nms_indices[: self.max_detections]
|
||||
num_detections = self.max_detections
|
||||
kept_logits_quantized = scores_output_quantized_filtered[nms_indices]
|
||||
class_ids_post_nms = np.argmax(kept_logits_quantized, axis=1)
|
||||
|
||||
# Extract the final boxes and scores using fancy indexing
|
||||
final_boxes = boxes_filtered_decoded[nms_indices]
|
||||
final_scores_logits = (
|
||||
max_scores_filtered_shiftedpositive[nms_indices]
|
||||
- self.logit_shift_to_positive_values
|
||||
) # Unshifted logits
|
||||
|
||||
# Detections array format: [class_id, score, ymin, xmin, ymax, xmax]
|
||||
detections[:num_detections, 0] = class_ids_post_nms
|
||||
detections[:num_detections, 1] = 1.0 / (
|
||||
1.0 + np.exp(-final_scores_logits)
|
||||
) # sigmoid
|
||||
detections[:num_detections, 2] = final_boxes[:, 1] / self.model_height
|
||||
detections[:num_detections, 3] = final_boxes[:, 0] / self.model_width
|
||||
detections[:num_detections, 4] = final_boxes[:, 3] / self.model_height
|
||||
detections[:num_detections, 5] = final_boxes[:, 2] / self.model_width
|
||||
return detections
|
||||
|
||||
def _post_process_ssd(self):
|
||||
boxes = self.interpreter.tensor(self.tensor_output_details[0]["index"])()[0]
|
||||
class_ids = self.interpreter.tensor(self.tensor_output_details[1]["index"])()[0]
|
||||
scores = self.interpreter.tensor(self.tensor_output_details[2]["index"])()[0]
|
||||
count = int(
|
||||
self.interpreter.tensor(self.tensor_output_details[3]["index"])()[0]
|
||||
)
|
||||
|
||||
detections = np.zeros((self.max_detections, 6), np.float32)
|
||||
|
||||
for i in range(count):
|
||||
if scores[i] < self.min_score:
|
||||
break
|
||||
if i == self.max_detections:
|
||||
logger.debug("Too many detections (%s)", count)
|
||||
break
|
||||
detections[i] = [
|
||||
class_ids[i],
|
||||
float(scores[i]),
|
||||
boxes[i][0],
|
||||
boxes[i][1],
|
||||
boxes[i][2],
|
||||
boxes[i][3],
|
||||
]
|
||||
|
||||
return detections
|
||||
return detections
|
||||
|
||||
|
||||
def tflite_load_delegate_interpreter(
|
||||
|
||||
@@ -7,7 +7,7 @@ from frigate.detectors.detection_api import DetectionApi
|
||||
from frigate.detectors.detector_config import BaseDetectorConfig
|
||||
from frigate.log import suppress_stderr_during
|
||||
|
||||
from ..detector_utils import TFLiteDetectionModel
|
||||
from ..detector_utils import tflite_detect_raw, tflite_init
|
||||
|
||||
try:
|
||||
from tflite_runtime.interpreter import Interpreter
|
||||
@@ -40,7 +40,6 @@ class CpuDetectorConfig(BaseDetectorConfig):
|
||||
|
||||
class CpuTfl(DetectionApi):
|
||||
type_key = DETECTOR_KEY
|
||||
supported_models = TFLiteDetectionModel.supported_models
|
||||
|
||||
def __init__(self, detector_config: CpuDetectorConfig):
|
||||
# Suppress TFLite delegate creation messages that bypass Python logging
|
||||
@@ -50,9 +49,7 @@ class CpuTfl(DetectionApi):
|
||||
num_threads=detector_config.num_threads or 3,
|
||||
)
|
||||
|
||||
self.tflite_model = TFLiteDetectionModel(
|
||||
interpreter, detector_config.model, DETECTOR_KEY
|
||||
)
|
||||
tflite_init(self, interpreter)
|
||||
|
||||
def detect_raw(self, tensor_input):
|
||||
return self.tflite_model.detect_raw(tensor_input)
|
||||
return tflite_detect_raw(self, tensor_input)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from frigate.detectors.detection_api import DetectionApi
|
||||
from frigate.detectors.detector_config import BaseDetectorConfig
|
||||
|
||||
from ..detector_utils import TFLiteDetectionModel
|
||||
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
|
||||
@@ -39,7 +41,10 @@ class EdgeTpuDetectorConfig(BaseDetectorConfig):
|
||||
|
||||
class EdgeTpuTfl(DetectionApi):
|
||||
type_key = DETECTOR_KEY
|
||||
supported_models = TFLiteDetectionModel.supported_models
|
||||
supported_models = [
|
||||
ModelTypeEnum.ssd,
|
||||
ModelTypeEnum.yologeneric,
|
||||
]
|
||||
|
||||
def __init__(self, detector_config: EdgeTpuDetectorConfig):
|
||||
device_config = {}
|
||||
@@ -55,7 +60,7 @@ class EdgeTpuTfl(DetectionApi):
|
||||
logger.info(f"Attempting to load TPU as {device_type}")
|
||||
edge_tpu_delegate = load_delegate("libedgetpu.so.1.0", device_config)
|
||||
logger.info("TPU found")
|
||||
interpreter = Interpreter(
|
||||
self.interpreter = Interpreter(
|
||||
model_path=detector_config.model.path,
|
||||
experimental_delegates=[edge_tpu_delegate],
|
||||
)
|
||||
@@ -73,9 +78,298 @@ class EdgeTpuTfl(DetectionApi):
|
||||
|
||||
raise
|
||||
|
||||
self.tflite_model = TFLiteDetectionModel(
|
||||
interpreter, detector_config.model, DETECTOR_KEY
|
||||
)
|
||||
self.interpreter.allocate_tensors()
|
||||
|
||||
self.tensor_input_details = self.interpreter.get_input_details()
|
||||
self.tensor_output_details = self.interpreter.get_output_details()
|
||||
self.model_width = detector_config.model.width
|
||||
self.model_height = detector_config.model.height
|
||||
|
||||
self.min_score = 0.4
|
||||
self.max_detections = 20
|
||||
|
||||
self.model_type = detector_config.model.model_type
|
||||
self.model_requires_int8 = self.tensor_input_details[0]["dtype"] == np.int8
|
||||
|
||||
if self.model_type == ModelTypeEnum.yologeneric:
|
||||
logger.debug("Using YOLO preprocessing/postprocessing")
|
||||
|
||||
if len(self.tensor_output_details) not in [2, 3]:
|
||||
logger.error(
|
||||
f"Invalid count of output tensors in YOLO model. Found {len(self.tensor_output_details)}, expecting 2 or 3."
|
||||
)
|
||||
raise
|
||||
|
||||
self.reg_max = 16 # = 64 dfl_channels // 4 # YOLO standard
|
||||
self.min_logit_value = np.log(
|
||||
self.min_score / (1 - self.min_score)
|
||||
) # for filtering
|
||||
self._generate_anchors_and_strides() # decode bounding box DFL
|
||||
self.project = np.arange(
|
||||
self.reg_max, dtype=np.float32
|
||||
) # for decoding bounding box DFL information
|
||||
|
||||
# Determine YOLO tensor indices and quantization scales for
|
||||
# boxes and class_scores the tensor ordering and names are
|
||||
# not reliable, so use tensor shape to detect which tensor
|
||||
# holds boxes or class scores.
|
||||
# The tensors have shapes (B, N, C)
|
||||
# where N is the number of candidates (=2100 for 320x320)
|
||||
# this may guess wrong if the number of classes is exactly 64
|
||||
output_boxes_index = None
|
||||
output_classes_index = None
|
||||
for i, x in enumerate(self.tensor_output_details):
|
||||
# the nominal index seems to start at 1 instead of 0
|
||||
if len(x["shape"]) == 3 and x["shape"][2] == 64:
|
||||
output_boxes_index = i
|
||||
elif len(x["shape"]) == 3 and x["shape"][2] > 1:
|
||||
# require the number of classes to be more than 1
|
||||
# to differentiate from (not used) max score tensor
|
||||
output_classes_index = i
|
||||
if output_boxes_index is None or output_classes_index is None:
|
||||
logger.warning("Unrecognized model output, unexpected tensor shapes.")
|
||||
output_classes_index = (
|
||||
0
|
||||
if (output_boxes_index is None or output_classes_index == 1)
|
||||
else 1
|
||||
) # 0 is default guess
|
||||
output_boxes_index = 1 if (output_boxes_index == 0) else 0
|
||||
|
||||
scores_details = self.tensor_output_details[output_classes_index]
|
||||
self.scores_tensor_index = scores_details["index"]
|
||||
self.scores_scale, self.scores_zero_point = scores_details["quantization"]
|
||||
# calculate the quantized version of the min_score
|
||||
self.min_score_quantized = int(
|
||||
(self.min_logit_value / self.scores_scale) + self.scores_zero_point
|
||||
)
|
||||
self.logit_shift_to_positive_values = (
|
||||
max(0, math.ceil((128 + self.scores_zero_point) * self.scores_scale))
|
||||
+ 1
|
||||
) # round up
|
||||
|
||||
boxes_details = self.tensor_output_details[output_boxes_index]
|
||||
self.boxes_tensor_index = boxes_details["index"]
|
||||
self.boxes_scale, self.boxes_zero_point = boxes_details["quantization"]
|
||||
|
||||
elif self.model_type == ModelTypeEnum.ssd:
|
||||
logger.debug("Using SSD preprocessing/postprocessing")
|
||||
|
||||
# SSD model indices (4 outputs: boxes, class_ids, scores, count)
|
||||
for x in self.tensor_output_details:
|
||||
if len(x["shape"]) == 3:
|
||||
self.output_boxes_index = x["index"]
|
||||
elif len(x["shape"]) == 1:
|
||||
self.output_count_index = x["index"]
|
||||
|
||||
self.output_class_ids_index = None
|
||||
self.output_class_scores_index = None
|
||||
|
||||
else:
|
||||
raise Exception(
|
||||
f"{self.model_type} is currently not supported for edgetpu. See the docs for more info on supported models."
|
||||
)
|
||||
|
||||
def _generate_anchors_and_strides(self):
|
||||
# for decoding the bounding box DFL information into xy coordinates
|
||||
all_anchors = []
|
||||
all_strides = []
|
||||
strides = (8, 16, 32) # YOLO's small, medium, large detection heads
|
||||
|
||||
for stride in strides:
|
||||
feat_h, feat_w = self.model_height // stride, self.model_width // stride
|
||||
|
||||
grid_y, grid_x = np.meshgrid(
|
||||
np.arange(feat_h, dtype=np.float32),
|
||||
np.arange(feat_w, dtype=np.float32),
|
||||
indexing="ij",
|
||||
)
|
||||
|
||||
grid_coords = np.stack((grid_x.flatten(), grid_y.flatten()), axis=1)
|
||||
anchor_points = grid_coords + 0.5
|
||||
|
||||
all_anchors.append(anchor_points)
|
||||
all_strides.append(np.full((feat_h * feat_w, 1), stride, dtype=np.float32))
|
||||
|
||||
self.anchors = np.concatenate(all_anchors, axis=0)
|
||||
self.anchor_strides = np.concatenate(all_strides, axis=0)
|
||||
|
||||
def determine_indexes_for_non_yolo_models(self):
|
||||
"""Legacy method for SSD models."""
|
||||
if (
|
||||
self.output_class_ids_index is None
|
||||
or self.output_class_scores_index is None
|
||||
):
|
||||
for i in range(4):
|
||||
index = self.tensor_output_details[i]["index"]
|
||||
if (
|
||||
index != self.output_boxes_index
|
||||
and index != self.output_count_index
|
||||
):
|
||||
if (
|
||||
np.mod(np.float32(self.interpreter.tensor(index)()[0][0]), 1)
|
||||
== 0.0
|
||||
):
|
||||
self.output_class_ids_index = index
|
||||
else:
|
||||
self.output_scores_index = index
|
||||
|
||||
def pre_process(self, tensor_input):
|
||||
if self.model_requires_int8:
|
||||
tensor_input = np.bitwise_xor(tensor_input, 128).view(
|
||||
np.int8
|
||||
) # shift by -128
|
||||
return tensor_input
|
||||
|
||||
def detect_raw(self, tensor_input):
|
||||
return self.tflite_model.detect_raw(tensor_input)
|
||||
tensor_input = self.pre_process(tensor_input)
|
||||
|
||||
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
|
||||
self.interpreter.invoke()
|
||||
|
||||
if self.model_type == ModelTypeEnum.yologeneric:
|
||||
# Multi-tensor YOLO model with (non-standard B(H*W)C output format).
|
||||
# (the comments indicate the shape of tensors,
|
||||
# using "2100" as the anchor count (for image size of 320x320),
|
||||
# "NC" as number of classes,
|
||||
# "N" as the count that survive after min-score filtering)
|
||||
# TENSOR A) class scores (1, 2100, NC) with logit values
|
||||
# TENSOR B) box coordinates (1, 2100, 64) encoded as dfl scores
|
||||
# Recommend that the model clamp the logit values in tensor (A)
|
||||
# to the range [-4,+4] to preserve precision from [2%,98%]
|
||||
# and because NMS requires the min_score parameter to be >= 0
|
||||
|
||||
# don't dequantize scores data yet, wait until the low-confidence
|
||||
# candidates are filtered out from the overall result set.
|
||||
# This reduces the work and makes post-processing faster.
|
||||
# this method works with raw quantized numbers when possible,
|
||||
# which relies on the value of the scale factor to be >0.
|
||||
# This speeds up max and argmax operations.
|
||||
# Get max confidence for each detection and create the mask
|
||||
detections = np.zeros(
|
||||
(self.max_detections, 6), np.float32
|
||||
) # initialize zero results
|
||||
scores_output_quantized = self.interpreter.get_tensor(
|
||||
self.scores_tensor_index
|
||||
)[0] # (2100, NC)
|
||||
max_scores_quantized = np.max(scores_output_quantized, axis=1) # (2100,)
|
||||
mask = max_scores_quantized >= self.min_score_quantized # (2100,)
|
||||
|
||||
if not np.any(mask):
|
||||
return detections # empty results
|
||||
|
||||
max_scores_filtered_shiftedpositive = (
|
||||
(max_scores_quantized[mask] - self.scores_zero_point)
|
||||
* self.scores_scale
|
||||
) + self.logit_shift_to_positive_values # (N,1) shifted logit values
|
||||
scores_output_quantized_filtered = scores_output_quantized[mask]
|
||||
|
||||
# dequantize boxes. NMS needs them to be in float format
|
||||
# remove candidates with probabilities < threshold
|
||||
boxes_output_quantized_filtered = (
|
||||
self.interpreter.get_tensor(self.boxes_tensor_index)[0]
|
||||
)[mask] # (N, 64)
|
||||
boxes_output_filtered = (
|
||||
boxes_output_quantized_filtered.astype(np.float32)
|
||||
- self.boxes_zero_point
|
||||
) * self.boxes_scale
|
||||
|
||||
# 2. Decode DFL to distances (ltrb)
|
||||
dfl_distributions = boxes_output_filtered.reshape(
|
||||
-1, 4, self.reg_max
|
||||
) # (N, 4, 16)
|
||||
|
||||
# Softmax over the 16 bins
|
||||
dfl_max = np.max(dfl_distributions, axis=2, keepdims=True)
|
||||
dfl_exp = np.exp(dfl_distributions - dfl_max)
|
||||
dfl_probs = dfl_exp / np.sum(dfl_exp, axis=2, keepdims=True) # (N, 4, 16)
|
||||
|
||||
# Weighted sum: (N, 4, 16) * (16,) -> (N, 4)
|
||||
distances = np.einsum("pcr,r->pc", dfl_probs, self.project)
|
||||
|
||||
# Calculate box corners in pixel coordinates
|
||||
anchors_filtered = self.anchors[mask]
|
||||
anchor_strides_filtered = self.anchor_strides[mask]
|
||||
x1y1 = (
|
||||
anchors_filtered - distances[:, [0, 1]]
|
||||
) * anchor_strides_filtered # (N, 2)
|
||||
x2y2 = (
|
||||
anchors_filtered + distances[:, [2, 3]]
|
||||
) * anchor_strides_filtered # (N, 2)
|
||||
boxes_filtered_decoded = np.concatenate((x1y1, x2y2), axis=-1) # (N, 4)
|
||||
|
||||
# 9. Apply NMS. Use logit scores here to defer sigmoid()
|
||||
# until after filtering out redundant boxes
|
||||
# Shift the logit scores to be non-negative (required by cv2)
|
||||
indices = cv2.dnn.NMSBoxes(
|
||||
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
|
||||
),
|
||||
nms_threshold=0.4, # should this be a model config setting?
|
||||
)
|
||||
num_detections = len(indices)
|
||||
if num_detections == 0:
|
||||
return detections # empty results
|
||||
|
||||
nms_indices = np.array(indices, dtype=np.int32).ravel() # or .flatten()
|
||||
if num_detections > self.max_detections:
|
||||
nms_indices = nms_indices[: self.max_detections]
|
||||
num_detections = self.max_detections
|
||||
kept_logits_quantized = scores_output_quantized_filtered[nms_indices]
|
||||
class_ids_post_nms = np.argmax(kept_logits_quantized, axis=1)
|
||||
|
||||
# Extract the final boxes and scores using fancy indexing
|
||||
final_boxes = boxes_filtered_decoded[nms_indices]
|
||||
final_scores_logits = (
|
||||
max_scores_filtered_shiftedpositive[nms_indices]
|
||||
- self.logit_shift_to_positive_values
|
||||
) # Unshifted logits
|
||||
|
||||
# Detections array format: [class_id, score, ymin, xmin, ymax, xmax]
|
||||
detections[:num_detections, 0] = class_ids_post_nms
|
||||
detections[:num_detections, 1] = 1.0 / (
|
||||
1.0 + np.exp(-final_scores_logits)
|
||||
) # sigmoid
|
||||
detections[:num_detections, 2] = final_boxes[:, 1] / self.model_height
|
||||
detections[:num_detections, 3] = final_boxes[:, 0] / self.model_width
|
||||
detections[:num_detections, 4] = final_boxes[:, 3] / self.model_height
|
||||
detections[:num_detections, 5] = final_boxes[:, 2] / self.model_width
|
||||
return detections
|
||||
|
||||
elif self.model_type == ModelTypeEnum.ssd:
|
||||
self.determine_indexes_for_non_yolo_models()
|
||||
boxes = self.interpreter.tensor(self.tensor_output_details[0]["index"])()[0]
|
||||
class_ids = self.interpreter.tensor(
|
||||
self.tensor_output_details[1]["index"]
|
||||
)()[0]
|
||||
scores = self.interpreter.tensor(self.tensor_output_details[2]["index"])()[
|
||||
0
|
||||
]
|
||||
count = int(
|
||||
self.interpreter.tensor(self.tensor_output_details[3]["index"])()[0]
|
||||
)
|
||||
|
||||
detections = np.zeros((self.max_detections, 6), np.float32)
|
||||
|
||||
for i in range(count):
|
||||
if scores[i] < self.min_score:
|
||||
break
|
||||
if i == self.max_detections:
|
||||
logger.debug(f"Too many detections ({count})!")
|
||||
break
|
||||
detections[i] = [
|
||||
class_ids[i],
|
||||
float(scores[i]),
|
||||
boxes[i][0],
|
||||
boxes[i][1],
|
||||
boxes[i][2],
|
||||
boxes[i][3],
|
||||
]
|
||||
|
||||
return detections
|
||||
|
||||
else:
|
||||
raise Exception(
|
||||
f"{self.model_type} is currently not supported for edgetpu. See the docs for more info on supported models."
|
||||
)
|
||||
|
||||
@@ -7,7 +7,8 @@ from frigate.detectors.detection_api import DetectionApi
|
||||
from frigate.detectors.detector_config import BaseDetectorConfig
|
||||
|
||||
from ..detector_utils import (
|
||||
TFLiteDetectionModel,
|
||||
tflite_detect_raw,
|
||||
tflite_init,
|
||||
tflite_load_delegate_interpreter,
|
||||
)
|
||||
|
||||
@@ -29,7 +30,6 @@ class TeflonDetectorConfig(BaseDetectorConfig):
|
||||
|
||||
class TeflonTfl(DetectionApi):
|
||||
type_key = DETECTOR_KEY
|
||||
supported_models = TFLiteDetectionModel.supported_models
|
||||
|
||||
def __init__(self, detector_config: TeflonDetectorConfig):
|
||||
# Location in Debian's mesa-teflon-delegate
|
||||
@@ -39,9 +39,7 @@ class TeflonTfl(DetectionApi):
|
||||
interpreter = tflite_load_delegate_interpreter(
|
||||
delegate_library, detector_config, device_config
|
||||
)
|
||||
self.tflite_model = TFLiteDetectionModel(
|
||||
interpreter, detector_config.model, DETECTOR_KEY
|
||||
)
|
||||
tflite_init(self, interpreter)
|
||||
|
||||
def detect_raw(self, tensor_input):
|
||||
return self.tflite_model.detect_raw(tensor_input)
|
||||
return tflite_detect_raw(self, tensor_input)
|
||||
|
||||
@@ -93,7 +93,6 @@ class EventCleanup(threading.Thread):
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
Event.has_snapshot == True,
|
||||
)
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
@@ -115,7 +114,6 @@ class EventCleanup(threading.Thread):
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
Event.has_snapshot == True,
|
||||
)
|
||||
|
||||
events_to_update: list[str] = []
|
||||
@@ -171,7 +169,6 @@ class EventCleanup(threading.Thread):
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
Event.has_snapshot == True,
|
||||
)
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
import re
|
||||
import time
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
@@ -33,6 +34,7 @@ __all__ = [
|
||||
"GenAIClientManager",
|
||||
"GenAIConfig",
|
||||
"GenAIProviderEnum",
|
||||
"GenAIResponse",
|
||||
"PROVIDERS",
|
||||
"load_providers",
|
||||
"register_genai_provider",
|
||||
@@ -41,6 +43,18 @@ __all__ = [
|
||||
PROVIDERS: dict[GenAIProviderEnum, type["GenAIClient"]] = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenAIResponse:
|
||||
"""A provider's answer to a single request.
|
||||
|
||||
The reasoning trace is kept apart from the content so callers can save it
|
||||
for debugging without it ending up in the parsed answer.
|
||||
"""
|
||||
|
||||
content: str
|
||||
reasoning: str | None = None
|
||||
|
||||
|
||||
def register_genai_provider(key: GenAIProviderEnum) -> Callable:
|
||||
"""Register a GenAI provider."""
|
||||
|
||||
@@ -109,11 +123,14 @@ class GenAIClient:
|
||||
activity_context_prompt: str,
|
||||
response_style: str = "default",
|
||||
frame_captions: list[str] | None = None,
|
||||
enable_thinking: bool = False,
|
||||
) -> ReviewMetadata | None:
|
||||
"""Generate a description for the review item activity.
|
||||
|
||||
`frame_captions` holds one caption per thumbnail for the annotated
|
||||
frame mode; each is sent directly before its frame.
|
||||
frame mode; each is sent directly before its frame. `enable_thinking`
|
||||
lets the model reason before answering, when the provider exposes a
|
||||
per-request toggle.
|
||||
"""
|
||||
if frame_captions and len(frame_captions) != len(thumbnails):
|
||||
logger.warning(
|
||||
@@ -137,6 +154,13 @@ class GenAIClient:
|
||||
f"Sending {len(thumbnails)} images to create review description on {review_data['camera']}"
|
||||
)
|
||||
|
||||
if enable_thinking and not self.supports_toggleable_thinking:
|
||||
logger.debug(
|
||||
"Thinking requested for review description but model %s does not "
|
||||
"expose a per-request thinking toggle, using its default behavior",
|
||||
self.genai_config.model,
|
||||
)
|
||||
|
||||
if debug_save:
|
||||
with open(
|
||||
os.path.join(
|
||||
@@ -164,12 +188,14 @@ class GenAIClient:
|
||||
|
||||
response_format = build_review_description_response_format(concerns)
|
||||
|
||||
response = self._send(
|
||||
result = self._send(
|
||||
context_prompt,
|
||||
thumbnails,
|
||||
response_format,
|
||||
enable_thinking=enable_thinking,
|
||||
image_captions=frame_captions,
|
||||
)
|
||||
response = result.content if result else None
|
||||
|
||||
if debug_save and response:
|
||||
with open(
|
||||
@@ -180,6 +206,17 @@ class GenAIClient:
|
||||
) as f:
|
||||
f.write(response)
|
||||
|
||||
# Saved even when the content is blank, since a model that spends its
|
||||
# whole output budget thinking returns a trace but no answer.
|
||||
if debug_save and result and result.reasoning:
|
||||
with open(
|
||||
os.path.join(
|
||||
CLIPS_DIR, "genai-requests", review_data["id"], "reasoning.txt"
|
||||
),
|
||||
"w",
|
||||
) as f:
|
||||
f.write(result.reasoning)
|
||||
|
||||
if response:
|
||||
clean_json = re.sub(
|
||||
r"\n?```$", "", re.sub(r"^```[a-zA-Z0-9]*\n?", "", response)
|
||||
@@ -268,7 +305,8 @@ class GenAIClient:
|
||||
) as f:
|
||||
f.write(timeline_summary_prompt)
|
||||
|
||||
response = self._send(timeline_summary_prompt, [])
|
||||
result = self._send(timeline_summary_prompt, [])
|
||||
response = result.content if result else None
|
||||
|
||||
if debug_save and response:
|
||||
with open(
|
||||
@@ -295,7 +333,8 @@ class GenAIClient:
|
||||
return None
|
||||
|
||||
logger.debug(f"Sending images to genai provider with prompt: {prompt}")
|
||||
return self._send(prompt, thumbnails)
|
||||
result = self._send(prompt, thumbnails)
|
||||
return result.content if result else None
|
||||
|
||||
def _init_provider(self) -> Any:
|
||||
"""Initialize the client."""
|
||||
@@ -308,13 +347,16 @@ class GenAIClient:
|
||||
response_format: dict | None = None,
|
||||
enable_thinking: bool = False,
|
||||
image_captions: list[str] | None = None,
|
||||
) -> str | None:
|
||||
) -> GenAIResponse | None:
|
||||
"""Submit a request to the provider.
|
||||
|
||||
``enable_thinking`` is honored only by providers that report
|
||||
``supports_toggleable_thinking``. Description-style callers leave it
|
||||
at the default (off) since synthesis tasks don't benefit from
|
||||
reasoning traces.
|
||||
at the default (off) unless the user opts in, since most models write
|
||||
better descriptions without spending tokens on a reasoning trace.
|
||||
|
||||
Returns None when the request fails. Providers that surface a
|
||||
reasoning trace separately return it in ``reasoning``.
|
||||
|
||||
``image_captions`` carries one caption per image, to be placed
|
||||
immediately before its image so the model can tell the frames apart.
|
||||
|
||||
@@ -7,8 +7,7 @@ overrides what is genuinely Azure-specific:
|
||||
|
||||
- Client construction: parses ``api-version`` out of the configured
|
||||
``base_url`` query string and instantiates :class:`openai.AzureOpenAI`
|
||||
(or :class:`openai.AsyncAzureOpenAI` for streaming chat) with
|
||||
``azure_endpoint`` instead of ``base_url``. Raises if the URL is
|
||||
with ``azure_endpoint`` instead of ``base_url``. Raises if the URL is
|
||||
malformed; :class:`GenAIClientManager` catches the exception and
|
||||
disables the provider.
|
||||
- Context size: Azure does not expose a per-model ``max_model_len`` field
|
||||
@@ -30,10 +29,9 @@ the ``transcribe`` role:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from openai import AsyncAzureOpenAI, AzureOpenAI
|
||||
from openai import AzureOpenAI
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import register_genai_provider
|
||||
@@ -46,8 +44,8 @@ logger = logging.getLogger(__name__)
|
||||
class AzureOpenAIClient(OpenAIClient):
|
||||
"""Generative AI client for Frigate using Azure OpenAI."""
|
||||
|
||||
def _client_options(self) -> dict[str, Any]:
|
||||
"""Build the Azure client options from the configured base_url."""
|
||||
def _init_provider(self) -> AzureOpenAI:
|
||||
"""Initialize the AzureOpenAI client from the configured base_url."""
|
||||
parsed_url = urlparse(self.genai_config.base_url or "")
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
api_version = query_params.get("api-version", [None])[0]
|
||||
@@ -55,19 +53,13 @@ class AzureOpenAIClient(OpenAIClient):
|
||||
if not api_version:
|
||||
raise ValueError("Azure OpenAI base_url is missing api-version.")
|
||||
|
||||
return {
|
||||
"api_key": self.genai_config.api_key,
|
||||
"api_version": api_version,
|
||||
"azure_endpoint": f"{parsed_url.scheme}://{parsed_url.netloc}/",
|
||||
}
|
||||
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
|
||||
|
||||
def _init_provider(self) -> AzureOpenAI:
|
||||
"""Initialize the AzureOpenAI client from the configured base_url."""
|
||||
return AzureOpenAI(**self._client_options())
|
||||
|
||||
def _init_async_provider(self) -> AsyncAzureOpenAI:
|
||||
"""Create an async Azure client for streaming chat."""
|
||||
return AsyncAzureOpenAI(**self._client_options())
|
||||
return AzureOpenAI(
|
||||
api_key=self.genai_config.api_key,
|
||||
api_version=api_version,
|
||||
azure_endpoint=azure_endpoint,
|
||||
)
|
||||
|
||||
def get_context_size(self) -> int:
|
||||
"""Azure does not reliably surface per-model context size; use 128K."""
|
||||
|
||||
@@ -2,20 +2,17 @@
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from google import genai
|
||||
from google.genai import errors, types
|
||||
from google.genai.types import FunctionCallingConfigMode
|
||||
from PIL import Image
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
from frigate.genai import GenAIClient, GenAIResponse, register_genai_provider
|
||||
from frigate.genai.utils import interleave_images
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,11 +21,6 @@ logger = logging.getLogger(__name__)
|
||||
# under it so the request fails as a log line rather than a 400.
|
||||
GEMINI_MAX_INLINE_BYTES = 15 * 1024 * 1024
|
||||
|
||||
# Image-token probes run on the enrichment thread, so they get a short timeout,
|
||||
# no retries, and a cooldown after failure instead of the client's defaults.
|
||||
COUNT_TOKENS_TIMEOUT_MS = 5000
|
||||
COUNT_TOKENS_RETRY_INTERVAL = 300
|
||||
|
||||
|
||||
def _decode_thought_signature(value: Any) -> bytes | None:
|
||||
"""Decode a base64-encoded thought_signature carried across conversation turns."""
|
||||
@@ -101,14 +93,9 @@ class GeminiClient(GenAIClient):
|
||||
"""Generative AI client for Frigate using Gemini."""
|
||||
|
||||
provider: genai.Client
|
||||
_image_token_cache: dict[tuple[int, int], int]
|
||||
_image_token_retry_at: float
|
||||
|
||||
def _init_provider(self) -> genai.Client:
|
||||
"""Initialize the client."""
|
||||
self._image_token_cache = {}
|
||||
self._image_token_retry_at = 0.0
|
||||
|
||||
# Merge provider_options into HttpOptions
|
||||
http_options_dict: dict[str, Any] = {
|
||||
"timeout": int(self.timeout * 1000), # requires milliseconds
|
||||
@@ -137,8 +124,13 @@ class GeminiClient(GenAIClient):
|
||||
response_format: dict | None = None,
|
||||
enable_thinking: bool = False,
|
||||
image_captions: list[str] | None = None,
|
||||
) -> str | None:
|
||||
"""Submit a request to Gemini."""
|
||||
) -> GenAIResponse | None:
|
||||
"""Submit a request to Gemini.
|
||||
|
||||
Thinking is configured at the model level for Gemini (or through
|
||||
``thinking_config`` in ``runtime_options``), so ``enable_thinking`` is
|
||||
ignored here.
|
||||
"""
|
||||
contents: list[Any] = [
|
||||
part
|
||||
if isinstance(part, str)
|
||||
@@ -178,7 +170,7 @@ class GeminiClient(GenAIClient):
|
||||
except (ValueError, AttributeError):
|
||||
# No description was generated
|
||||
return None
|
||||
return description
|
||||
return GenAIResponse(description)
|
||||
|
||||
@property
|
||||
def supports_transcription(self) -> bool:
|
||||
@@ -245,71 +237,6 @@ class GeminiClient(GenAIClient):
|
||||
# Gemini Pro Vision has a 1M token context window
|
||||
return 1000000
|
||||
|
||||
def estimate_image_tokens(self, width: int, height: int) -> float:
|
||||
"""Ask Gemini how many prompt tokens an image of these dimensions costs.
|
||||
|
||||
Gemini bills images far above the base pixel heuristic (Gemini 3 models
|
||||
charge ~1100 tokens for a 320x180 frame, not ~46), and the cost differs
|
||||
between model generations, so it is measured with the free count_tokens
|
||||
endpoint and cached per (width, height). Falls back to the base heuristic
|
||||
if the request fails, and does not probe again for
|
||||
COUNT_TOKENS_RETRY_INTERVAL seconds.
|
||||
"""
|
||||
cached = self._image_token_cache.get((width, height))
|
||||
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
if time.monotonic() < self._image_token_retry_at:
|
||||
return super().estimate_image_tokens(width, height)
|
||||
|
||||
img = Image.new("RGB", (width, height), (128, 128, 128))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=60)
|
||||
image = types.Part.from_bytes(data=buf.getvalue(), mime_type="image/jpeg")
|
||||
|
||||
try:
|
||||
baseline = self._count_tokens(["."])
|
||||
with_image = self._count_tokens([".", image])
|
||||
tokens = max(1, with_image - baseline)
|
||||
except Exception as e:
|
||||
self._image_token_retry_at = time.monotonic() + COUNT_TOKENS_RETRY_INTERVAL
|
||||
logger.debug(
|
||||
"Gemini image-token count failed for %dx%d (%s); using heuristic",
|
||||
width,
|
||||
height,
|
||||
e,
|
||||
)
|
||||
return super().estimate_image_tokens(width, height)
|
||||
|
||||
self._image_token_cache[(width, height)] = tokens
|
||||
logger.debug(
|
||||
"Gemini model '%s' uses %d tokens for %dx%d images",
|
||||
self.genai_config.model,
|
||||
tokens,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
return tokens
|
||||
|
||||
def _count_tokens(self, contents: list[Any]) -> int:
|
||||
"""Return Gemini's prompt-token count for contents, without generating."""
|
||||
total = self.provider.models.count_tokens(
|
||||
model=self.genai_config.model,
|
||||
contents=contents,
|
||||
config=types.CountTokensConfig(
|
||||
http_options=types.HttpOptions(
|
||||
timeout=COUNT_TOKENS_TIMEOUT_MS,
|
||||
retry_options=types.HttpRetryOptions(attempts=1),
|
||||
)
|
||||
),
|
||||
).total_tokens
|
||||
|
||||
if total is None:
|
||||
raise ValueError("count_tokens returned no total")
|
||||
|
||||
return total
|
||||
|
||||
def chat_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
@@ -464,7 +391,7 @@ class GeminiClient(GenAIClient):
|
||||
|
||||
response = self.provider.models.generate_content(
|
||||
model=self.genai_config.model,
|
||||
contents=gemini_messages,
|
||||
contents=gemini_messages, # type: ignore[arg-type]
|
||||
config=types.GenerateContentConfig(**config_params),
|
||||
)
|
||||
|
||||
@@ -726,7 +653,7 @@ class GeminiClient(GenAIClient):
|
||||
|
||||
stream = await self.provider.aio.models.generate_content_stream(
|
||||
model=self.genai_config.model,
|
||||
contents=gemini_messages,
|
||||
contents=gemini_messages, # type: ignore[arg-type]
|
||||
config=types.GenerateContentConfig(**config_params),
|
||||
)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import numpy as np
|
||||
import requests
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
from frigate.genai import GenAIClient, GenAIResponse, register_genai_provider
|
||||
from frigate.genai.utils import (
|
||||
interleave_images,
|
||||
parse_tool_calls_from_message,
|
||||
@@ -304,7 +304,7 @@ class LlamaCppClient(GenAIClient):
|
||||
response_format: dict | None = None,
|
||||
enable_thinking: bool = False,
|
||||
image_captions: list[str] | None = None,
|
||||
) -> str | None:
|
||||
) -> GenAIResponse | None:
|
||||
"""Submit a request to llama.cpp server."""
|
||||
if self.provider is None:
|
||||
logger.warning(
|
||||
@@ -362,7 +362,8 @@ class LlamaCppClient(GenAIClient):
|
||||
):
|
||||
choice = result["choices"][0]
|
||||
if "message" in choice and "content" in choice["message"]:
|
||||
return str(choice["message"]["content"].strip())
|
||||
message = self._message_from_choice(choice)
|
||||
return GenAIResponse(message["content"] or "", message["reasoning"])
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("llama.cpp returned an error: %s", str(e))
|
||||
|
||||
@@ -14,7 +14,7 @@ from ollama import Client as ApiClient
|
||||
from ollama import ResponseError
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
from frigate.genai import GenAIClient, GenAIResponse, register_genai_provider
|
||||
from frigate.genai.utils import (
|
||||
interleave_images,
|
||||
parse_tool_calls_from_message,
|
||||
@@ -243,7 +243,7 @@ class OllamaClient(GenAIClient):
|
||||
response_format: dict | None = None,
|
||||
enable_thinking: bool = False,
|
||||
image_captions: list[str] | None = None,
|
||||
) -> str | None:
|
||||
) -> GenAIResponse | None:
|
||||
"""Submit a request to Ollama through the chat API, the same path the
|
||||
tool-calling chat uses, with image placeholders keeping any captions
|
||||
next to their frames."""
|
||||
@@ -299,7 +299,8 @@ class OllamaClient(GenAIClient):
|
||||
response.get("eval_count"),
|
||||
response.get("prompt_eval_count"),
|
||||
)
|
||||
response_text = self._message_from_response(response)["content"] or ""
|
||||
message_result = self._message_from_response(response)
|
||||
response_text = message_result["content"] or ""
|
||||
|
||||
if not response_text:
|
||||
logger.warning(
|
||||
@@ -310,7 +311,7 @@ class OllamaClient(GenAIClient):
|
||||
response.get("eval_count"),
|
||||
)
|
||||
|
||||
return response_text
|
||||
return GenAIResponse(response_text, message_result["reasoning"])
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
"""Return available model names from the Ollama server."""
|
||||
|
||||
@@ -7,15 +7,14 @@ from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from httpx import TimeoutException
|
||||
from openai import AsyncOpenAI, OpenAI
|
||||
from openai import OpenAI
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
from frigate.genai import GenAIClient, GenAIResponse, register_genai_provider
|
||||
from frigate.genai.utils import interleave_images
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# gpt-transcribe replaced the singular `language` field with a `languages` array
|
||||
# and rejects a request that sends both. Older transcription models
|
||||
# (gpt-4o-transcribe, gpt-4o-mini-transcribe, whisper-1) still take the singular
|
||||
@@ -46,8 +45,8 @@ class OpenAIClient(GenAIClient):
|
||||
provider: OpenAI
|
||||
context_size: int | None = None
|
||||
|
||||
def _client_options(self) -> dict[str, Any]:
|
||||
"""Build the constructor options shared by the sync and async clients.
|
||||
def _init_provider(self) -> OpenAI:
|
||||
"""Initialize the client.
|
||||
|
||||
Subclasses (e.g. Azure) should raise on configuration errors; the
|
||||
manager catches construction failures and disables the provider.
|
||||
@@ -63,19 +62,7 @@ class OpenAIClient(GenAIClient):
|
||||
if self.genai_config.base_url:
|
||||
provider_opts["base_url"] = self.genai_config.base_url
|
||||
|
||||
return {"api_key": self.genai_config.api_key, **provider_opts}
|
||||
|
||||
def _init_provider(self) -> OpenAI:
|
||||
"""Initialize the client."""
|
||||
return OpenAI(**self._client_options())
|
||||
|
||||
def _init_async_provider(self) -> AsyncOpenAI:
|
||||
"""Create an async client for streaming chat.
|
||||
|
||||
A new client is created per stream because its connection pool binds
|
||||
to the event loop that first uses it.
|
||||
"""
|
||||
return AsyncOpenAI(**self._client_options())
|
||||
return OpenAI(api_key=self.genai_config.api_key, **provider_opts)
|
||||
|
||||
def _send(
|
||||
self,
|
||||
@@ -84,8 +71,14 @@ class OpenAIClient(GenAIClient):
|
||||
response_format: dict | None = None,
|
||||
enable_thinking: bool = False,
|
||||
image_captions: list[str] | None = None,
|
||||
) -> str | None:
|
||||
"""Submit a request to OpenAI."""
|
||||
) -> GenAIResponse | None:
|
||||
"""Submit a request to OpenAI.
|
||||
|
||||
The chat completions API has no per-request thinking toggle, so
|
||||
``enable_thinking`` is ignored here. Reasoning is controlled through
|
||||
``runtime_options`` (e.g. ``reasoning_effort``), and any trace the
|
||||
server returns separately is passed back as ``reasoning``.
|
||||
"""
|
||||
messages_content: list[dict] = []
|
||||
for part in interleave_images(prompt, images, image_captions):
|
||||
if isinstance(part, str):
|
||||
@@ -133,6 +126,10 @@ class OpenAIClient(GenAIClient):
|
||||
):
|
||||
message = result.choices[0].message
|
||||
content = message.content
|
||||
raw_reasoning = getattr(message, "reasoning_content", None) or getattr(
|
||||
message, "reasoning", None
|
||||
)
|
||||
reasoning = raw_reasoning.strip() if raw_reasoning else None
|
||||
|
||||
if not content:
|
||||
# When reasoning is enabled for some OpenAI backends the actual response
|
||||
@@ -147,7 +144,9 @@ class OpenAIClient(GenAIClient):
|
||||
)
|
||||
content = reasoning_content
|
||||
|
||||
return str(content.strip()) if content else None
|
||||
return (
|
||||
GenAIResponse(str(content.strip()), reasoning) if content else None
|
||||
)
|
||||
return None
|
||||
except (TimeoutException, Exception) as e:
|
||||
logger.warning("OpenAI returned an error: %s", str(e))
|
||||
@@ -425,61 +424,57 @@ class OpenAIClient(GenAIClient):
|
||||
finish_reason = "stop"
|
||||
usage_stats: dict[str, Any] | None = None
|
||||
|
||||
# Exiting these blocks, including when the caller disconnects and
|
||||
# the generator is cancelled, closes the response and its connection
|
||||
async with (
|
||||
self._init_async_provider() as client,
|
||||
await client.chat.completions.create(**request_params) as stream,
|
||||
):
|
||||
async for chunk in stream:
|
||||
chunk_usage = getattr(chunk, "usage", None)
|
||||
if chunk_usage is not None:
|
||||
usage_stats = _stats_from_openai_usage(chunk_usage)
|
||||
stream = self.provider.chat.completions.create(**request_params)
|
||||
|
||||
if not chunk or not chunk.choices:
|
||||
continue
|
||||
for chunk in stream:
|
||||
chunk_usage = getattr(chunk, "usage", None)
|
||||
if chunk_usage is not None:
|
||||
usage_stats = _stats_from_openai_usage(chunk_usage)
|
||||
|
||||
choice = chunk.choices[0]
|
||||
delta = choice.delta
|
||||
if not chunk or not chunk.choices:
|
||||
continue
|
||||
|
||||
# Check for finish reason
|
||||
if choice.finish_reason:
|
||||
finish_reason = choice.finish_reason
|
||||
choice = chunk.choices[0]
|
||||
delta = choice.delta
|
||||
|
||||
# Extract reasoning deltas (reasoning_content or reasoning,
|
||||
# depending on the server)
|
||||
reasoning_delta = getattr(
|
||||
delta, "reasoning_content", None
|
||||
) or getattr(delta, "reasoning", None)
|
||||
if reasoning_delta:
|
||||
reasoning_parts.append(reasoning_delta)
|
||||
yield ("reasoning_delta", reasoning_delta)
|
||||
# Check for finish reason
|
||||
if choice.finish_reason:
|
||||
finish_reason = choice.finish_reason
|
||||
|
||||
# Extract content deltas
|
||||
if delta.content:
|
||||
content_parts.append(delta.content)
|
||||
yield ("content_delta", delta.content)
|
||||
# Extract reasoning deltas (reasoning_content or reasoning,
|
||||
# depending on the server)
|
||||
reasoning_delta = getattr(delta, "reasoning_content", None) or getattr(
|
||||
delta, "reasoning", None
|
||||
)
|
||||
if reasoning_delta:
|
||||
reasoning_parts.append(reasoning_delta)
|
||||
yield ("reasoning_delta", reasoning_delta)
|
||||
|
||||
# Extract tool calls
|
||||
if delta.tool_calls:
|
||||
for tc in delta.tool_calls:
|
||||
idx = tc.index
|
||||
fn = tc.function
|
||||
# Extract content deltas
|
||||
if delta.content:
|
||||
content_parts.append(delta.content)
|
||||
yield ("content_delta", delta.content)
|
||||
|
||||
if idx not in tool_calls_by_index:
|
||||
tool_calls_by_index[idx] = {
|
||||
"id": tc.id or "",
|
||||
"name": fn.name if fn and fn.name else "",
|
||||
"arguments": "",
|
||||
}
|
||||
# Extract tool calls
|
||||
if delta.tool_calls:
|
||||
for tc in delta.tool_calls:
|
||||
idx = tc.index
|
||||
fn = tc.function
|
||||
|
||||
t = tool_calls_by_index[idx]
|
||||
if tc.id:
|
||||
t["id"] = tc.id
|
||||
if fn and fn.name:
|
||||
t["name"] = fn.name
|
||||
if fn and fn.arguments:
|
||||
t["arguments"] += fn.arguments
|
||||
if idx not in tool_calls_by_index:
|
||||
tool_calls_by_index[idx] = {
|
||||
"id": tc.id or "",
|
||||
"name": fn.name if fn and fn.name else "",
|
||||
"arguments": "",
|
||||
}
|
||||
|
||||
t = tool_calls_by_index[idx]
|
||||
if tc.id:
|
||||
t["id"] = tc.id
|
||||
if fn and fn.name:
|
||||
t["name"] = fn.name
|
||||
if fn and fn.arguments:
|
||||
t["arguments"] += fn.arguments
|
||||
|
||||
# Build final message
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
|
||||
+17
-26
@@ -18,17 +18,11 @@ from frigate.models import Event
|
||||
|
||||
# Base guidance per response field. `observations` is a reasoning scaffold,
|
||||
# not user-facing, so style presets never override it.
|
||||
#
|
||||
# Every scene and shortSummary guidance states its length. The response
|
||||
# schema caps both fields, and grammar-constrained providers such as llama.cpp
|
||||
# enforce the cap by cutting the text off, so a model that is never told the
|
||||
# limit ends mid-sentence. Every user-facing field also repeats the name rule:
|
||||
# models follow per-field guidance far more reliably than a single global rule.
|
||||
REVIEW_DESCRIPTION_FIELD_GUIDELINES: dict[str, str] = {
|
||||
"observations": "List what happens in chronological order, starting from the first frame. Include each arrival, departure, object handled, and notable change in position or state, including brief moments at the start or end of the sequence that a longer activity could overshadow. Each item is a single concrete fact written as a complete sentence.",
|
||||
"scene": 'Describe how the sequence begins, then the progression of events: all significant movements and actions in order. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name; do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign. Use no more than five sentences.',
|
||||
"title": "Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles.",
|
||||
"shortSummary": "Briefly summarize the primary activity across the observations in one sentence. For named subjects, always use their name.",
|
||||
"observations": "Include the very start of the activity, for example a vehicle entering the frame or pulling into the driveway, even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence. Use every name from Objects in Scene.",
|
||||
"scene": 'Describe how the sequence begins, then the progression of events, all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.',
|
||||
"title": "Every name in Objects in Scene belongs in the title. Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For an unnamed object, refer to it naturally with an article.",
|
||||
"shortSummary": "Briefly summarize the primary activity across the observations. Use every name from Objects in Scene.",
|
||||
"potential_threat_level": "Must be consistent with your scene description and the activity patterns above.",
|
||||
}
|
||||
|
||||
@@ -37,19 +31,19 @@ REVIEW_DESCRIPTION_FIELD_GUIDELINES: dict[str, str] = {
|
||||
# competing style instructions.
|
||||
REVIEW_RESPONSE_STYLES: dict[str, dict[str, str]] = {
|
||||
"natural": {
|
||||
"scene": 'Recount what happened the way a person would describe it to a neighbor, in plain everyday language: how the sequence begins, then each significant movement and action in the order it happens. Use flowing sentences that connect related actions, written the way people actually talk rather than like a surveillance report. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name rather than a generic term. Refer to unnamed objects naturally with articles. Stay factual, and keep the description consistent with the threat level you assign. Use no more than five sentences.',
|
||||
"title": 'Write the title as a short, sentence-case headline in present tense: the subject, then what they do, phrased the way you would text it to the homeowner. Describe only the action you see; do not assign the person a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens. For named subjects, always use their name.',
|
||||
"shortSummary": "Sum up the primary activity in one short, natural sentence, as if mentioning it to someone in passing. For named subjects, always use their name.",
|
||||
"scene": 'Recount what happened the way a person would describe it to a neighbor, in plain everyday language: how the sequence begins, then each significant movement and action in the order it happens. Use flowing sentences that connect related actions, written the way people actually talk rather than like a surveillance report. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name. Refer to unnamed objects naturally with articles. Stay factual, and keep the description consistent with the threat level you assign.',
|
||||
"title": 'Every name in Objects in Scene belongs in the title. Write the title as a short, sentence-case headline in present tense: the subject, then what they do, phrased the way you would text it to the homeowner. Describe only the action you see; do not assign the person a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens.',
|
||||
"shortSummary": "Sum up the primary activity in one short, natural sentence, as if mentioning it to someone in passing. Use every name from Objects in Scene.",
|
||||
},
|
||||
"concise": {
|
||||
"scene": 'Cover each significant movement and action in order using as few short, direct sentences as possible, omitting environmental and cosmetic detail unless it affects the assessment. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign. Use no more than five sentences.',
|
||||
"title": 'Write the title as a terse label of two to four words naming the specific activity observed and where it happened. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. For named subjects, always use their name.',
|
||||
"shortSummary": "Summarize the primary activity in one short sentence. For named subjects, always use their name.",
|
||||
"scene": 'Cover each significant movement and action in order using as few short, direct sentences as possible, omitting environmental and cosmetic detail unless it affects the assessment. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign.',
|
||||
"title": 'Every name in Objects in Scene belongs in the title. Write the title as a terse label of two to four words naming the specific activity observed and where it happened, apart from those names. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene.',
|
||||
"shortSummary": "Summarize the primary activity in one short sentence. Use every name from Objects in Scene.",
|
||||
},
|
||||
"detailed": {
|
||||
"scene": 'Describe how the sequence begins, then the progression of events: all significant movements and actions in order, including the specifics that best identify the subjects: colors, clothing, carried items, positions, and paths of movement, plus environmental details like lighting changes when they stand out. Favor the most identifying details over exhaustive coverage, and keep every added detail observational rather than speculative. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name rather than a generic term. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign. Use no more than five sentences.',
|
||||
"title": 'Write the title as a specific description of who did what and where, in under roughly twelve words, including the most distinguishing visible detail of the subject, such as clothing or vehicle color. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens. For named subjects, always use their name.',
|
||||
"shortSummary": "Briefly summarize the primary activity across the observations in one sentence, including the most identifying visible detail, such as vehicle color or clothing. For named subjects, always use their name.",
|
||||
"scene": 'Describe how the sequence begins, then the progression of events, all significant movements and actions in order, including the specifics that best identify the subjects: colors, clothing, carried items, positions, and paths of movement, plus environmental details like lighting changes when they stand out. Favor the most identifying details over exhaustive coverage, and keep every added detail observational rather than speculative. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign.',
|
||||
"title": 'Every name in Objects in Scene belongs in the title. Write the title as a specific description of who did what and where, in under roughly twelve words, including the most distinguishing visible detail of the subject, such as clothing or vehicle color. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens.',
|
||||
"shortSummary": "Briefly summarize the primary activity across the observations, including the most identifying visible detail, such as vehicle color or clothing. Use every name from Objects in Scene.",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -67,12 +61,9 @@ def get_review_field_guidelines(response_style: str = "default") -> dict[str, st
|
||||
|
||||
# Explains the per-frame labels and tracker notes used by the annotated frame
|
||||
# mode. Neither the notes nor this guidance say whether repeated detections are
|
||||
# the same subject, since the tracking data cannot tell. The last line keeps
|
||||
# "no longer detected" from being read as the subject leaving the frame, which
|
||||
# is only one of the reasons the tracker loses an object.
|
||||
# the same subject, since the tracking data cannot tell.
|
||||
FRAME_ANNOTATION_GUIDANCE = """- Each image below is immediately preceded by a text label giving its frame number and how many seconds into the sequence it was captured. Use these labels to track the order of events and the time between them.
|
||||
- Some images below are preceded by notes from the camera's object tracker recording what changed at that point: an object being first detected, starting to move, reversing direction, stopping, or no longer being detected. These notes come from tracking data rather than from the images, and they are reliable. Use them to establish how many distinct activities occur and in what order, and describe every one of them.
|
||||
- The notes say when something changed, not why. In particular, an object is no longer detected whenever the tracker loses sight of it, which happens when it leaves the frame and also when it goes out of view behind or inside something. Look at the frames before and after each note to determine what actually happened."""
|
||||
- Some images below are preceded by a note of what changed at that moment: a subject being first detected, starting to move, reversing direction, stopping, or no longer being detected. When the note names the subject, use that name. These notes come from tracking data rather than from the images, and they are reliable. Use them to establish how many distinct activities occur and in what order, and describe every one of them."""
|
||||
|
||||
|
||||
def build_review_description_prompt(
|
||||
@@ -173,9 +164,9 @@ Respond with a JSON object matching the provided schema. Field-specific guidance
|
||||
|
||||
## Objects in Scene
|
||||
|
||||
Each line represents a detection state, not necessarily unique individuals. The `←` symbol separates a recognized subject's name from their object type — use only the name (before the `←`) in your response, not the type after it. The same subject may appear across multiple lines if detected multiple times.
|
||||
The `←` symbol separates a recognized, known subject's name from their object type. Use each of these names. The same subject may appear on more than one line. A line with no `←` has no name; refer to it naturally with an article.
|
||||
|
||||
**Note: Unidentified objects (without names) are NOT indicators of suspicious activity—they simply mean the system hasn't identified that object.**
|
||||
An object with no name is not suspicious just because it is unnamed.
|
||||
{get_objects_list()}
|
||||
|
||||
{get_language_prompt()}
|
||||
|
||||
@@ -80,12 +80,11 @@ class VLMWatchRunner(threading.Thread):
|
||||
self._broadcast_status()
|
||||
self.conversation = [{"role": "system", "content": self._build_system_prompt()}]
|
||||
|
||||
# monotonic so a wall clock step can't end the job early or let it overrun
|
||||
max_end_time = time.monotonic() + self.job.max_duration_minutes * 60
|
||||
max_end_time = self.job.start_time + self.job.max_duration_minutes * 60
|
||||
|
||||
try:
|
||||
while not self.cancel_event.is_set():
|
||||
if time.monotonic() > max_end_time:
|
||||
if time.time() > max_end_time:
|
||||
logger.debug(
|
||||
"VLM watch job %s timed out after %d minutes",
|
||||
self.job.id,
|
||||
@@ -237,18 +236,18 @@ class VLMWatchRunner(threading.Thread):
|
||||
queue backup, but the loop only exits once a match has been seen
|
||||
*and* the cooldown period has elapsed.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
now = time.time()
|
||||
deadline = now + max_wait
|
||||
use_cooldown = not self.job.zones
|
||||
earliest_wake = now + _DETECTION_COOLDOWN_WITHOUT_ZONE if use_cooldown else 0
|
||||
triggered = False
|
||||
|
||||
while not self.cancel_event.is_set():
|
||||
remaining = deadline - time.monotonic()
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
if triggered and time.monotonic() >= earliest_wake:
|
||||
if triggered and time.time() >= earliest_wake:
|
||||
break
|
||||
|
||||
result = self.detection_subscriber.check_for_update(
|
||||
@@ -288,7 +287,7 @@ class VLMWatchRunner(threading.Thread):
|
||||
"VLM watch job %s: detection match on %s, draining for %.0fs",
|
||||
self.job.id,
|
||||
self.job.camera,
|
||||
max(0, earliest_wake - time.monotonic()),
|
||||
max(0, earliest_wake - time.time()),
|
||||
)
|
||||
triggered = True
|
||||
|
||||
|
||||
+1
-1
@@ -209,7 +209,7 @@ class NoticeStats(Model):
|
||||
mutes = IntegerField(default=0)
|
||||
first_seen = DateTimeField()
|
||||
last_seen = DateTimeField()
|
||||
# lifetime counts when the current analytics install ID was created
|
||||
# watermarks for a future analytics reporter; unused until then
|
||||
reported_occurrences = IntegerField(default=0)
|
||||
reported_acknowledgements = IntegerField(default=0)
|
||||
reported_mutes = IntegerField(default=0)
|
||||
|
||||
+18
-38
@@ -132,7 +132,24 @@ class NoticeRegistry:
|
||||
def flush(self) -> None:
|
||||
"""Write the repeats that batch_repeats kinds held back."""
|
||||
with self._lock:
|
||||
written = self._write_held()
|
||||
held, self._held = self._held, {}
|
||||
written = False
|
||||
|
||||
for row_id, repeats in held.items():
|
||||
row = Notice.get_or_none(Notice.id == row_id)
|
||||
|
||||
# resolved while its repeats waited
|
||||
if row is None:
|
||||
continue
|
||||
|
||||
self._write_repeats(
|
||||
row,
|
||||
repeats.kind,
|
||||
repeats.count,
|
||||
repeats.last_seen,
|
||||
repeats.params,
|
||||
)
|
||||
written = True
|
||||
|
||||
if written:
|
||||
self._notify()
|
||||
@@ -368,43 +385,6 @@ class NoticeRegistry:
|
||||
if row.kind in NOTICE_KINDS
|
||||
]
|
||||
|
||||
def reset_analytics_baseline(self) -> None:
|
||||
"""Count analytics notices from now on, for a new analytics install ID."""
|
||||
with self._lock:
|
||||
# held repeats were raised before the reset, so they join the baseline
|
||||
written = self._write_held()
|
||||
NoticeStats.update(
|
||||
reported_occurrences=NoticeStats.occurrences,
|
||||
reported_acknowledgements=NoticeStats.acknowledgements,
|
||||
reported_mutes=NoticeStats.mutes,
|
||||
).execute()
|
||||
|
||||
if written:
|
||||
self._notify()
|
||||
|
||||
def _write_held(self) -> bool:
|
||||
# called with the lock held
|
||||
held, self._held = self._held, {}
|
||||
written = False
|
||||
|
||||
for row_id, repeats in held.items():
|
||||
row = Notice.get_or_none(Notice.id == row_id)
|
||||
|
||||
# resolved while its repeats waited
|
||||
if row is None:
|
||||
continue
|
||||
|
||||
self._write_repeats(
|
||||
row,
|
||||
repeats.kind,
|
||||
repeats.count,
|
||||
repeats.last_seen,
|
||||
repeats.params,
|
||||
)
|
||||
written = True
|
||||
|
||||
return written
|
||||
|
||||
def _write_repeats(
|
||||
self,
|
||||
row: Notice,
|
||||
|
||||
@@ -99,16 +99,6 @@ _KINDS = (
|
||||
link="https://github.com/blakeblackshear/frigate/releases/tag/v{version}",
|
||||
counts_repeats=False,
|
||||
keep_latest=1,
|
||||
reportable=False,
|
||||
),
|
||||
# raised while analytics is off; the row keeps a dismissal across restarts
|
||||
NoticeKind(
|
||||
"analytics_prompt",
|
||||
NoticeSeverity.info,
|
||||
"system",
|
||||
link="/settings?page=systemTelemetry",
|
||||
counts_repeats=False,
|
||||
reportable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import datetime
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
@@ -23,7 +24,7 @@ from frigate.detectors.detector_config import (
|
||||
InputDTypeEnum,
|
||||
ModelConfig,
|
||||
)
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, load_labels
|
||||
from frigate.util.builtin import EventsPerSecond, load_labels
|
||||
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
|
||||
from frigate.util.process import FrigateProcess
|
||||
|
||||
@@ -128,7 +129,7 @@ class DetectorRunner(FrigateProcess):
|
||||
super().__init__(stop_event, PROCESS_PRIORITY_HIGH, name=name, daemon=True)
|
||||
self.detection_queue = detection_queue
|
||||
self.cameras = cameras
|
||||
self.avg_speed = InferenceSpeed(avg_speed)
|
||||
self.avg_speed = avg_speed
|
||||
self.start_time = start_time
|
||||
self.config = config
|
||||
self.detector_config = detector_config
|
||||
@@ -168,10 +169,9 @@ class DetectorRunner(FrigateProcess):
|
||||
logger.warning(f"Failed to get frame {connection_id} from SHM")
|
||||
continue
|
||||
|
||||
# detect and send the output. start_time is monotonic so the watchdog
|
||||
# doesn't mistake a wall clock step for a stuck detector
|
||||
# detect and send the output
|
||||
self.start_time.value = datetime.datetime.now().timestamp()
|
||||
mono_start = time.monotonic()
|
||||
self.start_time.value = mono_start
|
||||
detections = object_detector.detect_raw(input_frame)
|
||||
duration = time.monotonic() - mono_start
|
||||
|
||||
@@ -182,7 +182,7 @@ class DetectorRunner(FrigateProcess):
|
||||
detector_publisher.publish(connection_id)
|
||||
self.start_time.value = 0.0
|
||||
|
||||
self.avg_speed.update(duration)
|
||||
self.avg_speed.value = (self.avg_speed.value * 9 + duration) / 10
|
||||
|
||||
detector_publisher.stop()
|
||||
logger.info("Exited detection process...")
|
||||
@@ -203,7 +203,7 @@ class AsyncDetectorRunner(FrigateProcess):
|
||||
super().__init__(stop_event, PROCESS_PRIORITY_HIGH, name=name, daemon=True)
|
||||
self.detection_queue = detection_queue
|
||||
self.cameras = cameras
|
||||
self.avg_speed = InferenceSpeed(avg_speed)
|
||||
self.avg_speed = avg_speed
|
||||
self.start_time = start_time
|
||||
self.config = config
|
||||
self.detector_config = detector_config
|
||||
@@ -272,7 +272,7 @@ class AsyncDetectorRunner(FrigateProcess):
|
||||
self._publisher.publish(connection_id)
|
||||
|
||||
# update timers
|
||||
self.avg_speed.update(duration)
|
||||
self.avg_speed.value = (self.avg_speed.value * 9 + duration) / 10
|
||||
self.start_time.value = 0.0
|
||||
|
||||
def run(self) -> None:
|
||||
@@ -328,8 +328,7 @@ class ObjectDetectProcess:
|
||||
self.name = name
|
||||
self.cameras = cameras
|
||||
self.detection_queue = detection_queue
|
||||
# stays 0 until the first inference, so stats can tell an idle detector
|
||||
self.avg_inference_speed = Value("d", 0.0)
|
||||
self.avg_inference_speed = Value("d", 0.01)
|
||||
self.detection_start = Value("d", 0.0)
|
||||
self.detect_process: FrigateProcess | None = None
|
||||
self.config = config
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Handle outputting birdseye frames via jsmpeg and go2rtc."""
|
||||
|
||||
import datetime
|
||||
import glob
|
||||
import logging
|
||||
import math
|
||||
@@ -7,7 +8,6 @@ import os
|
||||
import queue
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
@@ -492,7 +492,7 @@ class BirdsEyeFrameManager:
|
||||
max_cameras = self.config.birdseye.layout.max_cameras
|
||||
max_camera_refresh = False
|
||||
if max_cameras:
|
||||
now = time.monotonic()
|
||||
now = datetime.datetime.now().timestamp()
|
||||
|
||||
if (
|
||||
len(self.active_cameras) == max_cameras
|
||||
@@ -800,8 +800,7 @@ class BirdsEyeFrameManager:
|
||||
|
||||
camera_state["live_active"] = self.camera_live_active(modes, activity)
|
||||
|
||||
# monotonic so a backward wall clock step can't freeze the output
|
||||
now = time.monotonic()
|
||||
now = datetime.datetime.now().timestamp()
|
||||
|
||||
# limit output to 10 fps
|
||||
if not force_update and (now - self.last_output_time) < 1 / 10:
|
||||
@@ -946,7 +945,7 @@ class Birdseye:
|
||||
coordinates = self.birdseye_manager.get_camera_coordinates()
|
||||
self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates)
|
||||
if self._idle_interval:
|
||||
now = time.monotonic()
|
||||
now = datetime.datetime.now().timestamp()
|
||||
is_idle = len(self.birdseye_manager.camera_layout) == 0
|
||||
if (
|
||||
is_idle
|
||||
|
||||
+25
-31
@@ -233,8 +233,7 @@ class PtzAutoTracker(threading.Thread):
|
||||
and camera_config.onvif.autotracking.enabled_in_config
|
||||
):
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._autotracker_setup(camera_config, camera, startup=True),
|
||||
self.onvif.loop,
|
||||
self._autotracker_setup(camera_config, camera), self.onvif.loop
|
||||
)
|
||||
# Wait for the coroutine to complete
|
||||
future.result()
|
||||
@@ -262,9 +261,7 @@ class PtzAutoTracker(threading.Thread):
|
||||
self.config_subscriber.stop()
|
||||
logger.info("Exiting autotracker...")
|
||||
|
||||
async def _autotracker_setup(
|
||||
self, camera_config: CameraConfig, camera: str, startup: bool = False
|
||||
):
|
||||
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
|
||||
logger.debug(f"{camera}: Autotracker init")
|
||||
|
||||
self.tracked_object[camera] = None
|
||||
@@ -291,34 +288,35 @@ class PtzAutoTracker(threading.Thread):
|
||||
self._disable(camera, "Unable to initialize onvif")
|
||||
return
|
||||
|
||||
if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
|
||||
self._disable(camera, "FOV relative movement not supported")
|
||||
return
|
||||
if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
|
||||
self._disable(camera, "FOV relative movement not supported")
|
||||
return
|
||||
|
||||
move_status_supported = await self.onvif.get_service_capabilities(camera)
|
||||
move_status_supported = await self.onvif.get_service_capabilities(camera)
|
||||
|
||||
if str(move_status_supported).lower() != "true":
|
||||
self._disable(camera, "ONVIF MoveStatus not supported")
|
||||
return
|
||||
if str(move_status_supported).lower() != "true":
|
||||
self._disable(camera, "ONVIF MoveStatus not supported")
|
||||
return
|
||||
|
||||
await self.onvif.get_camera_status(camera)
|
||||
if self.onvif.cams[camera]["init"]:
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
# movement queue with asyncio on OnvifController loop
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._process_move_queue(camera), self.onvif.loop
|
||||
)
|
||||
# movement queue with asyncio on OnvifController loop
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._process_move_queue(camera), self.onvif.loop
|
||||
)
|
||||
|
||||
if camera_config.onvif.autotracking.movement_weights:
|
||||
(
|
||||
self.ptz_metrics[camera].min_zoom.value,
|
||||
self.ptz_metrics[camera].max_zoom.value,
|
||||
self.intercept[camera],
|
||||
*self.move_coefficients[camera],
|
||||
self.zoom_time[camera],
|
||||
) = map(float, camera_config.onvif.autotracking.movement_weights)
|
||||
if camera_config.onvif.autotracking.movement_weights:
|
||||
(
|
||||
self.ptz_metrics[camera].min_zoom.value,
|
||||
self.ptz_metrics[camera].max_zoom.value,
|
||||
self.intercept[camera],
|
||||
*self.move_coefficients[camera],
|
||||
self.zoom_time[camera],
|
||||
) = map(float, camera_config.onvif.autotracking.movement_weights)
|
||||
|
||||
if startup and camera_config.onvif.autotracking.calibrate_on_startup:
|
||||
await self._calibrate_camera(camera)
|
||||
if camera_config.onvif.autotracking.calibrate_on_startup:
|
||||
await self._calibrate_camera(camera)
|
||||
|
||||
self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False)
|
||||
self.autotracker_init[camera] = True
|
||||
@@ -333,7 +331,6 @@ class PtzAutoTracker(threading.Thread):
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera),
|
||||
autotracking_config,
|
||||
)
|
||||
self.dispatcher.publish(f"{camera}/ptz_autotracker/state", "OFF", retain=True)
|
||||
|
||||
def _reset_tracked_object_metrics(self, camera: str) -> None:
|
||||
self.tracked_object_metrics[camera] = {}
|
||||
@@ -1272,9 +1269,6 @@ class PtzAutoTracker(threading.Thread):
|
||||
# Wait for the coroutine to complete
|
||||
future.result()
|
||||
|
||||
if not camera_config.onvif.autotracking.enabled:
|
||||
return
|
||||
|
||||
if self.calibrating[camera]:
|
||||
logger.debug(f"{camera}: Calibrating camera")
|
||||
return
|
||||
|
||||
@@ -625,12 +625,7 @@ class OnvifController:
|
||||
|
||||
move_request.Speed = move_speed
|
||||
|
||||
try:
|
||||
await cam["ptz"].RelativeMove(move_request)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Onvif sending relative move request to {camera_name} failed: {e}"
|
||||
)
|
||||
await cam["ptz"].RelativeMove(move_request)
|
||||
|
||||
# reset after the move request
|
||||
move_request.Translation.PanTilt.x = 0
|
||||
|
||||
@@ -1470,9 +1470,6 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
try:
|
||||
self._run_export(video_path, thumb_path)
|
||||
except Exception:
|
||||
self._discard_failed_export(video_path, thumb_path)
|
||||
raise
|
||||
finally:
|
||||
# staged runs hold a full copy of the export, so they must not
|
||||
# survive a failure the way the small chapter file could
|
||||
@@ -1482,8 +1479,7 @@ class RecordingExporter(threading.Thread):
|
||||
"""Drop the partial output and the row that promised it."""
|
||||
Path(video_path).unlink(missing_ok=True)
|
||||
Export.delete().where(Export.id == self.export_id).execute()
|
||||
if thumb_path:
|
||||
Path(thumb_path).unlink(missing_ok=True)
|
||||
Path(thumb_path).unlink(missing_ok=True)
|
||||
|
||||
def _run_export(self, video_path: str, thumb_path: str) -> None:
|
||||
try:
|
||||
@@ -1502,7 +1498,6 @@ class RecordingExporter(threading.Thread):
|
||||
else:
|
||||
ffmpeg_cmd, playlist_lines = self.get_preview_export_command(video_path)
|
||||
except DoesNotExist:
|
||||
self._discard_failed_export(video_path, thumb_path)
|
||||
return
|
||||
|
||||
# When neither custom ffmpeg arg is set the default path uses
|
||||
|
||||
@@ -100,14 +100,6 @@ class PendingReviewSegment:
|
||||
if zone not in self.zones:
|
||||
self.zones.append(zone)
|
||||
|
||||
def add_manual_detection(self, info: dict[str, Any]) -> None:
|
||||
"""Add a manual event's label and sub label to the segment."""
|
||||
if info.get("sub_label"):
|
||||
self.detections[info["event_id"]] = f"{info['label']}-verified"
|
||||
self.sub_labels[info["event_id"]] = info["sub_label"]
|
||||
else:
|
||||
self.detections[info["event_id"]] = info["label"]
|
||||
|
||||
def update_frame(
|
||||
self,
|
||||
camera_config: CameraConfig,
|
||||
@@ -491,6 +483,8 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
alerts so manual events keep their historical severity.
|
||||
"""
|
||||
review_config = self.config.cameras[camera].review
|
||||
# label contains 'label: sub_label', only the label is categorized
|
||||
label = label.split(": ")[0]
|
||||
|
||||
if review_config.alerts.enabled and label in review_config.alerts.labels:
|
||||
return SeverityEnum.alert
|
||||
@@ -900,7 +894,9 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
current_segment.last_detection_time = frame_time
|
||||
elif topic == DetectionTypeEnum.api or topic == DetectionTypeEnum.lpr:
|
||||
if manual_info["state"] == ManualEventState.complete:
|
||||
current_segment.add_manual_detection(manual_info)
|
||||
current_segment.detections[manual_info["event_id"]] = (
|
||||
manual_info["label"]
|
||||
)
|
||||
if topic == DetectionTypeEnum.api:
|
||||
severity = self.get_manual_event_severity(
|
||||
camera, manual_info["label"]
|
||||
@@ -925,7 +921,9 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
self.indefinite_events[camera][manual_info["event_id"]] = (
|
||||
manual_info["label"]
|
||||
)
|
||||
current_segment.add_manual_detection(manual_info)
|
||||
current_segment.detections[manual_info["event_id"]] = (
|
||||
manual_info["label"]
|
||||
)
|
||||
if topic == DetectionTypeEnum.api:
|
||||
if (
|
||||
self.get_manual_event_severity(
|
||||
@@ -1016,12 +1014,11 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
camera,
|
||||
frame_time,
|
||||
severity,
|
||||
{},
|
||||
{manual_info["event_id"]: manual_info["label"]},
|
||||
{},
|
||||
[],
|
||||
set(),
|
||||
)
|
||||
api_segment.add_manual_detection(manual_info)
|
||||
self._activate_segment(api_segment)
|
||||
|
||||
if manual_info["state"] == ManualEventState.start:
|
||||
@@ -1044,12 +1041,11 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
camera,
|
||||
frame_time,
|
||||
SeverityEnum.detection,
|
||||
{},
|
||||
{manual_info["event_id"]: manual_info["label"]},
|
||||
{},
|
||||
[],
|
||||
set(),
|
||||
)
|
||||
lpr_segment.add_manual_detection(manual_info)
|
||||
self._activate_segment(lpr_segment)
|
||||
|
||||
if manual_info["state"] == ManualEventState.start:
|
||||
|
||||
@@ -106,14 +106,6 @@ def stats_init(
|
||||
return stats_tracking
|
||||
|
||||
|
||||
def _monotonic_to_wall(timestamp: float) -> float:
|
||||
"""Convert a monotonic timestamp to wall clock time, keeping 0 as unset."""
|
||||
if timestamp <= 0:
|
||||
return 0.0
|
||||
|
||||
return time.time() - (time.monotonic() - timestamp)
|
||||
|
||||
|
||||
def get_detector_stats(
|
||||
stats_tracking: StatsTrackingTypes,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
@@ -136,7 +128,7 @@ def get_detector_stats(
|
||||
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
"detection_start": _monotonic_to_wall(detector.detection_start.value), # type: ignore[attr-defined]
|
||||
"detection_start": detector.detection_start.value, # type: ignore[attr-defined]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
"pid": pid,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
from frigate.config.config import stream_info_retriever
|
||||
|
||||
|
||||
def _unreachable_stream_info(ffmpeg, path: str) -> dict:
|
||||
return {"width": 0, "height": 0, "fourcc": None}
|
||||
|
||||
|
||||
# Validating a camera without detect dimensions probes its stream. Test streams
|
||||
# are unreachable, so return the failed probe result instead of waiting ~12s per
|
||||
# stream for ffprobe to time out
|
||||
stream_info_retriever.get_stream_info = _unreachable_stream_info
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
"""Run the unit test suite in parallel groups.
|
||||
|
||||
Test modules are split into groups balanced by test count, similar to Home
|
||||
Assistant's split_tests.py, and each group runs in its own unittest process.
|
||||
|
||||
Usage (from the repo root):
|
||||
python3 -u -m frigate.test [-j JOBS] [--groups GROUPS] [--timeout SECONDS]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
TEST_METHOD = re.compile(r"^\s+(?:async )?def test", re.MULTILINE)
|
||||
RAN_TESTS = re.compile(r"^Ran (\d+) tests? in", re.MULTILINE)
|
||||
|
||||
# Each group pays for its own interpreter and imports, so smaller groups only
|
||||
# help when there are more cores than groups
|
||||
TESTS_PER_GROUP = 250
|
||||
|
||||
# Each group runs in its own process group, which ctrl+c in the terminal does
|
||||
# not reach, so the runner passes the interrupt on to these
|
||||
interrupted = threading.Event()
|
||||
running_groups: set[int] = set()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestGroup:
|
||||
index: int
|
||||
modules: list[str] = field(default_factory=list)
|
||||
weight: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroupResult:
|
||||
group: TestGroup
|
||||
returncode: int
|
||||
output: str
|
||||
duration: float
|
||||
|
||||
@property
|
||||
def tests_run(self) -> int:
|
||||
"""Number of tests unittest reported running, 0 if it never finished."""
|
||||
match = RAN_TESTS.search(self.output)
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
|
||||
def discover_modules() -> dict[str, int]:
|
||||
"""Find test modules the same way unittest discovery does.
|
||||
|
||||
Returns:
|
||||
Module name mapped to the number of test methods it defines
|
||||
"""
|
||||
modules: dict[str, int] = {}
|
||||
|
||||
for path in sorted((REPO_ROOT / "frigate").rglob("test*.py")):
|
||||
relative = path.relative_to(REPO_ROOT)
|
||||
|
||||
# discovery only descends into packages
|
||||
if not all(
|
||||
(REPO_ROOT / parent / "__init__.py").exists()
|
||||
for parent in relative.parents
|
||||
if parent != Path(".")
|
||||
):
|
||||
continue
|
||||
|
||||
name = ".".join(relative.with_suffix("").parts)
|
||||
modules[name] = len(TEST_METHOD.findall(path.read_text()))
|
||||
|
||||
return modules
|
||||
|
||||
|
||||
def split_groups(modules: dict[str, int], count: int) -> list[TestGroup]:
|
||||
"""Split modules into groups with a similar number of tests.
|
||||
|
||||
Largest modules are placed first, each into the currently lightest group.
|
||||
"""
|
||||
groups = [TestGroup(index) for index in range(1, count + 1)]
|
||||
|
||||
for name, weight in sorted(modules.items(), key=lambda m: m[1], reverse=True):
|
||||
group = min(groups, key=lambda g: g.weight)
|
||||
group.modules.append(name)
|
||||
group.weight += weight
|
||||
|
||||
return [group for group in groups if group.modules]
|
||||
|
||||
|
||||
def signal_group(pgid: int, sig: signal.Signals) -> bool:
|
||||
"""Send a signal to every process in a process group.
|
||||
|
||||
Returns:
|
||||
False if no process is left in the group
|
||||
"""
|
||||
try:
|
||||
os.killpg(pgid, sig)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def stop_group(pgid: int) -> None:
|
||||
"""Stop the processes a group's tests left running.
|
||||
|
||||
They get a moment to exit before being killed, so the multiprocessing
|
||||
resource tracker, which ignores SIGTERM, can remove leaked shared memory
|
||||
once the processes holding it have exited.
|
||||
"""
|
||||
if signal_group(pgid, signal.SIGTERM):
|
||||
time.sleep(2)
|
||||
signal_group(pgid, signal.SIGKILL)
|
||||
|
||||
|
||||
def run_group(group: TestGroup, timeout: float) -> GroupResult:
|
||||
"""Run a group's modules in a unittest process of its own.
|
||||
|
||||
Args:
|
||||
group: The modules to run
|
||||
timeout: Seconds before the group is stopped
|
||||
|
||||
Returns:
|
||||
The exit code and combined output of the unittest process
|
||||
"""
|
||||
start = time.monotonic()
|
||||
|
||||
# a file instead of a pipe so a process left behind by a test can't keep
|
||||
# the group from finishing by holding the pipe open
|
||||
with tempfile.TemporaryFile("w+") as output:
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-u", "-X", "faulthandler", "-m", "unittest"]
|
||||
+ group.modules,
|
||||
cwd=REPO_ROOT,
|
||||
# a group in the background that read the terminal would be
|
||||
# stopped, and a stopped process doesn't act on SIGABRT
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=output,
|
||||
stderr=subprocess.STDOUT,
|
||||
# a process group of its own so the processes its tests start can
|
||||
# be stopped with it
|
||||
process_group=0,
|
||||
)
|
||||
running_groups.add(proc.pid)
|
||||
|
||||
# the runner may have been interrupted while this group was starting
|
||||
if interrupted.is_set():
|
||||
signal_group(proc.pid, signal.SIGINT)
|
||||
|
||||
timed_out = False
|
||||
|
||||
try:
|
||||
proc.wait(timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
# faulthandler prints the stack of every thread on SIGABRT, which
|
||||
# shows where the group is stuck
|
||||
timed_out = True
|
||||
proc.send_signal(signal.SIGABRT)
|
||||
proc.wait()
|
||||
|
||||
# aborting skips the cleanup of the running test, which would
|
||||
# have stopped the processes it started
|
||||
stop_group(proc.pid)
|
||||
|
||||
running_groups.discard(proc.pid)
|
||||
output.seek(0)
|
||||
text = output.read()
|
||||
|
||||
if timed_out:
|
||||
text += f"\nGroup timed out after {timeout:.0f}s\n"
|
||||
|
||||
return GroupResult(group, proc.returncode, text, time.monotonic() - start)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Split the tests into groups, run them in parallel, and report the results.
|
||||
|
||||
Returns:
|
||||
The exit code, 1 if any group failed
|
||||
"""
|
||||
cpus = os.cpu_count() or 1
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument(
|
||||
"-j",
|
||||
"--jobs",
|
||||
type=int,
|
||||
default=cpus,
|
||||
help=f"number of groups to run at once (default: {cpus})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--groups",
|
||||
type=int,
|
||||
help=(
|
||||
"number of groups to split the tests into (default: one per job, "
|
||||
f"or more to keep groups under about {TESTS_PER_GROUP} tests)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=600,
|
||||
help="seconds before a group is stopped and reported as failed",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="print the output of passing groups as well as failing ones",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
modules = discover_modules()
|
||||
group_count = args.groups or max(
|
||||
args.jobs, math.ceil(sum(modules.values()) / TESTS_PER_GROUP)
|
||||
)
|
||||
groups = split_groups(modules, group_count)
|
||||
print(f"Running {len(groups)} test groups with {args.jobs} jobs", flush=True)
|
||||
|
||||
start = time.monotonic()
|
||||
failed: list[GroupResult] = []
|
||||
total_tests = 0
|
||||
|
||||
with ThreadPoolExecutor(max_workers=args.jobs) as executor:
|
||||
futures = [executor.submit(run_group, group, args.timeout) for group in groups]
|
||||
|
||||
try:
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
total_tests += result.tests_run
|
||||
status = "OK" if result.returncode == 0 else "FAILED"
|
||||
print(
|
||||
f"Group {result.group.index}/{len(groups)}: {status}, "
|
||||
f"{result.tests_run} tests in {result.duration:.1f}s",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
failed.append(result)
|
||||
|
||||
if result.returncode != 0 or args.verbose:
|
||||
print(result.output, flush=True)
|
||||
except KeyboardInterrupt:
|
||||
interrupted.set()
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
for pgid in list(running_groups):
|
||||
signal_group(pgid, signal.SIGINT)
|
||||
|
||||
raise
|
||||
|
||||
print(f"\nRan {total_tests} tests in {time.monotonic() - start:.1f}s")
|
||||
|
||||
if failed:
|
||||
print(f"{len(failed)} of {len(groups)} groups FAILED, rerun one with:")
|
||||
|
||||
for result in sorted(failed, key=lambda r: r.group.index):
|
||||
print(
|
||||
f" Group {result.group.index}: python3 -u -m unittest "
|
||||
f"{' '.join(result.group.modules)}"
|
||||
)
|
||||
|
||||
return 1
|
||||
|
||||
print("OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Shared fixtures for the analytics tests."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
FRONT_CAMERA: dict[str, Any] = {
|
||||
"ffmpeg": {"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]},
|
||||
"detect": {"height": 720, "width": 1280, "fps": 5},
|
||||
}
|
||||
|
||||
|
||||
def make_config(config: dict[str, Any] | None = None) -> FrigateConfig:
|
||||
"""A parsed config; top-level keys in config replace the defaults here.
|
||||
|
||||
hwaccel_args is set so parsing never probes the test host's GPU.
|
||||
"""
|
||||
base: dict[str, Any] = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"ffmpeg": {"hwaccel_args": []},
|
||||
"cameras": {"front": FRONT_CAMERA},
|
||||
}
|
||||
return FrigateConfig(**{**base, **(config or {})})
|
||||
|
||||
|
||||
def make_context(
|
||||
config: FrigateConfig | None = None,
|
||||
stats: dict[str, Any] | None = None,
|
||||
notice_stats: list[dict[str, Any]] | None = None,
|
||||
) -> ReportContext:
|
||||
return ReportContext(
|
||||
config=config or make_config(),
|
||||
stats=stats or {},
|
||||
notice_stats=notice_stats or [],
|
||||
)
|
||||
+2
-10
@@ -1,12 +1,4 @@
|
||||
"""Constants for testing."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
# Unique per process so test groups can run in parallel without sharing a database
|
||||
TEST_DB = os.path.join(tempfile.gettempdir(), f"frigate_test_{os.getpid()}.db")
|
||||
TEST_DB_CLEANUPS = [TEST_DB, f"{TEST_DB}-shm", f"{TEST_DB}-wal"]
|
||||
|
||||
# The database is thrown away after each test, so skip syncing every commit to
|
||||
# disk, which is slow when several test processes are writing at once
|
||||
TEST_DB_PRAGMAS = {"synchronous": "off"}
|
||||
TEST_DB = "test.db"
|
||||
TEST_DB_CLEANUPS = ["test.db", "test.db-shm", "test.db-wal"]
|
||||
|
||||
@@ -18,7 +18,7 @@ from frigate.debug_replay import DebugReplayManager
|
||||
from frigate.jobs.export import JobStatePublisher
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.review.types import SeverityEnum
|
||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
|
||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||
|
||||
|
||||
class AuthTestClient(TestClient):
|
||||
@@ -38,12 +38,12 @@ class AuthTestClient(TestClient):
|
||||
class BaseTestHttp(unittest.TestCase):
|
||||
def setUp(self, models):
|
||||
# setup clean database for each test run
|
||||
migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
|
||||
migrate_db = SqliteDatabase("test.db")
|
||||
del logging.getLogger("peewee_migrate").handlers[:]
|
||||
router = Router(migrate_db)
|
||||
router.run()
|
||||
migrate_db.close()
|
||||
self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
|
||||
self.db = SqliteQueueDatabase(TEST_DB)
|
||||
self.db.bind(models)
|
||||
|
||||
# The export job manager broadcasts via JobStatePublisher on
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Tests for the analytics preview API."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from frigate.analytics import report
|
||||
from frigate.analytics.collectors import hardware
|
||||
from frigate.models import Notice, NoticeStats, User
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpAnalytics(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([Notice, NoticeStats, User])
|
||||
stats = Mock()
|
||||
stats.get_latest_stats.return_value = self.test_stats
|
||||
self.app = self.create_app(stats=stats, notice_registry=NoticeRegistry())
|
||||
|
||||
for target, name, value in (
|
||||
(report, "load_state", None),
|
||||
(hardware.hardware_prober, "probe", []),
|
||||
(hardware, "hwaccel_options", ("", [])),
|
||||
):
|
||||
patcher = patch.object(target, name, return_value=value)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_preview_returns_the_report_without_sending_it(self):
|
||||
with (
|
||||
patch("frigate.analytics.transport.requests.post") as post,
|
||||
AuthTestClient(self.app) as client,
|
||||
):
|
||||
response = client.get("/analytics/preview")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body["install_id"], report.PREVIEW_INSTALL_ID)
|
||||
self.assertEqual(body["schema_version"], 1)
|
||||
self.assertEqual(body["cameras"]["total"], 1)
|
||||
post.assert_not_called()
|
||||
|
||||
def test_preview_is_admin_only(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
response = client.get(
|
||||
"/analytics/preview",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
@@ -393,8 +393,8 @@ class TestHttpApp(BaseTestHttp):
|
||||
def test_event_time_filtering(self):
|
||||
morning_id = "123456.random"
|
||||
evening_id = "654321.random"
|
||||
morning = 1656568800 # 06/30/2022 6 am (GMT)
|
||||
evening = 1656612000 # 06/30/2022 6 pm (GMT)
|
||||
morning = 1656590400 # 06/30/2022 6 am (GMT)
|
||||
evening = 1656633600 # 06/30/2022 6 pm (GMT)
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_event(morning_id, morning)
|
||||
@@ -417,12 +417,6 @@ class TestHttpApp(BaseTestHttp):
|
||||
).json()
|
||||
assert events
|
||||
assert len(events) == 1
|
||||
# an event in the first minute of the range is included
|
||||
events = client.get(
|
||||
"/events",
|
||||
params={"time_range": "06:00,24:00"},
|
||||
).json()
|
||||
assert len(events) == 2
|
||||
|
||||
def test_set_delete_sub_label(self):
|
||||
mock_event_updater = Mock(spec=EventMetadataPublisher)
|
||||
|
||||
@@ -427,30 +427,6 @@ class TestHttpExport(BaseTestHttp):
|
||||
assert response.status_code == 400
|
||||
assert Export.get(Export.id == "front_door_running").video_path == video
|
||||
|
||||
def test_delete_export_rejected_while_in_progress(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
video = os.path.join(tmpdir, "front_door_abc123.mp4")
|
||||
with open(video, "wb") as handle:
|
||||
handle.write(b"video")
|
||||
|
||||
Export.create(
|
||||
id="front_door_running",
|
||||
camera="front_door",
|
||||
name="front door export",
|
||||
date=100,
|
||||
video_path=video,
|
||||
thumb_path=os.path.join(tmpdir, "t.webp"),
|
||||
in_progress=True,
|
||||
)
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
response = client.post(
|
||||
"/exports/delete", json={"ids": ["front_door_running"]}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert os.path.exists(video)
|
||||
|
||||
def test_rename_export_missing_file_leaves_the_row_alone(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
video = os.path.join(tmpdir, "front_door_gone_abc123.mp4")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user