Compare commits

...
Author SHA1 Message Date
Josh Hawkins 432eef03e3 don't race the capture thread when stopping ffmpeg
stop_all_ffmpeg called communicate() on the detect ffmpeg even when the capture thread was still reading stdout, which happened on every ffmpeg, record, and record_sub config restart and when a camera stalled during a stop. The two readers could buffer duplicate frames without bound or hang the stop. When the capture thread is still alive, it now terminates and waits, then kills, so the live thread drains the pipe and ffmpeg can finish its segment. Disable and shutdown still use stop_ffmpeg.
2026-11-06 18:48:53 -06:00
Martin WeineltandGitHub 0e90e8ee99 Reuse constants in media auth tests (#24625)
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-11-06 08:48:36 -06:00
463942e01d Measure Gemini image-token cost instead of using the pixel heuristic (#24469)
* Measure Gemini image-token cost instead of using the pixel heuristic

Review descriptions size their frame budget from the provider's
estimate_image_tokens(). Gemini did not override it, so the base
~1-token-per-1250-pixels heuristic was used: about 46 tokens for a 320x180
preview frame. Gemini 3 models actually bill about 1100 tokens per image
regardless of size, so the 24000-token image budget let Frigate send one
frame per second of the review item (e.g. 90 frames for a 90 s alert)
instead of ~21, and review descriptions cost roughly 4x more than intended.

Probe the model with the free count_tokens endpoint, the same way the
llama.cpp provider measures its mmproj, and cache the result per image
size. Fall back to the base heuristic if the request fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bound Gemini image-token probes and back off after failures

estimate_image_tokens() runs on the enrichment thread, and count_tokens was
using the client's defaults: a 120 s timeout and 3 attempts on 429/5xx. A
hung endpoint could stall review processing for minutes per review, and a
rate-limited key retried on every review of an unmeasured size.

Give the probe a 5 s timeout with no retries, and after a failure use the
heuristic without probing again for 5 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Update docs to note that Gemini charges roughly-flat token cost regardless of video resolution

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:47:44 -07:00
Dmytro MarchukGitHubClaude Opus 5.5greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
52c6175255 Custom notifications suspend duration UI (#23286)
* Add custom notification suspension dialog

* Improve on the dialog, fix some bugs

* Review changes

* Make Dropdown look like Select

* Review changes

* Remove accidential changes

* Fix file mode

* Update web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix llm findings

* fix another llm finding

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-11-06 08:45:37 -06:00
Josh HawkinsandGitHub 4eb60af5a7 Implement opt-in analytics (#24440)
* Add analytics opt-in setting, prompt notice kind, and notice watermarks

* Add the analytics report schema

* Add the analytics state file

* Add the install and hardware analytics collectors

* Add the detection analytics collector

* Add the cameras analytics collector

* Add the features analytics collector

* Add the health analytics collector

* Build analytics reports from the section collectors

* Add the analytics report transport

* Send the daily analytics report from the main process

* Add the analytics preview API

* Generate the analytics schema and check it in CI

* Record the image variant in each Dockerfile

* Show the analytics preview in telemetry settings

* Document anonymous analytics

* change wording

* fixes

* update network requirements docs

* adapt analytics to free-form model scenes, split notice counts, and peewee 4

* fixes

* change log levels

* docstrings

* docstrings

* honor file-only analytics opt-outs and skip pre-consent notices

An opt-out saved through the YAML editor's "Save only" or a hand edit never reached the live config, so reports kept going out under the old install ID until a restart. The reporter now reads telemetry.analytics from the config file when a report is due, validated with TelemetryConfig so yes, on, and 1 still count as consent, and an opt-out there deletes the state file and skips the send. The first report under a new install ID also carried every notice since the counters started, including ones from before the opt-in, so a fresh state file now resets the notice watermarks before the report's snapshot.

* take the notice baseline at opt-in instead of the first send

The watermark reset ran at the first send attempt, so notices raised between the opt-in and that send, up to 45 minutes after a startup, were absorbed into the reset and never reported. The reporter now writes the new install ID and resets the watermarks as soon as it sees consent without a state file.

* report notice counts cumulatively from a database baseline

Moving watermarks after each accepted send kept the install ID and the notice baseline out of step: pre-consent notices went out, notices raised before the first send were dropped, and a send that finished after an off/on toggle overwrote the new identity's baseline. health.notices now carries cumulative counts since the install ID was created. Creating an ID copies the lifetime counts into the existing reported_* columns before the state file names it, and nothing is written after a send, so the backend diffs consecutive reports per install ID.

* write held notice repeats before the analytics baseline reset

Repeats of batched kinds like failed_login wait in memory until the stats emitter flushes them, so a baseline reset taken while some were held left them out, and the next flush counted those pre-opt-in repeats as post-consent. The reset now writes held repeats to the notice history under the same lock before copying the counts.

* seed detector inference speed from the first inference

The average started at 0 so idle detectors read 0, but that made every detector's speed climb from 0 for its first couple dozen inferences after a start. Detectors now use the same InferenceSpeed helper as enrichments, so the first inference seeds the average and idle detectors still read 0.
2026-11-06 07:43:38 -07:00
Josh HawkinsandGitHub 1cb5ec4107 Miscellaneous fixes (#24878)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* don't calibrate or skip capability checks when autotracking is enabled at runtime

Enabling autotracking from the UI on a camera that had been disabled for missing FOV relative movement or MoveStatus skipped both checks because ONVIF was already initialized, then ran calibration on the object processing thread and hung waiting for a motor stop that never came. Setup now runs the capability checks every time, only calibrates at startup, stops tracking the triggering object when setup disables the camera, and publishes the OFF state so the toggle reflects it.

* only show settings reset buttons when the reset can succeed

Reset to Global and Reset to Default delete the section from config.yml, and the delete raised a KeyError (HTTP 500) when the key wasn't in the file. Camera live sections always read as overridden because the backend fills in live.streams per camera, and the global Reset to Default button showed for every section, including ones the user never wrote. Global reset now only shows when the section differs from its schema defaults, ignoring fields the backend resolves at runtime (ffmpeg and record export hwaccel_args, audio filters). Camera live overrides skip streams, ffmpeg overrides skip inputs, and reset is hidden for camera ffmpeg and detection models, where deleting the section can't produce a valid config. Birdseye modes uses a plain default so the schema carries it.

* Added an e2e spec covering reset visibility for default and customized global sections, the runtime-resolved fields, camera live overrides, and camera ffmpeg
2026-11-04 16:23:27 -07:00
Nicolas MowenandGitHub 18a2eccab1 Refactor to use async openai (#24874) 2026-11-04 15:38:11 -06:00
Nicolas MowenandGitHub be73c2bbc9 Fix reading labels when there are multiple sub labels that match (#24873)
* Fix reading labels when there are multiple that match

* Pull from event DB directly
2026-11-04 15:37:44 -06:00
GeoandGitHub 169c5e1303 fix(genai): run blocking OpenAI chat calls in worker threads (#24868)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* Don't block the event loop in OpenAI chat streaming

* Close OpenAI stream opened after the caller disconnected

* Show GenAI worker thread usage in system metrics

* Remove GenAI thread stats per review

* Use Starlette iterate_in_threadpool for OpenAI stream
2026-11-04 10:32:16 -07:00
Nicolas MowenandGitHub 2b4397bcba Convert sensitive wall clock usages to monotonic (#24864)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-11-02 16:33:55 -06:00
Nicolas MowenandGitHub 9379f81edf Adjust docs for new HA features for 0.19 (#24860) 2026-11-02 12:45:59 -06:00
Nicolas MowenandGitHub 47ea942590 Optimize review summaries for consistency (#24854)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-11-02 07:43:14 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d5b59f9edd Bump the web-minor-patch group in /web with 8 updates (#24859)
Bumps the web-minor-patch group in /web with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@cycjimmy/jsmpeg-player](https://github.com/cycjimmy/jsmpeg-player) | `6.1.6` | `6.1.8` |
| [apexcharts](https://github.com/apexcharts/apexcharts.js) | `8.5.0` | `8.6.0` |
| [framer-motion](https://github.com/motiondivision/motion) | `14.7.1` | `14.9.0` |
| [i18next](https://github.com/i18next/i18next) | `26.5.11` | `26.5.12` |
| [i18next-http-backend](https://github.com/i18next/i18next-http-backend) | `4.0.6` | `4.0.7` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.64.0` | `1.65.0` |
| [react-apexcharts](https://github.com/apexcharts/react-apexcharts) | `2.1.2` | `2.2.0` |
| [eslint](https://github.com/eslint/eslint) | `10.13.0` | `10.14.0` |


Updates `@cycjimmy/jsmpeg-player` from 6.1.6 to 6.1.8
- [Release notes](https://github.com/cycjimmy/jsmpeg-player/releases)
- [Changelog](https://github.com/cycjimmy/jsmpeg-player/blob/main/docs/CHANGELOG.md)
- [Commits](https://github.com/cycjimmy/jsmpeg-player/compare/v6.1.6...v6.1.8)

Updates `apexcharts` from 8.5.0 to 8.6.0
- [Release notes](https://github.com/apexcharts/apexcharts.js/releases)
- [Commits](https://github.com/apexcharts/apexcharts.js/compare/v8.5.0...v8.6.0)

Updates `framer-motion` from 14.7.1 to 14.9.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v14.7.1...v14.9.0)

Updates `i18next` from 26.5.11 to 26.5.12
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.5.11...v26.5.12)

Updates `i18next-http-backend` from 4.0.6 to 4.0.7
- [Changelog](https://github.com/i18next/i18next-http-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-http-backend/compare/v4.0.6...v4.0.7)

Updates `lucide-react` from 1.64.0 to 1.65.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.65.0/packages/lucide-react)

Updates `react-apexcharts` from 2.1.2 to 2.2.0
- [Release notes](https://github.com/apexcharts/react-apexcharts/releases)
- [Commits](https://github.com/apexcharts/react-apexcharts/commits/v2.2.0)

Updates `eslint` from 10.13.0 to 10.14.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.13.0...v10.14.0)

---
updated-dependencies:
- dependency-name: "@cycjimmy/jsmpeg-player"
  dependency-version: 6.1.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: apexcharts
  dependency-version: 8.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: framer-motion
  dependency-version: 14.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: i18next
  dependency-version: 26.5.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: i18next-http-backend
  dependency-version: 4.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: lucide-react
  dependency-version: 1.65.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: react-apexcharts
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: eslint
  dependency-version: 10.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 08:42:29 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
91bf509839 Update openai requirement from ==3.36.* to ==3.41.* in /docker/main (#24857)
Updates the requirements on [openai](https://github.com/openai/openai-python) to permit the latest version.
- [Release notes](https://github.com/openai/openai-python/releases)
- [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-python/compare/v3.36.0...v3.41.0)

---
updated-dependencies:
- dependency-name: openai
  dependency-version: 3.41.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 08:42:22 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dd97f48ab6 Update transformers requirement in /docker/main (#24856)
Updates the requirements on [transformers](https://github.com/huggingface/transformers) to permit the latest version.
- [Release notes](https://github.com/huggingface/transformers/releases)
- [Commits](https://github.com/huggingface/transformers/compare/v5.21...v5.22.1)

---
updated-dependencies:
- dependency-name: transformers
  dependency-version: 5.22.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 08:42:17 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
170a992b5a Update starlette requirement from ==1.9.* to ==1.10.* in /docker/main (#24855)
Updates the requirements on [starlette](https://github.com/Kludex/starlette) to permit the latest version.
- [Release notes](https://github.com/Kludex/starlette/releases)
- [Changelog](https://github.com/Kludex/starlette/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/starlette/compare/1.9.0...1.10.0)

---
updated-dependencies:
- dependency-name: starlette
  dependency-version: 1.10.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 08:42:11 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2a1d36f2ad Update fastapi requirement from ==0.159.* to ==0.162.* in /docker/main (#24858)
Updates the requirements on [fastapi](https://github.com/fastapi/fastapi) to permit the latest version.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.159.0...0.162.0)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-version: 0.162.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 08:42:05 -06:00
Josh HawkinsandGitHub 96ce75cf98 Miscellaneous fixes (#24846)
* miscellaneous fixes

- keep base path in recording share links
- reject non-positive scale on recordings/unavailable, which looped forever on the event loop
- reject negative vod event padding, which cleared has_clip
- clamp negative preview gif/mp4 offsets
- don't delete exports that are still in progress
- hash on unknown login usernames so timing doesn't reveal valid users
- include the first minute of the events time-of-day filter
- show an error when the push subscription is refused
- don't wipe an unnamed known plates entry when Add is pressed again
- use elapsed hours for export last N hours so DST doesn't shift the range

* fix live audio settings and trigger highlights

- start each camera group's audio from that group's saved settings instead of merging every group
- don't clear the previous trigger's highlight timeout when another trigger fires

* address review feedback

- Discard the export row when an export fails before or during ffmpeg, so it doesn't stay in progress until a restart
- Skip the empty thumbnail path when discarding a failed export
- Measure preview clip duration from the clamped start
- Default live player volume to 1 when the group has no saved setting
- Clear the unsaved registration message when notification registration fails
- Keep a highlight timer per trigger
- Remove the severity order_by in /review, which the start_time order_by replaced

* Hold pandas 3.1 until numpy 2

* fix two-way talk shortcut and detail stream active item after camera switch

* show already running message when a media sync is in progress

* pick the earliest overlapping preview for preview gif and mp4

* unmute camera audio when starting two-way talk from the shortcut

* remove password strength meter and fix confirm password feedback

The strength meter still scored against the old 8 character and character class rules, so any password with a digit, like test1234, showed a full green "Very Strong" bar next to the failing 12 character requirement. The meter is gone and the length checklist is the only feedback. The password field's form message duplicated the checklist, and editing the password never revalidated the confirm field, so a fixed mismatch kept showing "Passwords do not match". Editing the password now revalidates the confirm field once it has a value, and both dialogs show a live match/mismatch indicator in place of the confirm form message.
2026-11-02 08:11:27 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0f00cae567 Bump follow-redirects (#24847)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
Bumps the web-security group with 1 update in the /web directory: [follow-redirects](https://github.com/follow-redirects/follow-redirects).


Updates `follow-redirects` from 1.16.1 to 1.16.2
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.16.1...v1.16.2)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.2
  dependency-type: indirect
  dependency-group: web-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 08:07:57 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Josh Hawkins
63c12cf135 Bump the web-minor-patch group in /web with 18 updates (#24844)
* Bump the web-minor-patch group in /web with 18 updates

Bumps the web-minor-patch group in /web with 18 updates:

| Package | From | To |
| --- | --- | --- |
| [@cycjimmy/jsmpeg-player](https://github.com/cycjimmy/jsmpeg-player) | `6.1.2` | `6.1.6` |
| [@hookform/resolvers](https://github.com/react-hook-form/resolvers) | `6.0.0` | `6.0.2` |
| [apexcharts](https://github.com/apexcharts/apexcharts.js) | `8.4.1` | `8.5.0` |
| [axios](https://github.com/axios/axios) | `1.20.0` | `1.21.0` |
| [framer-motion](https://github.com/motiondivision/motion) | `14.6.0` | `14.7.1` |
| [hls.js](https://github.com/video-dev/hls.js) | `1.7.3` | `1.7.4` |
| [i18next](https://github.com/i18next/i18next) | `26.5.5` | `26.5.11` |
| [i18next-http-backend](https://github.com/i18next/i18next-http-backend) | `4.0.4` | `4.0.6` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.61.0` | `1.64.0` |
| [react-apexcharts](https://github.com/apexcharts/react-apexcharts) | `2.1.1` | `2.1.2` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.90.0` | `7.91.0` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.1.1` | `17.1.9` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.18.4` | `7.18.5` |
| [virtua](https://github.com/inokawa/virtua) | `0.53.6` | `0.54.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.6.5` | `26.6.6` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.6.1` | `10.6.2` |
| [postcss](https://github.com/postcss/postcss) | `8.5.31` | `8.5.32` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.72.0` | `8.72.1` |


Updates `@cycjimmy/jsmpeg-player` from 6.1.2 to 6.1.6
- [Release notes](https://github.com/cycjimmy/jsmpeg-player/releases)
- [Changelog](https://github.com/cycjimmy/jsmpeg-player/blob/main/docs/CHANGELOG.md)
- [Commits](https://github.com/cycjimmy/jsmpeg-player/compare/v6.1.2...v6.1.6)

Updates `@hookform/resolvers` from 6.0.0 to 6.0.2
- [Release notes](https://github.com/react-hook-form/resolvers/releases)
- [Commits](https://github.com/react-hook-form/resolvers/compare/v6.0.0...v6.0.2)

Updates `apexcharts` from 8.4.1 to 8.5.0
- [Release notes](https://github.com/apexcharts/apexcharts.js/releases)
- [Commits](https://github.com/apexcharts/apexcharts.js/compare/v8.4.1...v8.5.0)

Updates `axios` from 1.20.0 to 1.21.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.20.0...v1.21.0)

Updates `framer-motion` from 14.6.0 to 14.7.1
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v14.6.0...v14.7.1)

Updates `hls.js` from 1.7.3 to 1.7.4
- [Release notes](https://github.com/video-dev/hls.js/releases)
- [Changelog](https://github.com/video-dev/hls.js/blob/master/docs/release-process.md)
- [Commits](https://github.com/video-dev/hls.js/compare/v1.7.3...v1.7.4)

Updates `i18next` from 26.5.5 to 26.5.11
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.5.5...v26.5.11)

Updates `i18next-http-backend` from 4.0.4 to 4.0.6
- [Changelog](https://github.com/i18next/i18next-http-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-http-backend/compare/v4.0.4...v4.0.6)

Updates `lucide-react` from 1.61.0 to 1.64.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.64.0/packages/lucide-react)

Updates `react-apexcharts` from 2.1.1 to 2.1.2
- [Release notes](https://github.com/apexcharts/react-apexcharts/releases)
- [Commits](https://github.com/apexcharts/react-apexcharts/commits)

Updates `react-hook-form` from 7.90.0 to 7.91.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.90.0...v7.91.0)

Updates `react-i18next` from 17.1.1 to 17.1.9
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.1.1...v17.1.9)

Updates `react-router-dom` from 7.18.4 to 7.18.5
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router-dom@7.18.5/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.18.5/packages/react-router-dom)

Updates `virtua` from 0.53.6 to 0.54.0
- [Release notes](https://github.com/inokawa/virtua/releases)
- [Commits](https://github.com/inokawa/virtua/compare/0.53.6...0.54.0)

Updates `@types/node` from 26.6.5 to 26.6.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `autoprefixer` from 10.6.1 to 10.6.2
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.6.1...10.6.2)

Updates `postcss` from 8.5.31 to 8.5.32
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.31...8.5.32)

Updates `typescript-eslint` from 8.72.0 to 8.72.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.72.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@cycjimmy/jsmpeg-player"
  dependency-version: 6.1.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: "@hookform/resolvers"
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: apexcharts
  dependency-version: 8.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: axios
  dependency-version: 1.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: framer-motion
  dependency-version: 14.7.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: hls.js
  dependency-version: 1.7.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: i18next
  dependency-version: 26.5.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: i18next-http-backend
  dependency-version: 4.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: lucide-react
  dependency-version: 1.64.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: react-apexcharts
  dependency-version: 2.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: react-hook-form
  dependency-version: 7.91.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: react-i18next
  dependency-version: 17.1.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: react-router-dom
  dependency-version: 7.18.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: virtua
  dependency-version: 0.54.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: "@types/node"
  dependency-version: 26.6.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: autoprefixer
  dependency-version: 10.6.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: postcss
  dependency-version: 8.5.32
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: typescript-eslint
  dependency-version: 8.72.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Wait for wizard requests in setup specs

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-11-01 07:55:52 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Josh Hawkins
e82dc7ad13 Update google-genai requirement from ==2.32.* to ==2.34.* in /docker/main in the pydantic-genai group (#24839)
* Update google-genai requirement

Updates the requirements on [google-genai](https://github.com/googleapis/python-genai) to permit the latest version.

Updates `google-genai` to 2.34.0
- [Release notes](https://github.com/googleapis/python-genai/releases)
- [Changelog](https://github.com/googleapis/python-genai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-genai/compare/v2.32.0...v2.34.0)

---
updated-dependencies:
- dependency-name: google-genai
  dependency-version: 2.34.0
  dependency-type: direct:production
  dependency-group: pydantic-genai
...

Signed-off-by: dependabot[bot] <support@github.com>

* Remove type ignores google-genai 2.34 no longer needs

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-11-01 07:55:33 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
689a5b59ac Update sherpa-onnx requirement from ==1.13.* to ==2.0.* in /docker/main (#24842)
---
updated-dependencies:
- dependency-name: sherpa-onnx
  dependency-version: 2.0.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 07:41:48 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
69061d17f1 Update transformers requirement in /docker/main (#24841)
Updates the requirements on [transformers](https://github.com/huggingface/transformers) to permit the latest version.
- [Release notes](https://github.com/huggingface/transformers/releases)
- [Commits](https://github.com/huggingface/transformers/compare/v5.20.0...v5.21.1)

---
updated-dependencies:
- dependency-name: transformers
  dependency-version: 5.21.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 07:41:40 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
efb890e6e0 Bump the pip-main-minor-patch group in /docker/main with 3 updates (#24840)
Bumps the pip-main-minor-patch group in /docker/main with 3 updates: [ruff](https://github.com/astral-sh/ruff), [mypy](https://github.com/python/mypy) and [tzdata](https://github.com/python/tzdata).


Updates `ruff` from 0.17.1 to 0.17.3
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.17.1...0.17.3)

Updates `mypy` from 2.4.0 to 2.4.1
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v2.4.0...v2.4.1)

Updates `tzdata` from 2026.5 to 2026.6
- [Release notes](https://github.com/python/tzdata/releases)
- [Changelog](https://github.com/python/tzdata/blob/master/NEWS.md)
- [Commits](https://github.com/python/tzdata/compare/2026.5...2026.6)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.17.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: pip-main-minor-patch
- dependency-name: mypy
  dependency-version: 2.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pip-main-minor-patch
- dependency-name: tzdata
  dependency-version: '2026.6'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pip-main-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 07:41:30 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ea7b3e2336 Bump the actions group across 2 directories with 1 update (#24845)
Bumps the actions group with 1 update in the / directory: [docker/login-action](https://github.com/docker/login-action).
Bumps the actions group with 1 update in the /.github/actions/setup directory: [docker/login-action](https://github.com/docker/login-action).


Updates `docker/login-action` from 4.6.0 to 4.7.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

Updates `docker/login-action` from 4.6.0 to 4.7.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 07:41:08 -06:00
Josh HawkinsandGitHub a0b8d40a66 guard relativemove failures (#24835)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-10-31 14:15:24 -06:00
Josh HawkinsandGitHub 9b0aa75312 Add configurable notification TTL (#24829)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* add configurable ttl for active alert notifications

Notifications sent while an alert was active always used a TTL of 0, so the push service dropped them if the device wasn't reachable at that moment and users only received the notification sent when the alert ended. A new `ttl` notifications option, settable globally or per camera, lets the push service retry these notifications for that many seconds. It defaults to 0, and the settings UI warns that a non-zero value can deliver stale notifications.

* make notification ttl a global advanced setting

- Read `ttl` from the global notifications config and hide it on the camera form
- Show it under Advanced Settings on the Notifications page
- Remove extra padding that misaligned the Notifications cards with the rest of the form
- Shorten the ttl warning

* tweaks

* use enum instead of raw seconds value
2026-10-30 12:52:22 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Josh Hawkins
a6b2a29c4c Bump protobuf from 3.20.3 to 5.29.6 in /docker/tensorrt in the pip-trt-security group across 1 directory (#24825)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* Bump protobuf

Bumps the pip-trt-security group with 1 update in the /docker/tensorrt directory: [protobuf](https://developers.google.com/protocol-buffers/).


Updates `protobuf` from 3.20.3 to 5.29.6

---
updated-dependencies:
- dependency-name: protobuf
  dependency-version: 5.29.6
  dependency-type: direct:production
  dependency-group: pip-trt-security
...

Signed-off-by: dependabot[bot] <support@github.com>

* keep amd64 tensorrt protobuf on 7.36

Dependabot moved every protobuf pin in docker/tensorrt to 5.29, but the security alerts only cover the aarch64 pin. On amd64 that would downgrade protobuf below what TensorFlow 2.21 requires (>=6.31.1), and the image would build with a pip warning and fail at import tensorflow. This keeps the aarch64 change to 5.29.6, which is the same change verified on #24350 (onnx 1.14.0 and TensorFlow 2.19 both work on it).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-10-29 14:04:22 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
40aa2da22a Bump the web-security group across 1 directory with 2 updates (#24826)
Bumps the web-security group with 2 updates in the /web directory: [fast-uri](https://github.com/fastify/fast-uri) and [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser).


Updates `fast-uri` from 3.1.8 to 3.1.9
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9)

Updates `fast-uri` from 4.2.1 to 4.2.3
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9)

Updates `postcss-selector-parser` from 6.1.4 to 6.1.5
- [Release notes](https://github.com/postcss/postcss-selector-parser/releases)
- [Changelog](https://github.com/postcss/postcss-selector-parser/blob/6.1.5/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-selector-parser/compare/6.1.4...6.1.5)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.9
  dependency-type: indirect
  dependency-group: web-security
- dependency-name: fast-uri
  dependency-version: 4.2.3
  dependency-type: indirect
  dependency-group: web-security
- dependency-name: postcss-selector-parser
  dependency-version: 6.1.5
  dependency-type: indirect
  dependency-group: web-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 13:16:34 -05:00
Nicolas MowenandGitHub 3dd59d9a44 Optimize Python Tests (#24823)
* Optimize tests

* Address comments

* Fix test
2026-10-29 12:39:21 -05:00
Nicolas MowenandGitHub bbf23ccce7 Refactor TFL so code is shared among all detectors (#24822)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-10-29 08:49:18 -06:00
Josh HawkinsandGitHub 4953c19a71 Miscellaneous fixes (#24820)
* keep base path in logout redirect

* store manual event sub labels as verified objects

Manual events created with a sub label were stored in the review item as "label: sub_label", so review previews and collapsed History cards showed a generic icon, the review label filter missed them, and the sub label never reached sub_labels for chips, notifications, or GenAI prompts. The object processor now publishes the label and sub label separately, and the review maintainer stores them as label-verified plus a sub_labels entry, the same as tracked objects.

* only expire snapshots for events that still have one

Snapshot expiry selected every event older than snapshot retention, including events whose snapshot was already deleted, so with snapshot retention shorter than alert/detection retention the same missing files were unlinked again every cleanup cycle until the event row expired. That was nearly free on local disk but caused constant metadata load on network shares. The expiry queries now filter on has_snapshot so each snapshot is deleted once.
2026-10-29 09:04:16 -05:00
Nicolas MowenandGitHub ff2cd6a417 Add LFM to recommended local models (#24812) 2026-10-27 21:58:47 -05:00
177 changed files with 8209 additions and 1169 deletions
+1
View File
@@ -181,6 +181,7 @@ noninteractive
noprint
Norfair
nptype
NTFS
NTSC
numpy
nvenc
+9
View File
@@ -0,0 +1,9 @@
{
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {
"version": "2.7.1",
"resolved": "ghcr.io/devcontainers/features/common-utils@sha256:e76a6a8a35d1ad824daad1540725e0ec9f8ed2dc00d2850e990a9830013c7d08",
"integrity": "sha256:e76a6a8a35d1ad824daad1540725e0ec9f8ed2dc00d2850e990a9830013c7d08"
}
}
}
+1 -1
View File
@@ -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@dbcb813823bdd20940b903addbd779551569679f
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
with:
registry: ghcr.io
username: ${{ github.actor }}
+3
View File
@@ -55,6 +55,9 @@ 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"]
+1 -1
View File
@@ -590,7 +590,7 @@ jobs:
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
with:
registry: ghcr.io
username: ${{ github.actor }}
+3 -1
View File
@@ -124,5 +124,7 @@ 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 unittest"
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m frigate.test"
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
with:
registry: ghcr.io
username: ${{ github.actor }}
+4 -4
View File
@@ -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 unittest` to run tests
- **Testing**: unittest framework - use `python3 -u -m frigate.test` to run the suite in parallel groups
- **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 unittest`
- **Run Command**: `python3 -u -m frigate.test` (parallel groups; plain `python3 -u -m unittest` runs serially)
- **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
python3 -u -m unittest
# Run all tests in parallel groups (-j to set the number of jobs)
python3 -u -m frigate.test
# Run specific test file
python3 -u -m unittest frigate.test.test_ffmpeg_presets
+2 -2
View File
@@ -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 unittest`
- **Testing**: `python3 -u -m frigate.test` (runs the suite in parallel groups)
- **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 unittest # Run all tests
python3 -u -m frigate.test # Run all tests in parallel groups
python3 -u -m unittest frigate.test.test_ffmpeg_presets # Run specific test
ruff format frigate # Format
ruff check frigate # Lint
+1 -1
View File
@@ -54,7 +54,7 @@ run: local
run_tests: local
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \
python3 -u -m unittest
python3 -u -m frigate.test
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \
python3 -u -m mypy --config-file frigate/mypy.ini frigate
+1
View File
@@ -383,6 +383,7 @@ 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 -1
View File
@@ -1,4 +1,4 @@
ruff == 0.17.1
ruff == 0.17.3
# types
types-peewee == 4.0.*
+8 -8
View File
@@ -2,9 +2,9 @@ aiofiles == 25.1.*
click == 8.5.*
# FastAPI
aiohttp == 3.14.*
starlette == 1.9.*
starlette == 1.10.*
starlette-context == 0.5.*
fastapi[standard-no-fastapi-cloud-cli] == 0.159.*
fastapi[standard-no-fastapi-cloud-cli] == 0.162.*
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.0
mypy == 2.4.1
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.5
tzdata == 2026.6
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.20.*
transformers == 5.22.*
# Generative AI
google-genai == 2.32.*
google-genai == 2.34.*
ollama == 0.6.*
openai == 3.36.*
openai == 3.41.*
# 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==1.13.*
sherpa-onnx==2.0.*
faster-whisper==1.2.*
librosa==0.11.*
soundfile==0.14.*
+1
View File
@@ -25,6 +25,7 @@ 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
+1
View File
@@ -37,6 +37,7 @@ 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
+1
View File
@@ -15,3 +15,4 @@ ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:${INCLUDED_FFMPEG_VERSIO
WORKDIR /opt/frigate/
COPY --from=rootfs / /
ENV FRIGATE_IMAGE_VARIANT=rpi
+1
View File
@@ -22,6 +22,7 @@ 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
+1
View File
@@ -25,6 +25,7 @@ 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
+1
View File
@@ -151,6 +151,7 @@ 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 == 3.20.3; platform_machine == 'aarch64'
protobuf == 5.29.6; platform_machine == 'aarch64'
@@ -0,0 +1,41 @@
---
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 />
@@ -578,6 +578,10 @@ 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
@@ -1214,6 +1218,9 @@ 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
+15 -8
View File
@@ -75,11 +75,12 @@ 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) | 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. |
| 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. |
#### Embedding models
@@ -95,7 +96,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. |
@@ -343,7 +344,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-2.5-flash`)
- Set **Model** to the desired model (e.g., `gemini-3.5-flash-lite`)
</TabItem>
<TabItem value="yaml">
@@ -353,12 +354,18 @@ genai:
my_provider:
provider: gemini
api_key: "{FRIGATE_GEMINI_API_KEY}"
model: gemini-2.5-flash
model: gemini-3.5-flash-lite
```
</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:
+24 -1
View File
@@ -43,7 +43,7 @@ Notifications will be prevented if either:
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Notifications > Notifications" />.
1. Navigate to <NavPath path="Settings > Notifications" />.
- Set **Email** to your email address
- Enable notifications for the desired cameras
@@ -108,6 +108,29 @@ 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.
+1 -1
View File
@@ -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 cannot be used under Home Assistant OS, which does not include the NPU firmware.
Intel NPUs can be used under Home Assistant OS 18.4 or newer.
### Configuration {#configuration-openvino}
+2 -2
View File
@@ -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 unittest
python3 -u -m frigate.test
```
#### 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 unittest
python3 -u -m frigate.test
```
- 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.
+2 -5
View File
@@ -763,20 +763,17 @@ 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 are not supported because HA OS does not include the NPU firmware.
- Intel NPUs require Home Assistant OS 18.4 or newer. Earlier releases do not include the NPU firmware.
- Nvidia GPUs are not supported because HA Apps do not support the Nvidia runtime.
:::
:::tip
See [the network storage guide](/guides/ha_network_storage.md) for instructions to setup network storage for frigate.
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.
:::
+10 -1
View File
@@ -135,6 +135,15 @@ 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.
@@ -171,7 +180,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.
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).
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.
+20 -12
View File
@@ -1,40 +1,48 @@
---
id: ha_network_storage
title: Home Assistant network storage
title: Home Assistant storage
---
As of Home Assistant 2023.6, Network Mounted Storage is supported for Apps.
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.
## Setting Up Remote Storage For Frigate
## Prerequisites
### Prerequisites
### Network storage
- 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 the data will need to be moved regardless so the share can be created successfully.
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.
#### If you want to keep the current data
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
1. 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
### Create the media share
### Add the storage
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
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
5. Connect
6. Move files from `/media/frigate_tmp` to `/media/frigate` if they were kept in previous step
7. Start the Frigate App
+1
View File
@@ -130,6 +130,7 @@ const sidebars: SidebarsConfig = {
label: "Advanced Configuration",
items: [
"configuration/advanced/system",
"configuration/advanced/analytics",
"configuration/advanced/reference",
{
type: "link",
@@ -0,0 +1,60 @@
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>
);
}
File diff suppressed because it is too large Load Diff
+21
View File
@@ -10,6 +10,25 @@ 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:
@@ -6371,6 +6390,7 @@ paths:
required: false
schema:
type: integer
minimum: 0
description: Padding to apply to the vod.
default: 0
title: Padding
@@ -7576,6 +7596,7 @@ paths:
required: false
schema:
type: integer
minimum: 1
default: 30
title: Scale
responses:
+1
View File
@@ -0,0 +1 @@
"""Opt-in anonymous analytics reports."""
+1
View File
@@ -0,0 +1 @@
"""One collector per report section."""
+238
View File
@@ -0,0 +1,238 @@
"""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"]),
),
)
+37
View File
@@ -0,0 +1,37 @@
"""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}
+69
View File
@@ -0,0 +1,69 @@
"""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")),
)
+150
View File
@@ -0,0 +1,150 @@
"""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(),
)
+114
View File
@@ -0,0 +1,114 @@
"""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),
)
+63
View File
@@ -0,0 +1,63 @@
"""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),
)
+66
View File
@@ -0,0 +1,66 @@
"""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,
)
+13
View File
@@ -0,0 +1,13 @@
"""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)
+91
View File
@@ -0,0 +1,91 @@
"""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)
+218
View File
@@ -0,0 +1,218 @@
"""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)
+419
View File
@@ -0,0 +1,419 @@
"""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")
+86
View File
@@ -0,0 +1,86 @@
"""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)
+54
View File
@@ -0,0 +1,54 @@
"""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
+25
View File
@@ -0,0 +1,25 @@
"""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"))
+6 -1
View File
@@ -934,7 +934,8 @@ def profile(request: Request):
)
def logout(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth
response = RedirectResponse("/login", status_code=303)
base_path = request.headers.get("x-ingress-path", "").rstrip("/")
response = RedirectResponse(f"{base_path}/login", status_code=303)
response.delete_cookie(auth_config.cookie_name)
return response
@@ -965,6 +966,10 @@ 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)
+3 -1
View File
@@ -1,5 +1,6 @@
"""Chat and LLM tool calling APIs."""
import asyncio
import base64
import json
import logging
@@ -1653,7 +1654,8 @@ async def chat_completion(
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
f"with {len(conversation)} message(s) in conversation"
)
response = genai_client.chat_with_tools(
response = await asyncio.to_thread(
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
from pydantic import BaseModel, Field
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 = 30
scale: int = Field(default=30, ge=1)
class RecordingsDeleteQueryParams(BaseModel):
+1
View File
@@ -2,6 +2,7 @@ from enum import Enum
class Tags(Enum):
analytics = "Analytics"
app = "App"
auth = "Auth"
camera = "Camera"
+4 -4
View File
@@ -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
+1 -1
View File
@@ -1227,7 +1227,7 @@ def bulk_delete_exports(body: ExportBulkDeleteBody):
files_in_use = _get_files_in_use()
for export in exports:
if export.video_path.split("/")[-1] in files_in_use:
if export.in_progress or export.video_path.split("/")[-1] in files_in_use:
return JSONResponse(
content={
"success": False,
+3 -1
View File
@@ -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,6 +31,7 @@ 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 (
@@ -143,6 +144,7 @@ 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)
+9 -5
View File
@@ -862,7 +862,7 @@ async def vod_hour(
async def vod_event(
request: Request,
event_id: str,
padding: int = Query(0, description="Padding to apply to the vod."),
padding: int = Query(0, ge=0, description="Padding to apply to the vod."),
):
try:
event: Event = Event.get(Event.id == event_id)
@@ -1521,6 +1521,7 @@ 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()
)
@@ -1530,7 +1531,8 @@ async def preview_gif(
status_code=404,
)
diff = start_ts - preview.start_time
clip_start = max(start_ts, preview.start_time)
diff = clip_start - preview.start_time
minutes = int(diff / 60)
seconds = int(diff % 60)
config: FrigateConfig = request.app.frigate_config
@@ -1542,7 +1544,7 @@ async def preview_gif(
"-ss",
f"00:{minutes}:{seconds}",
"-t",
f"{end_ts - start_ts}",
f"{end_ts - clip_start}",
"-i",
preview.path,
"-r",
@@ -1709,6 +1711,7 @@ 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()
)
@@ -1721,7 +1724,8 @@ async def preview_mp4(
status_code=404,
)
diff = start_ts - preview.start_time
clip_start = max(start_ts, preview.start_time)
diff = clip_start - preview.start_time
minutes = int(diff / 60)
seconds = int(diff % 60)
config: FrigateConfig = request.app.frigate_config
@@ -1734,7 +1738,7 @@ async def preview_mp4(
"-ss",
f"00:{minutes}:{seconds}",
"-t",
f"{end_ts - start_ts}",
f"{end_ts - clip_start}",
"-i",
preview.path,
"-r",
+1 -2
View File
@@ -163,8 +163,7 @@ async def review(
# Apply ordering and limit
review_query = (
review_query.order_by(ReviewSegment.severity.asc())
.order_by(ReviewSegment.start_time.desc())
review_query.order_by(ReviewSegment.start_time.desc())
.limit(limit)
.dicts()
.iterator()
+13
View File
@@ -15,6 +15,7 @@ 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
@@ -524,6 +525,15 @@ 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)
@@ -697,6 +707,7 @@ 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()
@@ -798,6 +809,8 @@ 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()
+11 -7
View File
@@ -7,6 +7,7 @@ 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
@@ -22,6 +23,7 @@ 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,
@@ -184,11 +186,13 @@ 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: 0 # type: ignore[misc]
c.name: float("-inf") # type: ignore[misc]
for c in self.config.cameras.values()
}
self.last_notification_time: float = 0
self.last_notification_time: float = float("-inf")
self.user_cameras: dict[str, set[str]] = {}
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
self.notification_thread: threading.Thread | None = None
@@ -343,7 +347,7 @@ class WebPushClient(Communicator):
if "add" in updates:
for camera in updates["add"]:
self.suspended_cameras[camera] = 0
self.last_camera_notification_time[camera] = 0
self.last_camera_notification_time[camera] = float("-inf")
self._refresh_user_cameras()
@@ -498,7 +502,7 @@ class WebPushClient(Communicator):
return camera in allowed
def _within_cooldown(self, camera: str) -> bool:
now = datetime.datetime.now().timestamp()
now = time.monotonic()
if now - self.last_notification_time < self.config.notifications.cooldown:
logger.debug(
f"Skipping notification for {camera} - in global cooldown period"
@@ -543,7 +547,7 @@ class WebPushClient(Communicator):
camera_name: str = getattr(
self.config.cameras[camera], "friendly_name", None
) or titlecase(camera.replace("_", " "))
current_time = datetime.datetime.now().timestamp()
current_time = time.monotonic()
if self._within_cooldown(camera):
return
@@ -623,7 +627,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 0
ttl = 3600 if ended else NOTIFICATION_TTL_SECONDS[self.config.notifications.ttl]
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
@@ -656,7 +660,7 @@ class WebPushClient(Communicator):
camera_name: str = getattr(
self.config.cameras[camera], "friendly_name", None
) or titlecase(camera.replace("_", " "))
current_time = datetime.datetime.now().timestamp()
current_time = time.monotonic()
if self._within_cooldown(camera):
return
+2 -7
View File
@@ -57,11 +57,6 @@ 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,
@@ -84,7 +79,7 @@ class BirdseyeConfig(FrigateBaseModel):
description="Enable or disable the Birdseye view feature.",
)
modes: list[BirdseyeModeEnum] = Field(
default_factory=default_birdseye_modes,
default=[BirdseyeModeEnum.all_objects],
title="Activity types",
description="Activity types that include cameras in Birdseye.",
)
@@ -139,7 +134,7 @@ class BirdseyeCameraConfig(BaseModel):
description="Enable or disable the Birdseye view feature.",
)
modes: list[BirdseyeModeEnum] = Field(
default_factory=default_birdseye_modes,
default=[BirdseyeModeEnum.all_objects],
title="Activity types",
description="Activity types that include cameras in Birdseye.",
)
+19 -1
View File
@@ -1,10 +1,23 @@
from enum import Enum
from pydantic import Field
from frigate.util.env import EnvString
from ..base import FrigateBaseModel
__all__ = ["NotificationConfig"]
__all__ = ["NotificationConfig", "NotificationTTLEnum", "NOTIFICATION_TTL_SECONDS"]
class NotificationTTLEnum(str, Enum):
immediate = "immediate"
reliable = "reliable"
NOTIFICATION_TTL_SECONDS = {
NotificationTTLEnum.immediate: 0,
NotificationTTLEnum.reliable: 30,
}
class NotificationConfig(FrigateBaseModel):
@@ -24,6 +37,11 @@ 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",
+17
View File
@@ -1,9 +1,14 @@
"""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.
@@ -23,12 +28,24 @@ 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")
+5
View File
@@ -29,6 +29,11 @@ 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",
+3
View File
@@ -102,6 +102,9 @@ 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"
@@ -12,6 +12,8 @@ import math
from collections.abc import Sequence
from typing import Any
from titlecase import titlecase
from frigate.models import Event, Timeline
logger = logging.getLogger(__name__)
@@ -84,12 +86,16 @@ def event_name(event: dict[str, Any]) -> str:
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")
if sub_label:
return f'{label} "{sub_label}"'
return f'{label} "{titlecase(str(sub_label).replace("_", " "))}"'
article = "an" if label[:1].lower() in "aeiou" else "a"
return f"{article} {label}"
@@ -213,6 +219,7 @@ 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.
@@ -222,6 +229,12 @@ 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
@@ -249,6 +262,13 @@ 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)
@@ -259,8 +279,33 @@ 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 merges:
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:
heading = leg_heading(points, legs[0])
timeline.append(
(detected_at, f"{name} first detected at {where}, moving {heading}")
@@ -277,11 +322,7 @@ def build_timeline(
)
)
moments.extend(
(timestamp, phrase)
for timestamp, phrase in changes_by_event.get(event["id"], [])
if timestamp >= detected_at
)
moments.extend(event_changes)
for timestamp, phrase in moments:
if timestamp <= span_end:
@@ -390,7 +431,10 @@ def build_frame_captions(
timeline.extend(
(timestamp, f"[tracker] {note}")
for timestamp, note in build_timeline(
events, span_end, get_state_changes(detection_ids)
events,
span_end,
get_state_changes(detection_ids),
span_start=frame_times[0],
)
)
@@ -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 Recordings, ReviewSegment
from frigate.models import Event, 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,13 +439,22 @@ 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)
self.start_analysis(
camera_config,
final_data,
frames,
get_named_objects(
final_data["data"].get("detections") or [],
self.config.all_attributes,
),
)
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]
@@ -481,6 +490,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
camera_config.review.genai,
sorted(self.config.all_labels),
self.config.all_attributes,
named_objects,
),
).start()
@@ -741,6 +751,29 @@ 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,
@@ -752,6 +785,7 @@ 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()
@@ -776,11 +810,20 @@ def run_analysis(
unified_objects = []
objects_list = final_data["data"]["objects"]
sub_labels_list = final_data["data"]["sub_labels"]
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("_", " "))
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("_", " "))
unified_objects.append(f"{name} ← {object_type}")
for label in objects_list:
@@ -3,6 +3,7 @@
import datetime
import logging
import os
import time
from typing import Any
import cv2
@@ -67,7 +68,8 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
else:
self.inference_speed = None
self.last_run = datetime.datetime.now().timestamp()
# monotonic so a backward wall clock step can't pause classification
self.last_run = time.monotonic()
self.__build_detector()
def __build_detector(self) -> None:
@@ -215,11 +217,12 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
should_run = False
now = datetime.datetime.now().timestamp()
mono_now = time.monotonic()
if (
self.model_config.state_config.interval
and now > self.last_run + self.model_config.state_config.interval
and mono_now > self.last_run + self.model_config.state_config.interval
):
self.last_run = now
self.last_run = mono_now
should_run = True
if (
@@ -228,8 +231,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 now > self.last_run + 1:
self.last_run = now
if mono_now > self.last_run + 1:
self.last_run = mono_now
should_run = True
# Shortcut: always run if we have a pending state verification to complete
@@ -237,9 +240,9 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
not should_run
and camera in self.state_history
and self.state_history[camera]["pending_state"] is not None
and now > self.last_run + 0.5
and mono_now > self.last_run + 0.5
):
self.last_run = now
self.last_run = mono_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)"
+315 -25
View File
@@ -1,8 +1,13 @@
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:
@@ -12,39 +17,324 @@ except ModuleNotFoundError:
logger = logging.getLogger(__name__)
def tflite_init(self, interpreter):
self.interpreter = interpreter
class TFLiteDetectionModel:
"""Object detection pre and post processing for a TFLite interpreter.
self.interpreter.allocate_tensors()
Every TFLite detector shares this, differing only in how it builds the
interpreter (on the CPU, or with the EdgeTPU or Teflon delegate).
"""
self.tensor_input_details = self.interpreter.get_input_details()
self.tensor_output_details = self.interpreter.get_output_details()
supported_models = [
ModelTypeEnum.ssd,
ModelTypeEnum.yologeneric,
]
def __init__(
self, interpreter: Interpreter, model_config: ModelConfig, detector_key: str
):
"""Prepare the interpreter and work out the model's output layout.
def tflite_detect_raw(self, tensor_input):
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
self.interpreter.invoke()
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
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])
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."
)
detections = np.zeros((20, 6), np.float32)
self.interpreter = interpreter
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],
]
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
return detections
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
def tflite_load_delegate_interpreter(
+6 -3
View File
@@ -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 tflite_detect_raw, tflite_init
from ..detector_utils import TFLiteDetectionModel
try:
from tflite_runtime.interpreter import Interpreter
@@ -40,6 +40,7 @@ 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
@@ -49,7 +50,9 @@ class CpuTfl(DetectionApi):
num_threads=detector_config.num_threads or 3,
)
tflite_init(self, interpreter)
self.tflite_model = TFLiteDetectionModel(
interpreter, detector_config.model, DETECTOR_KEY
)
def detect_raw(self, tensor_input):
return tflite_detect_raw(self, tensor_input)
return self.tflite_model.detect_raw(tensor_input)
+9 -303
View File
@@ -1,15 +1,13 @@
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, ModelTypeEnum
from frigate.util.model import xyxy_to_xywh_for_nms
from frigate.detectors.detector_config import BaseDetectorConfig
from ..detector_utils import TFLiteDetectionModel
try:
from tflite_runtime.interpreter import Interpreter, load_delegate
@@ -41,10 +39,7 @@ class EdgeTpuDetectorConfig(BaseDetectorConfig):
class EdgeTpuTfl(DetectionApi):
type_key = DETECTOR_KEY
supported_models = [
ModelTypeEnum.ssd,
ModelTypeEnum.yologeneric,
]
supported_models = TFLiteDetectionModel.supported_models
def __init__(self, detector_config: EdgeTpuDetectorConfig):
device_config = {}
@@ -60,7 +55,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")
self.interpreter = Interpreter(
interpreter = Interpreter(
model_path=detector_config.model.path,
experimental_delegates=[edge_tpu_delegate],
)
@@ -78,298 +73,9 @@ class EdgeTpuTfl(DetectionApi):
raise
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
self.tflite_model = TFLiteDetectionModel(
interpreter, detector_config.model, DETECTOR_KEY
)
def detect_raw(self, 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."
)
return self.tflite_model.detect_raw(tensor_input)
+6 -4
View File
@@ -7,8 +7,7 @@ from frigate.detectors.detection_api import DetectionApi
from frigate.detectors.detector_config import BaseDetectorConfig
from ..detector_utils import (
tflite_detect_raw,
tflite_init,
TFLiteDetectionModel,
tflite_load_delegate_interpreter,
)
@@ -30,6 +29,7 @@ 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,7 +39,9 @@ class TeflonTfl(DetectionApi):
interpreter = tflite_load_delegate_interpreter(
delegate_library, detector_config, device_config
)
tflite_init(self, interpreter)
self.tflite_model = TFLiteDetectionModel(
interpreter, detector_config.model, DETECTOR_KEY
)
def detect_raw(self, tensor_input):
return tflite_detect_raw(self, tensor_input)
return self.tflite_model.detect_raw(tensor_input)
+3
View File
@@ -93,6 +93,7 @@ class EventCleanup(threading.Thread):
Event.start_time < expire_after,
Event.label == event.label,
Event.retain_indefinitely == False,
Event.has_snapshot == True,
)
.namedtuples()
.iterator()
@@ -114,6 +115,7 @@ 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] = []
@@ -169,6 +171,7 @@ class EventCleanup(threading.Thread):
Event.start_time < expire_after,
Event.label == event.label,
Event.retain_indefinitely == False,
Event.has_snapshot == True,
)
.namedtuples()
.iterator()
+18 -10
View File
@@ -7,7 +7,8 @@ overrides what is genuinely Azure-specific:
- Client construction: parses ``api-version`` out of the configured
``base_url`` query string and instantiates :class:`openai.AzureOpenAI`
with ``azure_endpoint`` instead of ``base_url``. Raises if the URL is
(or :class:`openai.AsyncAzureOpenAI` for streaming chat) 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
@@ -29,9 +30,10 @@ the ``transcribe`` role:
"""
import logging
from typing import Any
from urllib.parse import parse_qs, urlparse
from openai import AzureOpenAI
from openai import AsyncAzureOpenAI, AzureOpenAI
from frigate.config import GenAIProviderEnum
from frigate.genai import register_genai_provider
@@ -44,8 +46,8 @@ logger = logging.getLogger(__name__)
class AzureOpenAIClient(OpenAIClient):
"""Generative AI client for Frigate using Azure OpenAI."""
def _init_provider(self) -> AzureOpenAI:
"""Initialize the AzureOpenAI client from the configured base_url."""
def _client_options(self) -> dict[str, Any]:
"""Build the Azure client options 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]
@@ -53,13 +55,19 @@ class AzureOpenAIClient(OpenAIClient):
if not api_version:
raise ValueError("Azure OpenAI base_url is missing api-version.")
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
return {
"api_key": self.genai_config.api_key,
"api_version": api_version,
"azure_endpoint": f"{parsed_url.scheme}://{parsed_url.netloc}/",
}
return AzureOpenAI(
api_key=self.genai_config.api_key,
api_version=api_version,
azure_endpoint=azure_endpoint,
)
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())
def get_context_size(self) -> int:
"""Azure does not reliably surface per-model context size; use 128K."""
+80 -2
View File
@@ -2,14 +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
@@ -21,6 +24,11 @@ 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."""
@@ -93,9 +101,14 @@ 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
@@ -232,6 +245,71 @@ 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]],
@@ -386,7 +464,7 @@ class GeminiClient(GenAIClient):
response = self.provider.models.generate_content(
model=self.genai_config.model,
contents=gemini_messages, # type: ignore[arg-type]
contents=gemini_messages,
config=types.GenerateContentConfig(**config_params),
)
@@ -648,7 +726,7 @@ class GeminiClient(GenAIClient):
stream = await self.provider.aio.models.generate_content_stream(
model=self.genai_config.model,
contents=gemini_messages, # type: ignore[arg-type]
contents=gemini_messages,
config=types.GenerateContentConfig(**config_params),
)
+64 -47
View File
@@ -7,7 +7,7 @@ from collections.abc import AsyncGenerator
from typing import Any
from httpx import TimeoutException
from openai import OpenAI
from openai import AsyncOpenAI, OpenAI
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
@@ -15,6 +15,7 @@ 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
@@ -45,8 +46,8 @@ class OpenAIClient(GenAIClient):
provider: OpenAI
context_size: int | None = None
def _init_provider(self) -> OpenAI:
"""Initialize the client.
def _client_options(self) -> dict[str, Any]:
"""Build the constructor options shared by the sync and async clients.
Subclasses (e.g. Azure) should raise on configuration errors; the
manager catches construction failures and disables the provider.
@@ -62,7 +63,19 @@ class OpenAIClient(GenAIClient):
if self.genai_config.base_url:
provider_opts["base_url"] = self.genai_config.base_url
return OpenAI(api_key=self.genai_config.api_key, **provider_opts)
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())
def _send(
self,
@@ -412,57 +425,61 @@ class OpenAIClient(GenAIClient):
finish_reason = "stop"
usage_stats: dict[str, Any] | None = None
stream = self.provider.chat.completions.create(**request_params)
# 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)
for chunk in stream:
chunk_usage = getattr(chunk, "usage", None)
if chunk_usage is not None:
usage_stats = _stats_from_openai_usage(chunk_usage)
if not chunk or not chunk.choices:
continue
if not chunk or not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
choice = chunk.choices[0]
delta = choice.delta
# Check for finish reason
if choice.finish_reason:
finish_reason = choice.finish_reason
# Check for finish reason
if choice.finish_reason:
finish_reason = choice.finish_reason
# 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 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 content deltas
if delta.content:
content_parts.append(delta.content)
yield ("content_delta", delta.content)
# Extract content deltas
if delta.content:
content_parts.append(delta.content)
yield ("content_delta", delta.content)
# Extract tool calls
if delta.tool_calls:
for tc in delta.tool_calls:
idx = tc.index
fn = tc.function
# Extract tool calls
if delta.tool_calls:
for tc in delta.tool_calls:
idx = tc.index
fn = tc.function
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": "",
}
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
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
+20 -11
View File
@@ -18,11 +18,17 @@ 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": "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.",
"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 — 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.',
"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.",
"shortSummary": "Briefly summarize the primary activity across the observations in one sentence. For named subjects, always use their name.",
"potential_threat_level": "Must be consistent with your scene description and the activity patterns above.",
}
@@ -31,19 +37,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.',
"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.",
"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.",
},
"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.',
"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.",
"shortSummary": "Summarize the primary activity in one short sentence. For named subjects, always use their name.",
},
"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.',
"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, including the most identifying visible detail, such as vehicle color or clothing.",
"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.",
},
}
@@ -61,9 +67,12 @@ 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 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.
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."""
- 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."""
def build_review_description_prompt(
+7 -6
View File
@@ -80,11 +80,12 @@ class VLMWatchRunner(threading.Thread):
self._broadcast_status()
self.conversation = [{"role": "system", "content": self._build_system_prompt()}]
max_end_time = self.job.start_time + self.job.max_duration_minutes * 60
# 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
try:
while not self.cancel_event.is_set():
if time.time() > max_end_time:
if time.monotonic() > max_end_time:
logger.debug(
"VLM watch job %s timed out after %d minutes",
self.job.id,
@@ -236,18 +237,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.time()
now = time.monotonic()
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.time()
remaining = deadline - time.monotonic()
if remaining <= 0:
break
if triggered and time.time() >= earliest_wake:
if triggered and time.monotonic() >= earliest_wake:
break
result = self.detection_subscriber.check_for_update(
@@ -287,7 +288,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.time()),
max(0, earliest_wake - time.monotonic()),
)
triggered = True
+1 -1
View File
@@ -209,7 +209,7 @@ class NoticeStats(Model):
mutes = IntegerField(default=0)
first_seen = DateTimeField()
last_seen = DateTimeField()
# watermarks for a future analytics reporter; unused until then
# lifetime counts when the current analytics install ID was created
reported_occurrences = IntegerField(default=0)
reported_acknowledgements = IntegerField(default=0)
reported_mutes = IntegerField(default=0)
+38 -18
View File
@@ -132,24 +132,7 @@ class NoticeRegistry:
def flush(self) -> None:
"""Write the repeats that batch_repeats kinds held back."""
with self._lock:
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
written = self._write_held()
if written:
self._notify()
@@ -385,6 +368,43 @@ 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,
+10
View File
@@ -99,6 +99,16 @@ _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,
),
)
+10 -9
View File
@@ -1,4 +1,3 @@
import datetime
import logging
import queue
import threading
@@ -24,7 +23,7 @@ from frigate.detectors.detector_config import (
InputDTypeEnum,
ModelConfig,
)
from frigate.util.builtin import EventsPerSecond, load_labels
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, load_labels
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
from frigate.util.process import FrigateProcess
@@ -129,7 +128,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 = avg_speed
self.avg_speed = InferenceSpeed(avg_speed)
self.start_time = start_time
self.config = config
self.detector_config = detector_config
@@ -169,9 +168,10 @@ class DetectorRunner(FrigateProcess):
logger.warning(f"Failed to get frame {connection_id} from SHM")
continue
# detect and send the output
self.start_time.value = datetime.datetime.now().timestamp()
# detect and send the output. start_time is monotonic so the watchdog
# doesn't mistake a wall clock step for a stuck detector
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.value = (self.avg_speed.value * 9 + duration) / 10
self.avg_speed.update(duration)
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 = avg_speed
self.avg_speed = InferenceSpeed(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.value = (self.avg_speed.value * 9 + duration) / 10
self.avg_speed.update(duration)
self.start_time.value = 0.0
def run(self) -> None:
@@ -328,7 +328,8 @@ class ObjectDetectProcess:
self.name = name
self.cameras = cameras
self.detection_queue = detection_queue
self.avg_inference_speed = Value("d", 0.01)
# stays 0 until the first inference, so stats can tell an idle detector
self.avg_inference_speed = Value("d", 0.0)
self.detection_start = Value("d", 0.0)
self.detect_process: FrigateProcess | None = None
self.config = config
+5 -4
View File
@@ -1,6 +1,5 @@
"""Handle outputting birdseye frames via jsmpeg and go2rtc."""
import datetime
import glob
import logging
import math
@@ -8,6 +7,7 @@ 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 = datetime.datetime.now().timestamp()
now = time.monotonic()
if (
len(self.active_cameras) == max_cameras
@@ -800,7 +800,8 @@ class BirdsEyeFrameManager:
camera_state["live_active"] = self.camera_live_active(modes, activity)
now = datetime.datetime.now().timestamp()
# monotonic so a backward wall clock step can't freeze the output
now = time.monotonic()
# limit output to 10 fps
if not force_update and (now - self.last_output_time) < 1 / 10:
@@ -945,7 +946,7 @@ class Birdseye:
coordinates = self.birdseye_manager.get_camera_coordinates()
self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates)
if self._idle_interval:
now = datetime.datetime.now().timestamp()
now = time.monotonic()
is_idle = len(self.birdseye_manager.camera_layout) == 0
if (
is_idle
+31 -25
View File
@@ -233,7 +233,8 @@ class PtzAutoTracker(threading.Thread):
and camera_config.onvif.autotracking.enabled_in_config
):
future = asyncio.run_coroutine_threadsafe(
self._autotracker_setup(camera_config, camera), self.onvif.loop
self._autotracker_setup(camera_config, camera, startup=True),
self.onvif.loop,
)
# Wait for the coroutine to complete
future.result()
@@ -261,7 +262,9 @@ class PtzAutoTracker(threading.Thread):
self.config_subscriber.stop()
logger.info("Exiting autotracker...")
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
async def _autotracker_setup(
self, camera_config: CameraConfig, camera: str, startup: bool = False
):
logger.debug(f"{camera}: Autotracker init")
self.tracked_object[camera] = None
@@ -288,35 +291,34 @@ 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
if self.onvif.cams[camera]["init"]:
await self.onvif.get_camera_status(camera)
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 camera_config.onvif.autotracking.calibrate_on_startup:
await self._calibrate_camera(camera)
if startup and 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
@@ -331,6 +333,7 @@ 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] = {}
@@ -1269,6 +1272,9 @@ 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
+6 -1
View File
@@ -625,7 +625,12 @@ class OnvifController:
move_request.Speed = move_speed
await cam["ptz"].RelativeMove(move_request)
try:
await cam["ptz"].RelativeMove(move_request)
except Exception as e:
logger.warning(
f"Onvif sending relative move request to {camera_name} failed: {e}"
)
# reset after the move request
move_request.Translation.PanTilt.x = 0
+6 -1
View File
@@ -1470,6 +1470,9 @@ 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
@@ -1479,7 +1482,8 @@ 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()
Path(thumb_path).unlink(missing_ok=True)
if thumb_path:
Path(thumb_path).unlink(missing_ok=True)
def _run_export(self, video_path: str, thumb_path: str) -> None:
try:
@@ -1498,6 +1502,7 @@ 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
+14 -10
View File
@@ -100,6 +100,14 @@ 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,
@@ -483,8 +491,6 @@ 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
@@ -894,9 +900,7 @@ 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.detections[manual_info["event_id"]] = (
manual_info["label"]
)
current_segment.add_manual_detection(manual_info)
if topic == DetectionTypeEnum.api:
severity = self.get_manual_event_severity(
camera, manual_info["label"]
@@ -921,9 +925,7 @@ class ReviewSegmentMaintainer(threading.Thread):
self.indefinite_events[camera][manual_info["event_id"]] = (
manual_info["label"]
)
current_segment.detections[manual_info["event_id"]] = (
manual_info["label"]
)
current_segment.add_manual_detection(manual_info)
if topic == DetectionTypeEnum.api:
if (
self.get_manual_event_severity(
@@ -1014,11 +1016,12 @@ 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:
@@ -1041,11 +1044,12 @@ 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:
+9 -1
View File
@@ -106,6 +106,14 @@ 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]]:
@@ -128,7 +136,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": detector.detection_start.value, # type: ignore[attr-defined]
"detection_start": _monotonic_to_wall(detector.detection_start.value), # type: ignore[attr-defined]
# issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards
"pid": pid,
+11
View File
@@ -0,0 +1,11 @@
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
+278
View File
@@ -0,0 +1,278 @@
"""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())
+36
View File
@@ -0,0 +1,36 @@
"""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 [],
)
+10 -2
View File
@@ -1,4 +1,12 @@
"""Constants for testing."""
TEST_DB = "test.db"
TEST_DB_CLEANUPS = ["test.db", "test.db-shm", "test.db-wal"]
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"}
+3 -3
View File
@@ -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
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
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")
migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
del logging.getLogger("peewee_migrate").handlers[:]
router = Router(migrate_db)
router.run()
migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB)
self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
self.db.bind(models)
# The export job manager broadcasts via JobStatePublisher on
@@ -0,0 +1,49 @@
"""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)
+8 -2
View File
@@ -393,8 +393,8 @@ class TestHttpApp(BaseTestHttp):
def test_event_time_filtering(self):
morning_id = "123456.random"
evening_id = "654321.random"
morning = 1656590400 # 06/30/2022 6 am (GMT)
evening = 1656633600 # 06/30/2022 6 pm (GMT)
morning = 1656568800 # 06/30/2022 6 am (GMT)
evening = 1656612000 # 06/30/2022 6 pm (GMT)
with AuthTestClient(self.app) as client:
super().insert_mock_event(morning_id, morning)
@@ -417,6 +417,12 @@ 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)
+24
View File
@@ -427,6 +427,30 @@ 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")
+14 -12
View File
@@ -1,11 +1,12 @@
import os
import shutil
from unittest.mock import MagicMock
import tempfile
from unittest.mock import MagicMock, patch
import cv2
import numpy as np
from frigate.output.preview import PREVIEW_CACHE_DIR, PREVIEW_FRAME_TYPE
from frigate.output.preview import PREVIEW_FRAME_TYPE
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@@ -15,14 +16,15 @@ class TestHttpLatestFrame(BaseTestHttp):
self.app = super().create_app()
self.app.detected_frames_processor = MagicMock()
if os.path.exists(PREVIEW_CACHE_DIR):
shutil.rmtree(PREVIEW_CACHE_DIR)
os.makedirs(PREVIEW_CACHE_DIR)
def tearDown(self):
if os.path.exists(PREVIEW_CACHE_DIR):
shutil.rmtree(PREVIEW_CACHE_DIR)
super().tearDown()
# a directory of its own so test groups running in parallel can't
# delete each other's preview frames
self.preview_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.preview_dir, ignore_errors=True)
cache_dir_patch = patch(
"frigate.output.preview.PREVIEW_CACHE_DIR", self.preview_dir
)
cache_dir_patch.start()
self.addCleanup(cache_dir_patch.stop)
def test_latest_frame_fallback_to_preview(self):
camera = "front_door"
@@ -45,7 +47,7 @@ class TestHttpLatestFrame(BaseTestHttp):
2,
)
preview_path = os.path.join(
PREVIEW_CACHE_DIR, f"preview_{camera}-1234567890.0.{PREVIEW_FRAME_TYPE}"
self.preview_dir, f"preview_{camera}-1234567890.0.{PREVIEW_FRAME_TYPE}"
)
cv2.imwrite(preview_path, dummy_frame)
@@ -80,7 +82,7 @@ class TestHttpLatestFrame(BaseTestHttp):
# 2. Create a dummy preview file
preview_path = os.path.join(
PREVIEW_CACHE_DIR, f"preview_{camera}-999.0.{PREVIEW_FRAME_TYPE}"
self.preview_dir, f"preview_{camera}-999.0.{PREVIEW_FRAME_TYPE}"
)
cv2.imwrite(preview_path, dummy_frame)
+4
View File
@@ -584,6 +584,10 @@ class TestHttpMedia(BaseTestHttp):
{"start_time": 1160, "end_time": 1300},
]
# a non-positive scale would never advance the gap scan
response = client.get("/recordings/unavailable", params={"scale": 0})
assert response.status_code == 422
def test_recordings_summary_day_attribution_by_start_time(self):
"""
A recording spanning midnight marks only its start day.
@@ -0,0 +1,133 @@
"""Tests for the cameras collector."""
import unittest
from frigate.analytics.collectors import cameras
from frigate.analytics.schema import HeightBucket, HwaccelKey, RetainBucket
from frigate.test.analytics_helpers import make_config, make_context
MASK = {"coordinates": "0,0,1,0,1,1"}
CONFIG = {
"audio_transcription": {"enabled": True},
"record": {
"enabled": True,
"continuous": {"days": 3},
"alerts": {"retain": {"days": 14}},
"detections": {"retain": {"days": 45}},
},
"cameras": {
"front": {
"ffmpeg": {
"hwaccel_args": "preset-vaapi",
"inputs": [
{
"path": "rtsp://127.0.0.1:8554/front_sub",
"roles": ["detect"],
"input_args": "preset-rtsp-restream",
},
{"path": "rtsp://127.0.0.1:8554/front", "roles": ["record"]},
],
},
"detect": {"enabled": True, "height": 720, "width": 1280, "fps": 5},
"zones": {"porch": MASK, "yard": MASK},
"motion": {"mask": {"tree": MASK}},
"objects": {
"mask": {"sign": MASK},
"filters": {"person": {"mask": {"bush": MASK}}},
},
"onvif": {"host": "10.0.0.9"},
},
"garage": {
"type": "lpr",
"ffmpeg": {
"hwaccel_args": ["-hwaccel", "vaapi"],
"inputs": [
{
"path": "rtsp://10.0.0.5/stream",
"roles": ["detect", "record", "audio"],
}
],
},
"audio": {"enabled": True},
"detect": {"height": 2160, "width": 3840, "fps": 15},
"record": {"enabled": False, "sub": {"enabled": True}},
},
"attic": {
"enabled": False,
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.6/stream", "roles": ["detect"]}]
},
"detect": {"enabled": True, "height": 480, "width": 640, "fps": 5},
},
"_replay_front": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1/replay", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
STATS = {
"cameras": {
"front": {"connection_quality": "excellent"},
"garage": {"connection_quality": "offline"},
"attic": {"connection_quality": "unusable"},
"_replay_front": {"connection_quality": "fair"},
}
}
class TestCamerasCollector(unittest.TestCase):
def test_aggregates_enabled_cameras_and_skips_replays(self):
section = cameras.collect(make_context(make_config(CONFIG), STATS))
self.assertEqual((section.total, section.enabled), (3, 2))
self.assertEqual(section.types, {"generic": 1, "lpr": 1})
self.assertEqual(section.detect_height, {"720": 1, "ge_2160": 1})
self.assertEqual(section.detect_fps, {"le_5": 1, "gt_10": 1})
self.assertEqual(section.hwaccel, {"vaapi": 1, "custom": 1})
self.assertEqual(section.input_preset, {"rtsp-restream": 1, "rtsp-generic": 1})
self.assertEqual(section.go2rtc_restream, 1)
self.assertEqual(section.separate_detect_stream, 1)
self.assertEqual((section.detect, section.record, section.onvif), (1, 1, 1))
self.assertEqual((section.zones, section.cameras_with_zones), (2, 1))
self.assertEqual((section.motion_masks, section.object_masks), (1, 2))
self.assertEqual((section.audio, section.audio_transcription), (1, 1))
self.assertEqual(section.sub_stream_record, 0)
self.assertEqual(section.connection_quality, {"excellent": 1})
def test_runtime_disabled_camera_skips_connection_quality(self):
config = make_config(CONFIG)
config.cameras["front"].enabled = False
section = cameras.collect(make_context(config, STATS))
self.assertEqual(section.connection_quality, {})
self.assertEqual(section.retain_days.continuous, {"1_7": 1})
self.assertEqual(section.retain_days.motion, {"0": 1})
self.assertEqual(section.retain_days.alerts, {"8_30": 1})
self.assertEqual(section.retain_days.detections, {"gt_30": 1})
def test_buckets(self):
self.assertEqual(cameras.height_bucket(360), HeightBucket.le_360)
self.assertEqual(cameras.height_bucket(361), HeightBucket.h480)
self.assertEqual(cameras.height_bucket(1080), HeightBucket.h1080)
self.assertEqual(cameras.height_bucket(1440), HeightBucket.h1440)
self.assertEqual(cameras.height_bucket(2160), HeightBucket.ge_2160)
self.assertEqual(cameras.retain_bucket(0.5), RetainBucket.d1_7)
self.assertEqual(cameras.retain_bucket(0), RetainBucket.zero)
def test_preset_key(self):
self.assertEqual(cameras.preset_key([], HwaccelKey), "none")
self.assertEqual(cameras.preset_key("", HwaccelKey), "none")
self.assertEqual(cameras.preset_key("preset-bogus", HwaccelKey), "custom")
self.assertEqual(cameras.preset_key("-hwaccel vaapi", HwaccelKey), "custom")
self.assertEqual(cameras.preset_key("preset-nvidia", HwaccelKey), "nvidia")
def test_is_restream(self):
self.assertTrue(cameras.is_restream("rtsp://127.0.0.1:8554/front"))
self.assertTrue(cameras.is_restream("rtsp://localhost:8554/front"))
self.assertFalse(cameras.is_restream("rtsp://10.0.0.5:8554/front"))
self.assertFalse(cameras.is_restream("rtsp://[bad"))
self.assertFalse(cameras.is_restream("rtsp://127.0.0.1:notaport/x"))
@@ -0,0 +1,87 @@
"""Tests for the detection collector."""
import os
import tempfile
import unittest
from unittest.mock import patch
from frigate.analytics.collectors import detection
from frigate.analytics.schema import ModelSource
from frigate.test.analytics_helpers import make_config, make_context
class TestDetectionCollector(unittest.TestCase):
def test_reports_each_model_with_its_mean_inference_speed(self):
config = make_config({"models": [{"devices": ["cpu", "cpu"]}]})
stats = {
"detectors": {
"cpu": {"inference_speed": 10.0},
"cpu#2": {"inference_speed": 20.0},
},
"detection_fps": 12.346,
"skipped_fps": 0,
}
section = detection.collect(make_context(config, stats))
[model] = section.models
self.assertTrue(model.default)
self.assertEqual(model.detector, "cpu")
self.assertEqual(model.devices, 2)
self.assertEqual(model.model_type, "ssd")
self.assertEqual(model.input, "320x320")
self.assertEqual(model.source, ModelSource.default)
self.assertEqual(model.inference_ms, 15.0)
self.assertEqual(section.detection_fps, 12.35)
self.assertEqual(section.skipped_fps, 0.0)
def test_idle_detector_reports_no_speed(self):
config = make_config({"models": [{"devices": ["cpu"]}]})
stats = {"detectors": {"cpu": {"inference_speed": 0.0}}}
[model] = detection.collect(make_context(config, stats)).models
self.assertIsNone(model.inference_ms)
def test_named_scene_reports_no_name(self):
config = make_config(
{
"models": [
{"devices": ["cpu"]},
{
"scene": "backyard_thermal",
"devices": ["cpu"],
"path": "/thermal.tflite",
},
]
}
)
section = detection.collect(make_context(config))
self.assertEqual([m.default for m in section.models], [True, False])
self.assertNotIn("backyard_thermal", section.model_dump_json())
def test_inference_is_null_before_the_first_stats(self):
section = detection.collect(make_context())
self.assertIsNone(section.models[0].inference_ms)
def test_model_source(self):
with tempfile.TemporaryDirectory() as cache:
plus_model = os.path.join(cache, "abc123")
open(f"{plus_model}.json", "w").close()
with patch.object(detection, "MODEL_CACHE_DIR", cache):
self.assertEqual(detection.model_source(plus_model), ModelSource.plus)
self.assertEqual(
detection.model_source(os.path.join(cache, "no_info")),
ModelSource.custom,
)
self.assertEqual(detection.model_source(None), ModelSource.default)
self.assertEqual(
detection.model_source("/cpu_model.tflite"), ModelSource.default
)
self.assertEqual(
detection.model_source("/config/yolo.onnx"), ModelSource.custom
)

Some files were not shown because too many files have changed in this diff Show More