Compare commits

..
Author SHA1 Message Date
Josh Hawkins 78bc11d7e0 formatting 2026-03-16 13:46:04 -05:00
Josh Hawkins 56679a041b publish camera state when changing profiles 2026-03-16 13:41:30 -05:00
Josh Hawkins eeeec2db86 don't require restart for camera enabled change for profiles 2026-03-16 13:35:26 -05:00
Josh Hawkins b1081d7217 formatting 2026-03-16 13:17:17 -05:00
Josh Hawkins 3a08b3d54b fix zones
- Fix zone colors not matching across profiles by falling back to base zone color when profile zone data lacks a color field
- Use base_config for base-layer values in masks/zones view so profile-merged values don't pollute the base config editing view
- Handle zones separately in profile manager snapshot/restore since ZoneConfig requires special serialization (color as private attr, contour generation)
- Inherit base zone color and generate contours for profile zone overrides in profile manager
2026-03-16 13:14:58 -05:00
Josh Hawkins d41d328a9b use rasterized_mask as field
makes it easier to exclude from the schema with exclude=True
prevents leaking of the field when using model_dump for profiles
2026-03-16 12:43:45 -05:00
Josh Hawkins 80239a8017 fix settings showing profile-merged values when editing base config
When a profile is active, the in-memory config contains effective
(profile-merged) values. The settings UI was displaying these merged
values even when the "Base Config" view was selected.

Backend: snapshot pre-profile base configs in ProfileManager and expose
them via a `base_config` key in the /api/config camera response when a
profile is active. The top-level sections continue to reflect the
effective running config.

Frontend: read from `base_config` when available in BaseSection,
useConfigOverride, useAllCameraOverrides, and prepareSectionSavePayload.
Include formData labels in Object/Audio switches widgets so that labels
added only by a profile override remain visible when editing that profile.
2026-03-16 12:23:07 -05:00
Josh Hawkins f7271e0a5b remove 2026-03-16 07:38:49 -05:00
Josh Hawkins 67604eb61d fix test pollution
test_maintainer was injecting MagicMock() into sys.modules["frigate.config.camera.updater"] at module load time and never restoring it. When the profile tests later imported CameraConfigUpdateEnum and CameraConfigUpdateTopic from that module, they got mock objects instead of the real dataclass/enum, so equality comparisons always failed
2026-03-16 07:31:36 -05:00
Josh Hawkins 0835aa7ea5 fix typing 2026-03-16 06:54:35 -05:00
Josh Hawkins 3c3cf11da4 formatting 2026-03-16 06:36:25 -05:00
Josh Hawkins b657f04d0e formatting 2026-03-16 06:36:06 -05:00
Josh Hawkins cbfefd6df5 docs tweak 2026-03-15 16:45:32 -05:00
Josh Hawkins 79da95bf88 docs tweaks 2026-03-15 16:31:16 -05:00
Josh Hawkins 12e9bb3944 fix profile config inheritance bug where Pydantic defaults override base values
The /config API was dumping profile overrides with model_dump() which included
all Pydantic defaults. When the frontend merged these over
the camera's base config, explicitly-set base values were
lost. Now profile overrides are re-dumped with exclude_unset=True so only
user-specified fields are returned.

Also fixes the Save All path generating spurious deletion markers for
restart-required fields that are hidden during profile
editing but not excluded from the raw data sanitization in
prepareSectionSavePayload.
2026-03-12 11:20:03 -05:00
Josh Hawkins 091e0b80d2 show active profile indicator in desktop status bar 2026-03-12 10:58:16 -05:00
Josh Hawkins 0748766713 hide restart-required fields when editing a profile section
fields that require a restart cannot take effect via profile switching,
so they are merged into hiddenFields when profileName is set
2026-03-12 10:56:08 -05:00
Josh Hawkins 611316906a immediately create profiles on backend instead of deferring to Save All 2026-03-12 10:53:53 -05:00
Josh Hawkins e92fa2b4ba tweak language 2026-03-12 10:47:31 -05:00
Josh Hawkins 8d633423b0 show activity indicator on trash icon while deleting a profile 2026-03-12 10:47:05 -05:00
Josh Hawkins af0696771a docs 2026-03-12 09:13:20 -05:00
Josh Hawkins 46d91af001 change color order 2026-03-12 09:00:45 -05:00
Josh Hawkins 0f735bea37 use icon only on mobile 2026-03-12 08:57:57 -05:00
Josh Hawkins 5a1ec5d729 remove profile badge in settings and add profiles to main menu 2026-03-12 08:32:23 -05:00
Josh Hawkins 39500b20a0 implement profile friendly names and improve profile UI
- Add ProfileDefinitionConfig type and profiles field to FrigateConfig
- Use ProfilesApiResponse type with friendly_name support throughout
- Replace Record<string, unknown> with proper JsonObject/JsonValue types
- Add profile creation form matching zone pattern (Zod + NameAndIdFields)
- Add pencil icon for renaming profile friendly names in ProfilesView
- Move Profiles menu item to first under Camera Configuration
- Add activity indicators on save/rename/delete buttons
- Display friendly names in CameraManagementView profile selector
- Fix duplicate colored dots in management profile dropdown
- Fix i18n namespace for overridden base config tooltips
- Move profile override deletion from dropdown trash icon to footer
  button with confirmation dialog, matching Reset to Global pattern
- Remove Add Profile from section header dropdown to prevent saving
  camera overrides before top-level profile definition exists
- Clean up newProfiles state after API profile deletion
- Refresh profiles SWR cache after saving profile definitions
2026-03-11 20:55:56 -05:00
Josh Hawkins 0b3c6ed22e add top-level profiles config section with friendly names 2026-03-11 19:50:31 -05:00
Josh Hawkins dace54734b more unique colors 2026-03-11 16:21:41 -05:00
Josh Hawkins 210d203fa4 fix mask deletion 2026-03-11 16:04:17 -05:00
Josh Hawkins 5c23555882 implement an update_config method for profile manager 2026-03-11 15:57:27 -05:00
Josh Hawkins 5ec1b1841c refactor profilesview and add dots/border colors when overridden 2026-03-11 15:51:29 -05:00
Josh Hawkins 33e4dddb3e rename profile settings to ui settings 2026-03-11 12:35:45 -05:00
Josh Hawkins 6dd8aa912e ensure profile manager gets updated config 2026-03-11 12:35:34 -05:00
Josh Hawkins 8072c991cd ui tweaks 2026-03-11 12:35:13 -05:00
Josh Hawkins 3cdb40610f fix profile save diff, masksAndZones delete, and config sync 2026-03-11 12:04:35 -05:00
Josh Hawkins 7925d120ae tweak colors and switch 2026-03-11 11:36:15 -05:00
Josh Hawkins 18d413fbee tweaks 2026-03-11 11:35:12 -05:00
Josh Hawkins 98e9e79881 formatting 2026-03-11 11:07:23 -05:00
Josh Hawkins a0849b104c add profiles enable toggle and improve empty state 2026-03-11 11:05:18 -05:00
Josh Hawkins 096a13bce9 move profile dropdown from section panes to settings header 2026-03-11 11:01:51 -05:00
Josh Hawkins eccad7aa21 add face_recognition and lpr to profile-eligible sections 2026-03-11 10:35:38 -05:00
Josh Hawkins cd58329796 fix hidden field validation errors caused by lodash wildcard and schema gaps
lodash unset does not support wildcard (*) segments, so hidden fields like
filters.*.mask were never stripped from form data, leaving null raw_coordinates
that fail RJSF anyOf validation. Add unsetWithWildcard helper and also strip
hidden fields from the JSON schema itself as defense-in-depth.
2026-03-11 10:35:19 -05:00
Josh Hawkins 379247dee6 profile support for mask and zone editor 2026-03-11 07:16:44 -05:00
Josh Hawkins 6205a9d588 add red dot for any pending changes including profiles 2026-03-09 16:08:50 -05:00
Josh Hawkins 53f80a06a5 i18n 2026-03-09 16:06:22 -05:00
Josh Hawkins fa49e0e7b1 add active profile badge to settings toolbar 2026-03-09 15:57:24 -05:00
Josh Hawkins fe7aa2ba3d add profiles summary page with card-based layout and fix backend zone comparison bug 2026-03-09 15:52:26 -05:00
Josh Hawkins 7131acafa6 add per-profile camera enable/disable to Camera Management view 2026-03-09 15:35:35 -05:00
Josh Hawkins 94dbabd0ef add profile section dropdown and wire into camera settings pages 2026-03-09 15:20:50 -05:00
Josh Hawkins d5dc77daa4 add profileName prop to BaseSection for profile-aware config editing 2026-03-09 15:11:56 -05:00
Josh Hawkins edf7fcb5b4 add profile state management and save preview support 2026-03-09 15:06:11 -05:00
Josh Hawkins 72b4a4ddad add frontend profile types, color utility, and config save support 2026-03-09 15:00:25 -05:00
Josh Hawkins 60930e50c2 add zones support to camera profiles 2026-03-09 14:47:29 -05:00
Josh Hawkins d15fc4e58e add enabled field to camera profiles for enabling/disabling cameras 2026-03-09 14:47:29 -05:00
Josh Hawkins 7c6926d1e6 consolidate 2026-03-09 14:47:29 -05:00
Josh Hawkins 708399508b fix CameraLiveConfig JSON serialization error on profile activation
refactor _publish_updates to only publish ZMQ updates for
sections that actually changed, not all sections on affected cameras.
2026-03-09 14:47:29 -05:00
Josh Hawkins 61746984d1 formatting 2026-03-09 14:47:29 -05:00
Josh Hawkins 9efc2499f6 add tests for invalid profile values and keys
Tests that Pydantic rejects: invalid field values (fps: "not_a_number"),
unknown section keys (ffmpeg in profile), invalid nested values, and
invalid profiles in full config parsing.
2026-03-09 14:47:29 -05:00
Josh Hawkins 26ec07985c wire ProfileManager into app startup and FastAPI
- Create ProfileManager after dispatcher init
- Restore persisted profile on startup
- Pass dispatcher and profile_manager to FastAPI app
2026-03-09 14:47:29 -05:00
Josh Hawkins 76c65bbf6d add MQTT and dispatcher integration for profiles
- Subscribe to frigate/profile/set MQTT topic
- Publish profile/state and profiles/available on connect
- Add _on_profile_command handler to dispatcher
- Broadcast active profile state on WebSocket connect
2026-03-09 14:47:29 -05:00
Josh Hawkins f76bb9cfb8 add profile API endpoints (GET /profiles, GET/PUT /profile) 2026-03-09 14:47:29 -05:00
Josh Hawkins c367e5c95a add ProfileManager for profile activation and persistence
Handles snapshotting base configs, applying profile overrides via
deep_merge + apply_section_update, publishing ZMQ updates, and
persisting active profile to /config/.active_profile.
2026-03-09 14:47:29 -05:00
Josh Hawkins 1c190220cb add active_profile field to FrigateConfig
Runtime-only field excluded from YAML serialization, tracks which
profile is currently active.
2026-03-09 14:47:29 -05:00
Josh Hawkins ea2252d09f add profiles field to CameraConfig 2026-03-09 14:47:29 -05:00
Josh Hawkins 0381c96973 add CameraProfileConfig model for named config overrides 2026-03-09 14:47:29 -05:00
671 changed files with 6968 additions and 40296 deletions
+5 -46
View File
@@ -1,18 +1,17 @@
_Please read the [contributing guidelines](https://github.com/blakeblackshear/frigate/blob/dev/CONTRIBUTING.md) before submitting a PR._
## Proposed change
<!--
Thank you!
Describe what this pull request does and how it will benefit users of Frigate.
Please describe in detail any considerations, breaking changes, etc.
If you're introducing a new feature or significantly refactoring existing functionality,
we encourage you to start a discussion first. This helps ensure your idea aligns with
Frigate's development goals.
Describe what this pull request does and how it will benefit users of Frigate.
Please describe in detail any considerations, breaking changes, etc. that are
made in this pull request.
-->
## Type of change
- [ ] Dependency upgrade
@@ -26,45 +25,6 @@ _Please read the [contributing guidelines](https://github.com/blakeblackshear/fr
- This PR fixes or closes issue: fixes #
- This PR is related to issue:
- Link to discussion with maintainers (**required** for large/pinned features):
## For new features
<!--
Every new feature adds scope that maintainers must test, maintain, and support long-term.
We try to be thoughtful about what we take on, and sometimes that means saying no to
good code if the feature isn't the right fit — or saying yes to something we weren't sure
about. These calls are sometimes subjective, and we won't always get them right. We're
happy to discuss and reconsider.
Linking to an existing feature request or discussion with community interest helps us
understand demand, but a great idea is a great idea even without a crowd behind it.
You can delete this section for bugfixes and non-feature changes.
-->
- [ ] There is an existing feature request or discussion with community interest for this change.
- Link:
## AI disclosure
<!--
We welcome contributions that use AI tools, but we need to understand your relationship
with the code you're submitting. See our AI usage policy in CONTRIBUTING.md for details.
Be honest — this won't disqualify your PR. Trust matters more than method.
-->
- [ ] No AI tools were used in this PR.
- [ ] AI tools were used in this PR. Details below:
**AI tool(s) used** (e.g., Claude, Copilot, ChatGPT, Cursor):
**How AI was used** (e.g., code generation, code review, debugging, documentation):
**Extent of AI involvement** (e.g., generated entire implementation, assisted with specific functions, suggested fixes):
**Human oversight**: Describe what manual review, testing, and validation you performed on the AI-generated portions.
## Checklist
@@ -75,6 +35,5 @@ _Please read the [contributing guidelines](https://github.com/blakeblackshear/fr
- [ ] The code change is tested and works locally.
- [ ] Local tests pass. **Your PR cannot be merged unless tests pass**
- [ ] There is no commented out code in this PR.
- [ ] I can explain every line of code in this PR if asked.
- [ ] UI changes including text have used i18n keys and have been added to the `en` locale.
- [ ] The code has been formatted using Ruff (`ruff format frigate`)
+8 -8
View File
@@ -32,7 +32,7 @@ jobs:
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push amd64 standard build
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
file: docker/main/Dockerfile
@@ -56,7 +56,7 @@ jobs:
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push arm64 standard build
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
file: docker/main/Dockerfile
@@ -67,7 +67,7 @@ jobs:
${{ steps.setup.outputs.image-name }}-standard-arm64
cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64
- name: Build and push RPi build
uses: docker/bake-action@v7
uses: docker/bake-action@v6
with:
source: .
push: true
@@ -96,7 +96,7 @@ jobs:
BASE_IMAGE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu
SLIM_BASE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu
TRT_BASE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu
uses: docker/bake-action@v7
uses: docker/bake-action@v6
with:
source: .
push: true
@@ -124,7 +124,7 @@ jobs:
- name: Build and push TensorRT (x86 GPU)
env:
COMPUTE_LEVEL: "50 60 70 80 90"
uses: docker/bake-action@v7
uses: docker/bake-action@v6
with:
source: .
push: true
@@ -137,7 +137,7 @@ jobs:
- name: AMD/ROCm general build
env:
HSA_OVERRIDE: 0
uses: docker/bake-action@v7
uses: docker/bake-action@v6
with:
source: .
push: true
@@ -163,7 +163,7 @@ jobs:
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Rockchip build
uses: docker/bake-action@v7
uses: docker/bake-action@v6
with:
source: .
push: true
@@ -188,7 +188,7 @@ jobs:
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Synaptics build
uses: docker/bake-action@v7
uses: docker/bake-action@v6
with:
source: .
push: true
-120
View File
@@ -1,120 +0,0 @@
name: PR template check
on:
pull_request_target:
types: [opened, edited]
permissions:
pull-requests: write
jobs:
check_template:
name: Validate PR description
runs-on: ubuntu-latest
steps:
- name: Check PR description against template
uses: actions/github-script@v7
with:
script: |
const maintainers = ['blakeblackshear', 'NickM-27', 'hawkeye217', 'dependabot[bot]'];
const author = context.payload.pull_request.user.login;
if (maintainers.includes(author)) {
console.log(`Skipping template check for maintainer: ${author}`);
return;
}
const body = context.payload.pull_request.body || '';
const errors = [];
// Check that key template sections exist
const requiredSections = [
'## Proposed change',
'## Type of change',
'## AI disclosure',
'## Checklist',
];
for (const section of requiredSections) {
if (!body.includes(section)) {
errors.push(`Missing section: **${section}**`);
}
}
// Check that "Proposed change" has content beyond the default HTML comment
const proposedChangeMatch = body.match(
/## Proposed change\s*(?:<!--[\s\S]*?-->\s*)?([\s\S]*?)(?=\n## )/
);
const proposedContent = proposedChangeMatch
? proposedChangeMatch[1].trim()
: '';
if (!proposedContent) {
errors.push(
'The **Proposed change** section is empty. Please describe what this PR does.'
);
}
// Check that at least one "Type of change" checkbox is checked
const typeSection = body.match(
/## Type of change\s*([\s\S]*?)(?=\n## )/
);
if (typeSection && !/- \[x\]/i.test(typeSection[1])) {
errors.push(
'No **Type of change** selected. Please check at least one option.'
);
}
// Check that at least one AI disclosure checkbox is checked
const aiSection = body.match(
/## AI disclosure\s*([\s\S]*?)(?=\n## )/
);
if (aiSection && !/- \[x\]/i.test(aiSection[1])) {
errors.push(
'No **AI disclosure** option selected. Please indicate whether AI tools were used.'
);
}
// Check that at least one checklist item is checked
const checklistSection = body.match(
/## Checklist\s*([\s\S]*?)$/
);
if (checklistSection && !/- \[x\]/i.test(checklistSection[1])) {
errors.push(
'No **Checklist** items checked. Please review and check the items that apply.'
);
}
if (errors.length === 0) {
console.log('PR description passes template validation.');
return;
}
const prNumber = context.payload.pull_request.number;
const message = [
'## PR template validation failed',
'',
'This PR was automatically closed because the description does not follow the [pull request template](https://github.com/blakeblackshear/frigate/blob/dev/.github/pull_request_template.md).',
'',
'**Issues found:**',
...errors.map((e) => `- ${e}`),
'',
'Please update your PR description to include all required sections from the template, then reopen this PR.',
'',
'> If you used an AI tool to generate this PR, please see our [contributing guidelines](https://github.com/blakeblackshear/frigate/blob/dev/CONTRIBUTING.md) for details.',
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: message,
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'closed',
});
core.setFailed('PR description does not follow the template.');
-3
View File
@@ -27,9 +27,6 @@ jobs:
- name: Lint
run: npm run lint
working-directory: ./web
- name: Check i18n keys
run: npm run i18n:extract:ci
working-directory: ./web
web_test:
name: Web - Test
-17
View File
@@ -6,23 +6,6 @@
"type": "debugpy",
"request": "launch",
"module": "frigate"
},
{
"type": "editor-browser",
"request": "launch",
"name": "Vite: Launch in integrated browser",
"url": "http://localhost:5173"
},
{
"type": "editor-browser",
"request": "launch",
"name": "Nginx: Launch in integrated browser",
"url": "http://localhost:5000"
},
{
"type": "editor-browser",
"request": "attach",
"name": "Attach to integrated browser"
}
]
}
-140
View File
@@ -1,140 +0,0 @@
# Contributing to Frigate
Thank you for your interest in contributing to Frigate. This document covers the expectations and guidelines for contributions. Please read it before submitting a pull request.
## Before you start
### Bugfixes
If you've found a bug and want to fix it, go for it. Link to the relevant issue in your PR if one exists, or describe the bug in the PR description.
### New features
Every new feature adds scope that the maintainers must test, maintain, and support long-term. Before writing code for a new feature:
1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Pinned feature requests are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one.
2. **Start a discussion or feature request first.** This helps ensure your idea aligns with Frigate's direction before you invest time building it. Community interest in a feature request helps us gauge demand, though a great idea is a great idea even without a crowd behind it.
3. **Be open to "no".** We try to be thoughtful about what we take on, and sometimes that means saying no to good code if the feature isn't the right fit for the project. These calls are sometimes subjective, and we won't always get them right. We're happy to discuss and reconsider.
## AI usage policy
AI tools are a reality of modern development and we're not opposed to their use. But we need to understand your relationship with the code you're submitting. The more AI was involved, the more important it is that you've genuinely reviewed, tested, and understood what it produced.
### Requirements when AI is used
If AI is used to generate any portion of the code, contributors must adhere to the following requirements:
1. **Explicitly disclose the manner in which AI was employed.** The PR template asks for this. Be honest — this won't automatically disqualify your PR. We'd rather have an honest disclosure than find out later. Trust matters more than method.
2. **Perform a comprehensive manual review prior to submitting the pull request.** Don't submit code you haven't read carefully and tested locally.
3. **Be prepared to explain every line of code they submitted when asked about it by a maintainer.** If you can't explain why something works the way it does, you're not ready to submit it.
4. **It is strictly prohibited to use AI to write your posts for you** (bug reports, feature requests, pull request descriptions, GitHub discussions, responding to humans, etc.). We need to hear from _you_, not your AI assistant. These are the spaces where we build trust and understanding with contributors, and that only works if we're talking to each other.
### Established contributors
Contributors with a long history of thoughtful, quality contributions to Frigate have earned trust through that track record. The level of scrutiny we apply to AI usage naturally reflects that trust. This isn't a formal exemption — it's just how trust works. If you've been around, we know how you think and how you work. If you're new, we're still getting to know you, and clear disclosure helps build that relationship.
### What this means in practice
We're not trying to gatekeep how you write code. Use whatever tools make you productive. But there's a difference between using AI as a tool to implement something you understand and handing a feature request to an AI and submitting whatever comes back. The former is fine. The latter creates maintenance risk for the project.
Some honest context: when we review a PR, we're not just evaluating whether the code works today. We're evaluating whether we can maintain it, debug it, and extend it long-term — often without the original author's involvement. Code that the author doesn't deeply understand is code that nobody understands, and that's a liability.
## Pull request guidelines
### Before submitting
- **Search for existing PRs** to avoid duplicating effort.
- **Test your changes locally.** Your PR cannot be merged unless tests pass.
- **Format your code.** Run `ruff format frigate` for Python and `npm run prettier:write` from the `web/` directory for frontend changes.
- **Run the linter.** Run `ruff check frigate` for Python and `npm run lint` from `web/` for frontend.
- **One concern per PR.** Don't combine unrelated changes. A bugfix and a new feature should be separate PRs.
### What we look for in review
- **Does it work?** Tested locally, tests pass, no regressions.
- **Is it maintainable?** Clear code, appropriate complexity, good separation of concerns.
- **Does it fit?** Consistent with Frigate's architecture and design philosophy.
- **Is it scoped well?** Solves the stated problem without unnecessary additions.
### After submitting
- Be responsive to review feedback. We may ask for changes.
- Expect honest, direct feedback. We try to be respectful but we also try to be efficient.
- If your PR goes stale, rebase it on the latest `dev` branch.
## Coding standards
### Python (backend)
- **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`
- **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.
- **Language**: American English for all code, comments, and documentation
### TypeScript/React (frontend)
- **Linting**: ESLint (`npm run lint` from `web/`)
- **Formatting**: Prettier (`npm run prettier:write` from `web/`)
- **Type safety**: TypeScript strict mode. Avoid `any`.
- **i18n**: All user-facing strings must use `react-i18next`. Never hardcode display text in components. Add English strings to the appropriate files in `web/public/locales/en/`.
- **Components**: Use Radix UI/shadcn primitives and TailwindCSS with the `cn()` utility.
### Development commands
```bash
# Python
python3 -u -m unittest # Run all tests
python3 -u -m unittest frigate.test.test_ffmpeg_presets # Run specific test
ruff format frigate # Format
ruff check frigate # Lint
# Frontend (from web/ directory)
npm run build # Build
npm run lint # Lint
npm run lint:fix # Lint + fix
npm run prettier:write # Format
```
## Project structure
```
frigate/ # Python backend
api/ # FastAPI route handlers
config/ # Configuration parsing and validation
detectors/ # Object detection backends
events/ # Event management and storage
test/ # Backend tests
util/ # Shared utilities
web/ # React/TypeScript frontend
src/
api/ # API client functions
components/ # Reusable components
hooks/ # Custom React hooks
pages/ # Route components
types/ # TypeScript type definitions
views/ # Complex view components
docker/ # Docker build files
docs/ # Documentation site
migrations/ # Database migrations
```
## Translations
Frigate uses [Weblate](https://hosted.weblate.org/projects/frigate-nvr/) for managing language translations. If you'd like to help translate Frigate into your language:
1. Visit the [Frigate project on Weblate](https://hosted.weblate.org/projects/frigate-nvr/).
2. Create an account or log in.
3. Browse the available languages and select the one you'd like to contribute to, or request a new language.
4. Translate strings directly in the Weblate interface — no code changes or pull requests needed.
Translation contributions through Weblate are automatically synced to the repository. Please do not submit pull requests for translation changes — use Weblate instead so that translations are properly tracked and coordinated.
## Resources
- [Documentation](https://docs.frigate.video)
- [Discussions, Support, and Bug Reports](https://github.com/blakeblackshear/frigate/discussions)
- [Feature Requests](https://github.com/blakeblackshear/frigate/issues)
-6
View File
@@ -266,12 +266,6 @@ RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \
pip3 install -U /deps/wheels/*.whl
# Install Axera Engine
RUN pip3 install https://github.com/AXERA-TECH/pyaxengine/releases/download/0.1.3-frigate/axengine-0.1.3-py3-none-any.whl
ENV PATH="${PATH}:/usr/bin/axcl"
ENV LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:/usr/lib/axcl"
# Install MemryX runtime (requires libgomp (OpenMP) in the final docker image)
RUN --mount=type=bind,source=docker/main/install_memryx.sh,target=/deps/install_memryx.sh \
bash -c "bash /deps/install_memryx.sh"
-1
View File
@@ -73,7 +73,6 @@ cd /tmp/nginx
--with-file-aio \
--with-http_sub_module \
--with-http_ssl_module \
--with-http_v2_module \
--with-http_auth_request_module \
--with-http_realip_module \
--with-threads \
+13 -14
View File
@@ -52,7 +52,7 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-03-19-13-03/ffmpeg-n7.1.3-43-g5a1f107b4c-linux64-gpl-7.1.tar.xz"
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
fi
@@ -64,7 +64,7 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-03-19-13-03/ffmpeg-n7.1.3-43-g5a1f107b4c-linuxarm64-gpl-7.1.tar.xz"
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
fi
@@ -105,9 +105,9 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
# install legacy and standard intel icd and level-zero-gpu
# see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info
# needed core package
wget https://github.com/intel/compute-runtime/releases/download/25.13.33276.19/libigdgmm12_22.7.0_amd64.deb
dpkg -i libigdgmm12_22.7.0_amd64.deb
rm libigdgmm12_22.7.0_amd64.deb
wget https://github.com/intel/compute-runtime/releases/download/24.52.32224.5/libigdgmm12_22.5.5_amd64.deb
dpkg -i libigdgmm12_22.5.5_amd64.deb
rm libigdgmm12_22.5.5_amd64.deb
# legacy packages
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb
@@ -115,19 +115,18 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb
# standard packages
wget https://github.com/intel/compute-runtime/releases/download/25.13.33276.19/intel-opencl-icd_25.13.33276.19_amd64.deb
wget https://github.com/intel/compute-runtime/releases/download/25.13.33276.19/intel-level-zero-gpu_1.6.33276.19_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.10.10/intel-igc-opencl-2_2.10.10+18926_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.10.10/intel-igc-core-2_2.10.10+18926_amd64.deb
wget https://github.com/intel/compute-runtime/releases/download/24.52.32224.5/intel-opencl-icd_24.52.32224.5_amd64.deb
wget https://github.com/intel/compute-runtime/releases/download/24.52.32224.5/intel-level-zero-gpu_1.6.32224.5_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.5.6/intel-igc-opencl-2_2.5.6+18417_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.5.6/intel-igc-core-2_2.5.6+18417_amd64.deb
# npu packages
wget https://github.com/oneapi-src/level-zero/releases/download/v1.28.2/level-zero_1.28.2+u22.04_amd64.deb
wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-driver-compiler-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-fw-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-level-zero-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
wget https://github.com/oneapi-src/level-zero/releases/download/v1.21.9/level-zero_1.21.9+u22.04_amd64.deb
wget https://github.com/intel/linux-npu-driver/releases/download/v1.17.0/intel-driver-compiler-npu_1.17.0.20250508-14912879441_ubuntu22.04_amd64.deb
wget https://github.com/intel/linux-npu-driver/releases/download/v1.17.0/intel-fw-npu_1.17.0.20250508-14912879441_ubuntu22.04_amd64.deb
wget https://github.com/intel/linux-npu-driver/releases/download/v1.17.0/intel-level-zero-npu_1.17.0.20250508-14912879441_ubuntu22.04_amd64.deb
dpkg -i *.deb
rm *.deb
apt-get -qq install -f -y
fi
if [[ "${TARGETARCH}" == "arm64" ]]; then
@@ -55,7 +55,7 @@ function setup_homekit_config() {
if [[ ! -f "${config_path}" ]]; then
echo "[INFO] Creating empty config file for HomeKit..."
: > "${config_path}"
echo '{}' > "${config_path}"
fi
# Convert YAML to JSON for jq processing
@@ -65,25 +65,23 @@ function setup_homekit_config() {
return 0
}
# Use jq to extract the homekit section, if it exists
local homekit_json
homekit_json=$(jq '
if has("homekit") then {homekit: .homekit} else null end
' "${temp_json}" 2>/dev/null) || homekit_json="null"
# Use jq to filter and keep only the homekit section
local cleaned_json="/tmp/cache/homekit_cleaned.json"
jq '
# Keep only the homekit section if it exists, otherwise empty object
if has("homekit") then {homekit: .homekit} else {} end
' "${temp_json}" > "${cleaned_json}" 2>/dev/null || {
echo '{}' > "${cleaned_json}"
}
# If no homekit section, write an empty config file
if [[ "${homekit_json}" == "null" ]]; then
: > "${config_path}"
else
# Convert homekit JSON back to YAML and write to the config file
echo "${homekit_json}" | yq eval -P - > "${config_path}" 2>/dev/null || {
echo "[WARNING] Failed to convert cleaned config to YAML, creating minimal config"
: > "${config_path}"
}
fi
# Convert back to YAML and write to the config file
yq eval -P "${cleaned_json}" > "${config_path}" 2>/dev/null || {
echo "[WARNING] Failed to convert cleaned config to YAML, creating minimal config"
echo '{}' > "${config_path}"
}
# Clean up temp files
rm -f "${temp_json}"
rm -f "${temp_json}" "${cleaned_json}"
}
set_libva_version
@@ -63,9 +63,6 @@ http {
server {
include listen.conf;
# enable HTTP/2 for TLS connections to eliminate browser 6-connection limit
http2 on;
# vod settings
vod_base_url '';
vod_segments_base_url '';
+2 -4
View File
@@ -59,14 +59,12 @@ ARG ROCM
# Copy HIP headers required for MIOpen JIT (BuildHip) / HIPRTC at runtime
COPY --from=rocm /opt/rocm-${ROCM}/include/ /opt/rocm-${ROCM}/include/
COPY --from=rocm /opt/rocm-$ROCM/bin/rocminfo /opt/rocm-$ROCM/bin/migraphx-driver /opt/rocm-$ROCM/bin/
# Copy MIOpen database files for gfx10xx, gfx11xx, and gfx12xx only (RDNA2/RDNA3/RDNA4)
# Copy MIOpen database files for gfx10xx and gfx11xx only (RDNA2/RDNA3)
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx10* /opt/rocm-$ROCM/share/miopen/db/
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx11* /opt/rocm-$ROCM/share/miopen/db/
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx12* /opt/rocm-$ROCM/share/miopen/db/
# Copy rocBLAS library files for gfx10xx, gfx11xx, and gfx12xx only
# Copy rocBLAS library files for gfx10xx and gfx11xx only
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx10* /opt/rocm-$ROCM/lib/rocblas/library/
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx11* /opt/rocm-$ROCM/lib/rocblas/library/
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx12* /opt/rocm-$ROCM/lib/rocblas/library/
COPY --from=rocm /opt/rocm-dist/ /
#######################################################################
+12 -12
View File
@@ -1,18 +1,18 @@
# Nvidia ONNX Runtime GPU Support
--extra-index-url 'https://pypi.nvidia.com'
cython==3.0.*; platform_machine == 'x86_64'
nvidia-cuda-cupti-cu12==12.8.90; platform_machine == 'x86_64'
nvidia-cublas-cu12==12.8.4.1; platform_machine == 'x86_64'
nvidia-cudnn-cu12==9.8.0.87; platform_machine == 'x86_64'
nvidia-cufft-cu12==11.3.3.83; platform_machine == 'x86_64'
nvidia-curand-cu12==10.3.9.90; platform_machine == 'x86_64'
nvidia-cuda-nvcc-cu12==12.8.93; platform_machine == 'x86_64'
nvidia-cuda-nvrtc-cu12==12.8.93; platform_machine == 'x86_64'
nvidia-cuda-runtime-cu12==12.8.90; platform_machine == 'x86_64'
nvidia-cusolver-cu12==11.7.3.90; platform_machine == 'x86_64'
nvidia-cusparse-cu12==12.5.8.93; platform_machine == 'x86_64'
nvidia-nccl-cu12==2.26.2.post1; platform_machine == 'x86_64'
nvidia-nvjitlink-cu12==12.8.93; platform_machine == 'x86_64'
nvidia-cuda-cupti-cu12==12.9.79; platform_machine == 'x86_64'
nvidia-cublas-cu12==12.9.1.*; platform_machine == 'x86_64'
nvidia-cudnn-cu12==9.19.0.*; platform_machine == 'x86_64'
nvidia-cufft-cu12==11.4.1.*; platform_machine == 'x86_64'
nvidia-curand-cu12==10.3.10.*; platform_machine == 'x86_64'
nvidia-cuda-nvcc-cu12==12.9.86; platform_machine == 'x86_64'
nvidia-cuda-nvrtc-cu12==12.9.86; platform_machine == 'x86_64'
nvidia-cuda-runtime-cu12==12.9.79; platform_machine == 'x86_64'
nvidia-cusolver-cu12==11.7.5.*; platform_machine == 'x86_64'
nvidia-cusparse-cu12==12.5.10.*; platform_machine == 'x86_64'
nvidia-nccl-cu12==2.29.7; platform_machine == 'x86_64'
nvidia-nvjitlink-cu12==12.9.86; platform_machine == 'x86_64'
onnx==1.16.*; platform_machine == 'x86_64'
onnxruntime-gpu==1.24.*; platform_machine == 'x86_64'
protobuf==3.20.3; platform_machine == 'x86_64'
+2 -10
View File
@@ -44,21 +44,13 @@ go2rtc:
### `environment_vars`
This section can be used to set environment variables for those unable to modify the environment of the container, like within Home Assistant OS. Docker users should set environment variables in their `docker run` command (`-e FRIGATE_MQTT_PASSWORD=secret`) or `docker-compose.yml` file (`environment:` section) instead. Note that values set here are stored in plain text in your config file, so if the goal is to keep credentials out of your configuration, use Docker environment variables or Docker secrets instead.
Variables prefixed with `FRIGATE_` can be referenced in config fields that support environment variable substitution (such as MQTT host and credentials, camera stream URLs, and ONVIF host and credentials) using the `{FRIGATE_VARIABLE_NAME}` syntax.
This section can be used to set environment variables for those unable to modify the environment of the container, like within Home Assistant OS.
Example:
```yaml
environment_vars:
FRIGATE_MQTT_USER: my_mqtt_user
FRIGATE_MQTT_PASSWORD: my_mqtt_password
mqtt:
host: "{FRIGATE_MQTT_HOST}"
user: "{FRIGATE_MQTT_USER}"
password: "{FRIGATE_MQTT_PASSWORD}"
VARIABLE_NAME: variable_value
```
#### TensorFlow Thread Configuration
+2 -2
View File
@@ -86,7 +86,7 @@ Frigate looks for a JWT token secret in the following order:
1. An environment variable named `FRIGATE_JWT_SECRET`
2. A file named `FRIGATE_JWT_SECRET` in the directory specified by the `CREDENTIALS_DIRECTORY` environment variable (defaults to the Docker Secrets directory: `/run/secrets/`)
3. A `jwt_secret` option from the Home Assistant App options
3. A `jwt_secret` option from the Home Assistant Add-on options
4. A `.jwt_secret` file in the config directory
If no secret is found on startup, Frigate generates one and stores it in a `.jwt_secret` file in the config directory.
@@ -232,7 +232,7 @@ The viewer role provides read-only access to all cameras in the UI and API. Cust
### Role Configuration Example
```yaml {11-16}
```yaml
cameras:
front_door:
# ... camera config
-4
View File
@@ -52,10 +52,6 @@ cameras:
password: admin
# Optional: Skip TLS verification from the ONVIF server (default: shown below)
tls_insecure: False
# Optional: ONVIF media profile to use for PTZ control, matched by token or name. (default: shown below)
# If not set, the first profile with valid PTZ configuration is selected automatically.
# Use this when your camera has multiple ONVIF profiles and you need to select a specific one.
profile: None
# Optional: PTZ camera object autotracking. Keeps a moving object in
# the center of the frame by automatically moving the PTZ camera.
autotracking:
+3 -6
View File
@@ -24,7 +24,7 @@ A custom icon can be added to the birdseye background by providing a 180x180 ima
If you want to include a camera in Birdseye view only for specific circumstances, or just don't include it at all, the Birdseye setting can be set at the camera level.
```yaml {8-10,12-14}
```yaml
# Include all cameras by default in Birdseye view
birdseye:
enabled: True
@@ -48,7 +48,6 @@ By default birdseye shows all cameras that have had the configured activity in t
```yaml
birdseye:
enabled: True
# highlight-next-line
inactivity_threshold: 15
```
@@ -79,11 +78,9 @@ birdseye:
cameras:
front:
birdseye:
# highlight-next-line
order: 1
back:
birdseye:
# highlight-next-line
order: 2
```
@@ -95,7 +92,7 @@ It is possible to limit the number of cameras shown on birdseye at one time. Whe
For example, this can be configured to only show the most recently active camera.
```yaml {3-4}
```yaml
birdseye:
enabled: True
layout:
@@ -106,7 +103,7 @@ birdseye:
By default birdseye tries to fit 2 cameras in each row and then double in size until a suitable layout is found. The scaling can be configured with a value between 1.0 and 5.0 depending on use case.
```yaml {3-4}
```yaml
birdseye:
enabled: True
layout:
+2 -4
View File
@@ -23,7 +23,6 @@ Some cameras support h265 with different formats, but Safari only supports the a
cameras:
h265_cam: # <------ Doesn't matter what the camera is called
ffmpeg:
# highlight-next-line
apple_compatibility: true # <- Adds compatibility with MacOS and iPhone
```
@@ -31,7 +30,7 @@ cameras:
Note that mjpeg cameras require encoding the video into h264 for recording, and restream roles. This will use significantly more CPU than if the cameras supported h264 feeds directly. It is recommended to use the restream role to create an h264 restream and then use that as the source for ffmpeg.
```yaml {3,10}
```yaml
go2rtc:
streams:
mjpeg_cam: "ffmpeg:http://your_mjpeg_stream_url#video=h264#hardware" # <- use hardware acceleration to create an h264 stream usable for other components.
@@ -97,7 +96,6 @@ This camera is H.265 only. To be able to play clips on some devices (like MacOs
cameras:
annkec800: # <------ Name the camera
ffmpeg:
# highlight-next-line
apple_compatibility: true # <- Adds compatibility with MacOS and iPhone
output_args:
record: preset-record-generic-audio-aac
@@ -276,7 +274,7 @@ To use a USB camera (webcam) with Frigate, the recommendation is to use go2rtc's
- In your Frigate Configuration File, add the go2rtc stream and roles as appropriate:
```yaml {4,11-12}
```
go2rtc:
streams:
usb_camera:
+1 -3
View File
@@ -66,7 +66,7 @@ Not every PTZ supports ONVIF, which is the standard protocol Frigate uses to com
Add the onvif section to your camera in your configuration file:
```yaml {4-8}
```yaml
cameras:
back:
ffmpeg: ...
@@ -91,8 +91,6 @@ If your ONVIF camera does not require authentication credentials, you may still
:::
If your camera has multiple ONVIF profiles, you can specify which one to use for PTZ control with the `profile` option, matched by token or name. When not set, Frigate selects the first profile with a valid PTZ configuration. Check the Frigate debug logs (`frigate.ptz.onvif: debug`) to see available profile names and tokens for your camera.
An ONVIF-capable camera that supports relative movement within the field of view (FOV) can also be configured to automatically track moving objects and keep them in the center of the frame. For autotracking setup, see the [autotracking](autotracking.md) docs.
## ONVIF PTZ camera recommendations
@@ -7,11 +7,11 @@ Object classification allows you to train a custom MobileNetV2 classification mo
## Minimum System Requirements
Object classification models are lightweight and run very fast on CPU.
Object classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate.
Training the model does briefly use a high amount of system resources for about 13 minutes per training run. On lower-power devices, training may take longer.
A CPU with AVX + AVX2 instructions is required for training and inference.
A CPU with AVX instructions is required for training and inference.
## Classes
@@ -27,6 +27,7 @@ For object classification:
### Classification Type
- **Sub label**:
- Applied to the objects `sub_label` field.
- Ideal for a single, more specific identity or type.
- Example: `cat``Leo`, `Charlie`, `None`.
@@ -102,19 +103,8 @@ If examples for some of your classes do not appear in the grid, you can continue
### Improving the Model
:::tip Diversity matters far more than volume
Selecting dozens of nearly identical images is one of the fastest ways to degrade model performance. MobileNetV2 can overfit quickly when trained on homogeneous data — the model learns what *that exact moment* looked like rather than what actually defines the class. **This is why Frigate does not implement bulk training in the UI.**
For more detail, see [Frigate Tip: Best Practices for Training Face and Custom Classification Models](https://github.com/blakeblackshear/frigate/discussions/21374).
:::
- **Start small and iterate**: Begin with a small, representative set of images per class. Models often begin working well with surprisingly few examples and improve naturally over time.
- **Favor hard examples**: When images appear in the Recent Classifications tab, prioritize images scoring below 90100% or those captured under new lighting, weather, or distance conditions.
- **Avoid bulk training similar images**: Training large batches of images that already score 100% (or close) adds little new information and increases the risk of overfitting.
- **The wizard is just the starting point**: You dont need to find and label every class upfront. Missing classes will naturally appear in Recent Classifications, and those images tend to be more valuable because they represent new conditions and edge cases.
- **Problem framing**: Keep classes visually distinct and relevant to the chosen object types.
- **Data collection**: Use the models Recent Classification tab to gather balanced examples across times of day, weather, and distances.
- **Preprocessing**: Ensure examples reflect object crops similar to Frigates boxes; keep the subject centered.
- **Labels**: Keep label names short and consistent; include a `none` class if you plan to ignore uncertain predictions for sub labels.
- **Threshold**: Tune `threshold` per model to reduce false assignments. Start at `0.8` and adjust based on validation.
@@ -129,7 +119,6 @@ Enable debug logs for classification models by adding `frigate.data_processing.r
logger:
default: info
logs:
# highlight-next-line
frigate.data_processing.real_time.custom_classification: debug
```
@@ -7,11 +7,11 @@ State classification allows you to train a custom MobileNetV2 classification mod
## Minimum System Requirements
State classification models are lightweight and run very fast on CPU.
State classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate.
Training the model does briefly use a high amount of system resources for about 13 minutes per training run. On lower-power devices, training may take longer.
A CPU with AVX + AVX2 instructions is required for training and inference.
A CPU with AVX instructions is required for training and inference.
## Classes
@@ -70,21 +70,10 @@ Once some images are assigned, training will begin automatically.
### Improving the Model
:::tip Diversity matters far more than volume
Selecting dozens of nearly identical images is one of the fastest ways to degrade model performance. MobileNetV2 can overfit quickly when trained on homogeneous data — the model learns what *that exact moment* looked like rather than what actually defines the state. This often leads to models that work perfectly under the original conditions but become unstable when day turns to night, weather changes, or seasonal lighting shifts. **This is why Frigate does not implement bulk training in the UI.**
For more detail, see [Frigate Tip: Best Practices for Training Face and Custom Classification Models](https://github.com/blakeblackshear/frigate/discussions/21374).
:::
- **Start small and iterate**: Begin with a small, representative set of images per class. Models often begin working well with surprisingly few examples and improve naturally over time.
- **Problem framing**: Keep classes visually distinct and state-focused (e.g., `open`, `closed`, `unknown`). Avoid combining object identity with state in a single model unless necessary.
- **Data collection**: Use the model's Recent Classifications tab to gather balanced examples across times of day and weather.
- **When to train**: Focus on cases where the model is entirely incorrect or flips between states when it should not. There's no need to train additional images when the model is already working consistently.
- **Favor hard examples**: When images appear in the Recent Classifications tab, prioritize images scoring below 90100% or those captured under new conditions (e.g., first snow of the year, seasonal changes, objects temporarily in view, insects at night). These represent scenarios different from the default state and help prevent overfitting.
- **Avoid bulk training similar images**: Training large batches of images that already score 100% (or close) adds little new information and increases the risk of overfitting.
- **The wizard is just the starting point**: You don't need to find and label every state upfront. Missing states will naturally appear in Recent Classifications, and those images tend to be more valuable because they represent new conditions and edge cases.
- **Selecting training images**: Images scoring below 100% due to new conditions (e.g., first snow of the year, seasonal changes) or variations (e.g., objects temporarily in view, insects at night) are good candidates for training, as they represent scenarios different from the default state. Training these lower-scoring images that differ from existing training data helps prevent overfitting. Avoid training large quantities of images that look very similar, especially if they already score 100% as this can lead to overfitting.
## Debugging Classification Models
@@ -96,7 +85,6 @@ Enable debug logs for classification models by adding `frigate.data_processing.r
logger:
default: info
logs:
# highlight-next-line
frigate.data_processing.real_time.custom_classification: debug
```
+3 -2
View File
@@ -32,8 +32,6 @@ All of these features run locally on your system.
## Minimum System Requirements
A CPU with AVX + AVX2 instructions is required to run Face Recognition.
The `small` model is optimized for efficiency and runs on the CPU, most CPUs should run the model efficiently.
The `large` model is optimized for accuracy, an integrated or discrete GPU / NPU is required. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation.
@@ -145,14 +143,17 @@ Start with the [Usage](#usage) section and re-read the [Model Requirements](#mod
1. Ensure `person` is being _detected_. A `person` will automatically be scanned by Frigate for a face. Any detected faces will appear in the Recent Recognitions tab in the Frigate UI's Face Library.
If you are using a Frigate+ or `face` detecting model:
- Watch the debug view (Settings --> Debug) to ensure that `face` is being detected along with `person`.
- You may need to adjust the `min_score` for the `face` object if faces are not being detected.
If you are **not** using a Frigate+ or `face` detecting model:
- Check your `detect` stream resolution and ensure it is sufficiently high enough to capture face details on `person` objects.
- You may need to lower your `detection_threshold` if faces are not being detected.
2. Any detected faces will then be _recognized_.
- Make sure you have trained at least one face per the recommendations above.
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
+1 -24
View File
@@ -187,7 +187,7 @@ genai:
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:
```yaml {4,5}
```
genai:
provider: gemini
...
@@ -220,29 +220,6 @@ genai:
model: gpt-4o
```
:::note
To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` environment variable to your provider's API URL.
:::
:::tip
For OpenAI-compatible servers (such as llama.cpp) that don't expose the configured context size in the API response, you can manually specify the context size in `provider_options`:
```yaml {5,6}
genai:
provider: openai
base_url: http://your-llama-server
model: your-model-name
provider_options:
context_size: 8192 # Specify the configured context size
```
This ensures Frigate uses the correct context window size when generating prompts.
:::
### Azure OpenAI
Microsoft offers several vision models through Azure OpenAI. A subscription is required.
@@ -80,7 +80,6 @@ By default, review summaries use preview images (cached preview frames) which ha
review:
genai:
enabled: true
# highlight-next-line
image_source: recordings # Options: "preview" (default) or "recordings"
```
@@ -105,7 +104,7 @@ If recordings are not available for a given time period, the system will automat
Along with the concern of suspicious activity or immediate threat, you may have concerns such as animals in your garden or a gate being left open. These concerns can be configured so that the review summaries will make note of them if the activity requires additional review. For example:
```yaml {4,5}
```yaml
review:
genai:
enabled: true
@@ -117,7 +116,7 @@ review:
By default, review summaries are generated in English. You can configure Frigate to generate summaries in your preferred language by setting the `preferred_language` option:
```yaml {4}
```yaml
review:
genai:
enabled: true
@@ -10,7 +10,6 @@ import CommunityBadge from '@site/src/components/CommunityBadge';
It is highly recommended to use an integrated or discrete GPU for hardware acceleration video decoding in Frigate.
Some types of hardware acceleration are detected and used automatically, but you may need to update your configuration to enable hardware accelerated decoding in ffmpeg. To verify that hardware acceleration is working:
- Check the logs: A message will either say that hardware acceleration was automatically detected, or there will be a warning that no hardware acceleration was automatically detected
- If hardware acceleration is specified in the config, verification can be done by ensuring the logs are free from errors. There is no CPU fallback for hardware acceleration.
@@ -68,7 +67,7 @@ Frigate can utilize most Intel integrated GPUs and Arc GPUs to accelerate video
:::note
The default driver is `iHD`. You may need to change the driver to `i965` by adding the following environment variable `LIBVA_DRIVER_NAME=i965` to your docker-compose file or [in the `config.yml` for HA App users](advanced.md#environment_vars).
The default driver is `iHD`. You may need to change the driver to `i965` by adding the following environment variable `LIBVA_DRIVER_NAME=i965` to your docker-compose file or [in the `config.yml` for HA Add-on users](advanced.md#environment_vars).
See [The Intel Docs](https://www.intel.com/content/www/us/en/support/articles/000005505/processors.html) to figure out what generation your CPU is.
@@ -117,13 +116,12 @@ services:
frigate:
...
image: ghcr.io/blakeblackshear/frigate:stable
# highlight-next-line
privileged: true
```
##### Docker Run CLI - Privileged
```bash {4}
```bash
docker run -d \
--name frigate \
...
@@ -137,7 +135,7 @@ Only recent versions of Docker support the `CAP_PERFMON` capability. You can tes
##### Docker Compose - CAP_PERFMON
```yaml {5,6}
```yaml
services:
frigate:
...
@@ -148,7 +146,7 @@ services:
##### Docker Run CLI - CAP_PERFMON
```bash {4}
```bash
docker run -d \
--name frigate \
...
@@ -190,7 +188,7 @@ Frigate can utilize modern AMD integrated GPUs and AMD GPUs to accelerate video
### Configuring Radeon Driver
You need to change the driver to `radeonsi` by adding the following environment variable `LIBVA_DRIVER_NAME=radeonsi` to your docker-compose file or [in the `config.yml` for HA App users](advanced.md#environment_vars).
You need to change the driver to `radeonsi` by adding the following environment variable `LIBVA_DRIVER_NAME=radeonsi` to your docker-compose file or [in the `config.yml` for HA Add-on users](advanced.md#environment_vars).
### Via VAAPI
@@ -215,7 +213,7 @@ Additional configuration is needed for the Docker container to be able to access
#### Docker Compose - Nvidia GPU
```yaml {5-12}
```yaml
services:
frigate:
...
@@ -232,7 +230,7 @@ services:
#### Docker Run CLI - Nvidia GPU
```bash {4}
```bash
docker run -d \
--name frigate \
...
@@ -294,7 +292,7 @@ These instructions were originally based on the [Jellyfin documentation](https:/
## Raspberry Pi 3/4
Ensure you increase the allocated RAM for your GPU to at least 128 (`raspi-config` > Performance Options > GPU Memory).
If you are using the HA App, you may need to use the full access variant and turn off _Protection mode_ for hardware acceleration.
If you are using the HA Add-on, you may need to use the full access variant and turn off _Protection mode_ for hardware acceleration.
```yaml
# if you want to decode a h264 stream
@@ -311,7 +309,7 @@ ffmpeg:
If running Frigate through Docker, you either need to run in privileged mode or
map the `/dev/video*` devices to Frigate. With Docker Compose add:
```yaml {4-5}
```yaml
services:
frigate:
...
@@ -321,7 +319,7 @@ services:
Or with `docker run`:
```bash {4}
```bash
docker run -d \
--name frigate \
...
@@ -353,7 +351,7 @@ You will need to use the image with the nvidia container runtime:
### Docker Run CLI - Jetson
```bash {3}
```bash
docker run -d \
...
--runtime nvidia
@@ -362,7 +360,7 @@ docker run -d \
### Docker Compose - Jetson
```yaml {5}
```yaml
services:
frigate:
...
@@ -453,14 +451,14 @@ Restarting ffmpeg...
you should try to uprade to FFmpeg 7. This can be done using this config option:
```yaml
```
ffmpeg:
path: "7.0"
```
You can set this option globally to use FFmpeg 7 for all cameras or on camera level to use it only for specific cameras. Do not confuse this option with:
```yaml
```
cameras:
name:
ffmpeg:
@@ -482,7 +480,7 @@ Make sure to follow the [Synaptics specific installation instructions](/frigate/
Add one of the following FFmpeg presets to your `config.yml` to enable hardware video processing:
```yaml {2}
```yaml
ffmpeg:
hwaccel_args: -c:v h264_v4l2m2m
input_args: preset-rtsp-restream
+9 -10
View File
@@ -3,7 +3,7 @@ id: index
title: Frigate Configuration
---
For Home Assistant App installations, the config file should be at `/addon_configs/<addon_directory>/config.yml`, where `<addon_directory>` is specific to the variant of the Frigate App you are running. See the list of directories [here](#accessing-app-config-dir).
For Home Assistant Add-on installations, the config file should be at `/addon_configs/<addon_directory>/config.yml`, where `<addon_directory>` is specific to the variant of the Frigate Add-on you are running. See the list of directories [here](#accessing-add-on-config-dir).
For all other installation types, the config file should be mapped to `/config/config.yml` inside the container.
@@ -25,11 +25,11 @@ cameras:
- detect
```
## Accessing the Home Assistant App configuration directory {#accessing-app-config-dir}
## Accessing the Home Assistant Add-on configuration directory {#accessing-add-on-config-dir}
When running Frigate through the HA App, the Frigate `/config` directory is mapped to `/addon_configs/<addon_directory>` in the host, where `<addon_directory>` is specific to the variant of the Frigate App you are running.
When running Frigate through the HA Add-on, the Frigate `/config` directory is mapped to `/addon_configs/<addon_directory>` in the host, where `<addon_directory>` is specific to the variant of the Frigate Add-on you are running.
| App Variant | Configuration directory |
| Add-on Variant | Configuration directory |
| -------------------------- | ----------------------------------------- |
| Frigate | `/addon_configs/ccab4aaf_frigate` |
| Frigate (Full Access) | `/addon_configs/ccab4aaf_frigate-fa` |
@@ -38,11 +38,11 @@ When running Frigate through the HA App, the Frigate `/config` directory is mapp
**Whenever you see `/config` in the documentation, it refers to this directory.**
If for example you are running the standard App variant and use the [VS Code App](https://github.com/hassio-addons/addon-vscode) to browse your files, you can click _File_ > _Open folder..._ and navigate to `/addon_configs/ccab4aaf_frigate` to access the Frigate `/config` directory and edit the `config.yaml` file. You can also use the built-in file editor in the Frigate UI to edit the configuration file.
If for example you are running the standard Add-on variant and use the [VS Code Add-on](https://github.com/hassio-addons/addon-vscode) to browse your files, you can click _File_ > _Open folder..._ and navigate to `/addon_configs/ccab4aaf_frigate` to access the Frigate `/config` directory and edit the `config.yaml` file. You can also use the built-in file editor in the Frigate UI to edit the configuration file.
## VS Code Configuration Schema
VS Code supports JSON schemas for automatically validating configuration files. You can enable this feature by adding `# yaml-language-server: $schema=http://frigate_host:5000/api/config/schema.json` to the beginning of the configuration file. Replace `frigate_host` with the IP address or hostname of your Frigate server. If you're using both VS Code and Frigate as an App, you should use `ccab4aaf-frigate` instead. Make sure to expose the internal unauthenticated port `5000` when accessing the config from VS Code on another machine.
VS Code supports JSON schemas for automatically validating configuration files. You can enable this feature by adding `# yaml-language-server: $schema=http://frigate_host:5000/api/config/schema.json` to the beginning of the configuration file. Replace `frigate_host` with the IP address or hostname of your Frigate server. If you're using both VS Code and Frigate as an Add-on, you should use `ccab4aaf-frigate` instead. Make sure to expose the internal unauthenticated port `5000` when accessing the config from VS Code on another machine.
## Environment Variable Substitution
@@ -50,7 +50,6 @@ Frigate supports the use of environment variables starting with `FRIGATE_` **onl
```yaml
mqtt:
host: "{FRIGATE_MQTT_HOST}"
user: "{FRIGATE_MQTT_USER}"
password: "{FRIGATE_MQTT_PASSWORD}"
```
@@ -61,7 +60,7 @@ mqtt:
```yaml
onvif:
host: "192.168.1.12"
host: 10.0.10.10
port: 8000
user: "{FRIGATE_RTSP_USER}"
password: "{FRIGATE_RTSP_PASSWORD}"
@@ -83,10 +82,10 @@ genai:
Here are some common starter configuration examples. Refer to the [reference config](./reference.md) for detailed information about all the config values.
### Raspberry Pi Home Assistant App with USB Coral
### Raspberry Pi Home Assistant Add-on with USB Coral
- Single camera with 720p, 5fps stream for detect
- MQTT connected to the Home Assistant Mosquitto App
- MQTT connected to the Home Assistant Mosquitto Add-on
- Hardware acceleration for decoding video
- USB Coral detector
- Save all video with any detectable motion for 7 days regardless of whether any objects were detected or not
@@ -30,7 +30,7 @@ In the default mode, Frigate's LPR needs to first detect a `car` or `motorcycle`
## Minimum System Requirements
License plate recognition works by running AI models locally on your system. The YOLOv9 plate detector model and the OCR models ([PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR)) are relatively lightweight and can run on your CPU or GPU, depending on your configuration. At least 4GB of RAM and a CPU with AVX + AVX2 instructions is required.
License plate recognition works by running AI models locally on your system. The YOLOv9 plate detector model and the OCR models ([PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR)) are relatively lightweight and can run on your CPU or GPU, depending on your configuration. At least 4GB of RAM is required.
## Configuration
@@ -43,7 +43,7 @@ lpr:
Like other enrichments in Frigate, LPR **must be enabled globally** to use the feature. You should disable it for specific cameras at the camera level if you don't want to run LPR on cars on those cameras:
```yaml {4,5}
```yaml
cameras:
garage:
...
@@ -375,6 +375,7 @@ Use `match_distance` to allow small character mismatches. Alternatively, define
Start with ["Why isn't my license plate being detected and recognized?"](#why-isnt-my-license-plate-being-detected-and-recognized). If you are still having issues, work through these steps.
1. Start with a simplified LPR config.
- Remove or comment out everything in your LPR config, including `min_area`, `min_plate_length`, `format`, `known_plates`, or `enhancement` values so that the only values left are `enabled` and `debug_save_plates`. This will run LPR with Frigate's default values.
```yaml
@@ -385,28 +386,31 @@ Start with ["Why isn't my license plate being detected and recognized?"](#why-is
```
2. Enable debug logs to see exactly what Frigate is doing.
- Enable debug logs for LPR by adding `frigate.data_processing.common.license_plate: debug` to your `logger` configuration. These logs are _very_ verbose, so only keep this enabled when necessary. Restart Frigate after this change.
```yaml
logger:
default: info
logs:
# highlight-next-line
frigate.data_processing.common.license_plate: debug
```
3. Ensure your plates are being _detected_.
If you are using a Frigate+ or `license_plate` detecting model:
- Watch the debug view (Settings --> Debug) to ensure that `license_plate` is being detected.
- View MQTT messages for `frigate/events` to verify detected plates.
- You may need to adjust your `min_score` and/or `threshold` for the `license_plate` object if your plates are not being detected.
If you are **not** using a Frigate+ or `license_plate` detecting model:
- Watch the debug logs for messages from the YOLOv9 plate detector.
- You may need to adjust your `detection_threshold` if your plates are not being detected.
4. Ensure the characters on detected plates are being _recognized_.
- Enable `debug_save_plates` to save images of detected text on plates to the clips directory (`/media/frigate/clips/lpr`). Ensure these images are readable and the text is clear.
- Watch the debug view to see plates recognized in real-time. For non-dedicated LPR cameras, the `car` or `motorcycle` label will change to the recognized plate when LPR is enabled and working.
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
+19 -8
View File
@@ -15,7 +15,7 @@ The jsmpeg live view will use more browser and client GPU resources. Using go2rt
| ------ | ------------------------------------- | ---------- | ---------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| jsmpeg | same as `detect -> fps`, capped at 10 | 720p | no | no | Resolution is configurable, but go2rtc is recommended if you want higher resolutions and better frame rates. jsmpeg is Frigate's default without go2rtc configured. |
| mse | native | native | yes (depends on audio codec) | yes | iPhone requires iOS 17.1+, Firefox is h.264 only. This is Frigate's default when go2rtc is configured. |
| webrtc | native | native | yes (depends on audio codec) | yes | Requires extra configuration. Frigate attempts to use WebRTC when MSE fails or when using a camera's two-way talk feature. |
| webrtc | native | native | yes (depends on audio codec) | yes | Requires extra configuration. Frigate attempts to use WebRTC when MSE fails or when using a camera's two-way talk feature. |
### Camera Settings Recommendations
@@ -77,7 +77,7 @@ Configure the `streams` option with a "friendly name" for your stream followed b
Using Frigate's internal version of go2rtc is required to use this feature. You cannot specify paths in the `streams` configuration, only go2rtc stream names.
```yaml {3,6,8,25-29}
```yaml
go2rtc:
streams:
test_cam:
@@ -114,9 +114,9 @@ cameras:
WebRTC works by creating a TCP or UDP connection on port `8555`. However, it requires additional configuration:
- For external access, over the internet, setup your router to forward port `8555` to port `8555` on the Frigate device, for both TCP and UDP.
- For internal/local access, unless you are running through the HA App, you will also need to set the WebRTC candidates list in the go2rtc config. For example, if `192.168.1.10` is the local IP of the device running Frigate:
- For internal/local access, unless you are running through the HA Add-on, you will also need to set the WebRTC candidates list in the go2rtc config. For example, if `192.168.1.10` is the local IP of the device running Frigate:
```yaml title="config.yml" {4-7}
```yaml title="config.yml"
go2rtc:
streams:
test_cam: ...
@@ -128,13 +128,13 @@ WebRTC works by creating a TCP or UDP connection on port `8555`. However, it req
- For access through Tailscale, the Frigate system's Tailscale IP must be added as a WebRTC candidate. Tailscale IPs all start with `100.`, and are reserved within the `100.64.0.0/10` CIDR block.
- Note that some browsers may not support H.265 (HEVC). You can check your browser's current version for H.265 compatibility [here](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#codecs-madness).
- Note that some browsers may not support H.265 (HEVC). You can check your browser's current version for H.265 compatibility [here](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#codecs-madness).
:::tip
This extra configuration may not be required if Frigate has been installed as a Home Assistant App, as Frigate uses the Supervisor's API to generate a WebRTC candidate.
This extra configuration may not be required if Frigate has been installed as a Home Assistant Add-on, as Frigate uses the Supervisor's API to generate a WebRTC candidate.
However, it is recommended if issues occur to define the candidates manually. You should do this if the Frigate App fails to generate a valid candidate. If an error occurs you will see some warnings like the below in the App logs page during the initialization:
However, it is recommended if issues occur to define the candidates manually. You should do this if the Frigate Add-on fails to generate a valid candidate. If an error occurs you will see some warnings like the below in the Add-on logs page during the initialization:
```log
[WARN] Failed to get IP address from supervisor
@@ -154,7 +154,7 @@ If not running in host mode, port 8555 will need to be mapped for the container:
docker-compose.yml
```yaml {4-6}
```yaml
services:
frigate:
...
@@ -222,28 +222,34 @@ Note that disabling a camera through the config file (`enabled: False`) removes
When your browser runs into problems playing back your camera streams, it will log short error messages to the browser console. They indicate playback, codec, or network issues on the client/browser side, not something server side with Frigate itself. Below are the common messages you may see and simple actions you can take to try to resolve them.
- **startup**
- What it means: The player failed to initialize or connect to the live stream (network or startup error).
- What to try: Reload the Live view or click _Reset_. Verify `go2rtc` is running and the camera stream is reachable. Try switching to a different stream from the Live UI dropdown (if available) or use a different browser.
- Possible console messages from the player code:
- `Error opening MediaSource.`
- `Browser reported a network error.`
- `Max error count ${errorCount} exceeded.` (the numeric value will vary)
- **mse-decode**
- What it means: The browser reported a decoding error while trying to play the stream, which usually is a result of a codec incompatibility or corrupted frames.
- What to try: Check the browser console for the supported and negotiated codecs. Ensure your camera/restream is using H.264 video and AAC audio (these are the most compatible). If your camera uses a non-standard audio codec, configure `go2rtc` to transcode the stream to AAC. Try another browser (some browsers have stricter MSE/codec support) and, for iPhone, ensure you're on iOS 17.1 or newer.
- Possible console messages from the player code:
- `Safari cannot open MediaSource.`
- `Safari reported InvalidStateError.`
- `Safari reported decoding errors.`
- **stalled**
- What it means: Playback has stalled because the player has fallen too far behind live (extended buffering or no data arriving).
- What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval — shorter intervals make playback start and recover faster. You can also try increasing the timeout value in the UI pane of Frigate's settings.
- Possible console messages from the player code:
- `Buffer time (10 seconds) exceeded, browser may not be playing media correctly.`
- `Media playback has stalled after <n> seconds due to insufficient buffering or a network interruption.` (the seconds value will vary)
@@ -264,18 +270,21 @@ When your browser runs into problems playing back your camera streams, it will l
If you are using continuous streaming or you are loading more than a few high resolution streams at once on the dashboard, your browser may struggle to begin playback of your streams before the timeout. Frigate always prioritizes showing a live stream as quickly as possible, even if it is a lower quality jsmpeg stream. You can use the "Reset" link/button to try loading your high resolution stream again.
Errors in stream playback (e.g., connection failures, codec issues, or buffering timeouts) that cause the fallback to low bandwidth mode (jsmpeg) are logged to the browser console for easier debugging. These errors may include:
- Network issues (e.g., MSE or WebRTC network connection problems).
- Unsupported codecs or stream formats (e.g., H.265 in WebRTC, which is not supported in some browsers).
- Buffering timeouts or low bandwidth conditions causing fallback to jsmpeg.
- Browser compatibility problems (e.g., iOS Safari limitations with MSE).
To view browser console logs:
1. Open the Frigate Live View in your browser.
2. Open the browser's Developer Tools (F12 or right-click > Inspect > Console tab).
3. Reproduce the error (e.g., load a problematic stream or simulate network issues).
4. Look for messages prefixed with the camera name.
These logs help identify if the issue is player-specific (MSE vs. WebRTC) or related to camera configuration (e.g., go2rtc streams, codecs). If you see frequent errors:
- Verify your camera's H.264/AAC settings (see [Frigate's camera settings recommendations](#camera_settings_recommendations)).
- Check go2rtc configuration for transcoding (e.g., audio to AAC/OPUS).
- Test with a different stream via the UI dropdown (if `live -> streams` is configured).
@@ -315,7 +324,9 @@ When your browser runs into problems playing back your camera streams, it will l
To prevent this, make the `detect` stream match the go2rtc live stream's aspect ratio (resolution does not need to match, just the aspect ratio). You can either adjust the camera's output resolution or set the `width` and `height` values in your config's `detect` section to a resolution with an aspect ratio that matches.
Example: Resolutions from two streams
- Mismatched (may cause aspect ratio switching on the dashboard):
- Live/go2rtc stream: 1920x1080 (16:9)
- Detect stream: 640x352 (~1.82:1, not 16:9)
+6 -47
View File
@@ -49,11 +49,6 @@ Frigate supports multiple different detectors that work on different types of ha
- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs.
**AXERA** <CommunityBadge />
- [AXEngine](#axera): axmodels can run on AXERA AI acceleration.
**For Testing**
- [CPU Detector (not recommended for actual use](#cpu-detector-not-recommended): Use a CPU to run tflite model, this is not recommended and in most cases OpenVINO can be used in CPU mode with better results.
@@ -166,7 +161,7 @@ YOLOv9 models that are compiled for TensorFlow Lite and properly quantized are s
:::tip
**Frigate+ Users:** Follow the [instructions](/integrations/plus#use-models) to set a model ID in your config file.
**Frigate+ Users:** Follow the [instructions](../integrations/plus#use-models) to set a model ID in your config file.
:::
@@ -577,7 +572,7 @@ $ docker run --device=/dev/kfd --device=/dev/dri \
When using Docker Compose:
```yaml {4-6}
```yaml
services:
frigate:
...
@@ -608,7 +603,7 @@ $ docker run -e HSA_OVERRIDE_GFX_VERSION=10.0.0 \
When using Docker Compose:
```yaml {4-5}
```yaml
services:
frigate:
...
@@ -1483,42 +1478,6 @@ model:
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
```
## AXERA
Hardware accelerated object detection is supported on the following SoCs:
- AX650N
- AX8850N
This implementation uses the [AXera Pulsar2 Toolchain](https://huggingface.co/AXERA-TECH/Pulsar2).
See the [installation docs](../frigate/installation.md#axera) for information on configuring the AXEngine hardware.
### Configuration
When configuring the AXEngine detector, you have to specify the model name.
#### yolov9
A yolov9 model is provided in the container at `/axmodels` and is used by this detector type by default.
Use the model configuration shown below when using the axengine detector with the default axmodel:
```yaml
detectors:
axengine:
type: axengine
model:
path: frigate-yolov9-tiny
model_type: yolo-generic
width: 320
height: 320
input_dtype: int
input_pixel_format: bgr
labelmap_path: /labelmap/coco-80.txt
```
# Models
Some model types are not included in Frigate by default.
@@ -1612,12 +1571,12 @@ YOLOv9 model can be exported as ONNX using the command below. You can copy and p
```sh
docker build . --build-arg MODEL_SIZE=t --build-arg IMG_SIZE=320 --output . -f- <<'EOF'
FROM python:3.11 AS build
RUN apt-get update && apt-get install --no-install-recommends -y cmake libgl1 && rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/
RUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/
WORKDIR /yolov9
ADD https://github.com/WongKinYiu/yolov9.git .
RUN uv pip install --system -r requirements.txt
RUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier==0.4.* onnxscript
RUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier>=0.4.1 onnxscript
ARG MODEL_SIZE
ARG IMG_SIZE
ADD https://github.com/WongKinYiu/yolov9/releases/download/v0.1/yolov9-${MODEL_SIZE}-converted.pt yolov9-${MODEL_SIZE}.pt
+1 -3
View File
@@ -130,7 +130,7 @@ When exporting a time-lapse the default speed-up is 25x with 30 FPS. This means
To configure the speed-up factor, the frame rate and further custom settings, the configuration parameter `timelapse_args` can be used. The below configuration example would change the time-lapse speed to 60x (for fitting 1 hour of recording into 1 minute of time-lapse) with 25 FPS:
```yaml {3-4}
```yaml
record:
enabled: True
export:
@@ -162,8 +162,6 @@ Normal operation may leave small numbers of orphaned files until Frigate's sched
The Maintenance pane in the Frigate UI or an API endpoint `POST /api/media/sync` can be used to trigger a media sync. When using the API, a job ID is returned and the operation continues on the server. Status can be checked with the `/api/media/sync/status/{job_id}` endpoint.
Setting `verbose: true` writes a detailed report of every orphaned file and database entry to `/config/media_sync/<job_id>.txt`. For recordings, the report separates orphaned database entries (DB records whose files are missing from disk) from orphaned files (files on disk with no corresponding database record).
:::warning
This operation uses considerable CPU resources and includes a safety threshold that aborts if more than 50% of files would be deleted. Only run when necessary. If you set `force: true` the safety threshold will be bypassed; do not use `force` unless you are certain the deletions are intended.
+6 -13
View File
@@ -16,8 +16,6 @@ mqtt:
# Optional: Enable mqtt server (default: shown below)
enabled: True
# Required: host name
# NOTE: MQTT host can be specified with an environment variable or docker secrets that must begin with 'FRIGATE_'.
# e.g. host: '{FRIGATE_MQTT_HOST}'
host: mqtt.server.com
# Optional: port (default: shown below)
port: 1883
@@ -618,12 +616,13 @@ record:
# never stored, so setting the mode to "all" here won't bring them back.
mode: motion
# Optional: Configuration for the snapshots written to the clips directory for each tracked object
# Timestamp, bounding_box, crop and height settings are applied by default to API requests for snapshots.
# Optional: Configuration for the jpg snapshots written to the clips directory for each tracked object
# NOTE: Can be overridden at the camera level
snapshots:
# Optional: Enable writing snapshot images to /media/frigate/clips (default: shown below)
# Optional: Enable writing jpg snapshot to /media/frigate/clips (default: shown below)
enabled: False
# Optional: save a clean copy of the snapshot image (default: shown below)
clean_copy: True
# Optional: print a timestamp on the snapshots (default: shown below)
timestamp: False
# Optional: draw bounding box on the snapshots (default: shown below)
@@ -641,8 +640,8 @@ snapshots:
# Optional: Per object retention days
objects:
person: 15
# Optional: quality of the encoded snapshot image, 0-100 (default: shown below)
quality: 60
# Optional: quality of the encoded jpeg, 0-100 (default: shown below)
quality: 70
# Optional: Configuration for semantic search capability
semantic_search:
@@ -951,8 +950,6 @@ cameras:
onvif:
# Required: host of the camera being connected to.
# NOTE: HTTP is assumed by default; HTTPS is supported if you specify the scheme, ex: "https://0.0.0.0".
# NOTE: ONVIF host, user, and password can be specified with environment variables or docker secrets
# that must begin with 'FRIGATE_'. e.g. host: '{FRIGATE_ONVIF_USERNAME}'
host: 0.0.0.0
# Optional: ONVIF port for device (default: shown below).
port: 8000
@@ -966,10 +963,6 @@ cameras:
# Optional: Ignores time synchronization mismatches between the camera and the server during authentication.
# Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents.
ignore_time_mismatch: False
# Optional: ONVIF media profile to use for PTZ control, matched by token or name. (default: shown below)
# If not set, the first profile with valid PTZ configuration is selected automatically.
# Use this when your camera has multiple ONVIF profiles and you need to select a specific one.
profile: None
# Optional: PTZ camera object autotracking. Keeps a moving object in
# the center of the frame by automatically moving the PTZ camera.
autotracking:
+1 -3
View File
@@ -34,7 +34,7 @@ To improve connection speed when using Birdseye via restream you can enable a sm
The go2rtc restream can be secured with RTSP based username / password authentication. Ex:
```yaml {2-4}
```yaml
go2rtc:
rtsp:
username: "admin"
@@ -147,7 +147,6 @@ For example:
```yaml
go2rtc:
streams:
# highlight-error-line
my_camera: rtsp://username:$@foo%@192.168.1.100
```
@@ -156,7 +155,6 @@ becomes
```yaml
go2rtc:
streams:
# highlight-next-line
my_camera: rtsp://username:$%40foo%25@192.168.1.100
```
+1 -1
View File
@@ -71,7 +71,7 @@ To exclude a specific camera from alerts or detections, simply provide an empty
For example, to exclude objects on the camera _gatecamera_ from any detections, include this in your config:
```yaml {3-5}
```yaml
cameras:
gatecamera:
review:
+1 -1
View File
@@ -13,7 +13,7 @@ Semantic Search is accessed via the _Explore_ view in the Frigate UI.
Semantic Search works by running a large AI model locally on your system. Small or underpowered systems like a Raspberry Pi will not run Semantic Search reliably or at all.
A minimum of 8GB of RAM is required to use Semantic Search. A CPU with AVX + AVX2 instructions is required to run Semantic Search. A GPU is not strictly required but will provide a significant performance increase over CPU-only systems.
A minimum of 8GB of RAM is required to use Semantic Search. A GPU is not strictly required but will provide a significant performance increase over CPU-only systems.
For best performance, 16GB or more of RAM and a dedicated GPU are recommended.
+13 -11
View File
@@ -3,7 +3,7 @@ id: snapshots
title: Snapshots
---
Frigate can save a snapshot image to `/media/frigate/clips` for each object that is detected named as `<camera>-<id>-clean.webp`. They are also accessible [via the api](../integrations/api/event-snapshot-events-event-id-snapshot-jpg-get.api.mdx)
Frigate can save a snapshot image to `/media/frigate/clips` for each object that is detected named as `<camera>-<id>.jpg`. They are also accessible [via the api](../integrations/api/event-snapshot-events-event-id-snapshot-jpg-get.api.mdx)
Snapshots are accessible in the UI in the Explore pane. This allows for quick submission to the Frigate+ service.
@@ -13,19 +13,21 @@ Snapshots sent via MQTT are configured in the [config file](/configuration) unde
## Frame Selection
Frigate does not save every frame. It picks a single "best" frame for each tracked object based on detection confidence, object size, and the presence of key attributes like faces or license plates. Frames where the object touches the edge of the frame are deprioritized. That best frame is written to disk once tracking ends.
Frigate does not save every frame — it picks a single "best" frame for each tracked object and uses it for both the snapshot and clean copy. As the object is tracked across frames, Frigate continuously evaluates whether the current frame is better than the previous best based on detection confidence, object size, and the presence of key attributes like faces or license plates. Frames where the object touches the edge of the frame are deprioritized. The snapshot is written to disk once tracking ends using whichever frame was determined to be the best.
MQTT snapshots are published more frequently — each time a better thumbnail frame is found during tracking, or when the current best image is older than `best_image_timeout` (default: 60s). These use their own annotation settings configured under `cameras -> your_camera -> mqtt`.
## Rendering
## Clean Copy
Frigate stores a single clean snapshot on disk:
Frigate can produce up to two snapshot files per event, each used in different places:
| API / Use | Result |
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Stored file | `<camera>-<id>-clean.webp`, always unannotated |
| `/api/events/<id>/snapshot.jpg` | Starts from the camera's `snapshots` defaults, then applies any query param overrides at request time |
| `/api/events/<id>/snapshot-clean.webp` | Returns the same stored snapshot without annotations |
| [Frigate+](/plus/first_model) submission | Uses the same stored clean snapshot |
| Version | File | Annotations | Used by |
| --- | --- | --- | --- |
| **Regular snapshot** | `<camera>-<id>.jpg` | Respects your `timestamp`, `bounding_box`, `crop`, and `height` settings | API (`/api/events/<id>/snapshot.jpg`), MQTT (`<camera>/<label>/snapshot`), Explore pane in the UI |
| **Clean copy** | `<camera>-<id>-clean.webp` | Always unannotated — no bounding box, no timestamp, no crop, full resolution | API (`/api/events/<id>/snapshot-clean.webp`), [Frigate+](/plus/first_model) submissions, "Download Clean Snapshot" in the UI |
MQTT snapshots are configured separately under `cameras -> your_camera -> mqtt` and are unrelated to the stored event snapshot.
MQTT snapshots are configured separately under `cameras -> your_camera -> mqtt` and are unrelated to the clean copy.
The clean copy is required for submitting events to [Frigate+](/plus/first_model) — if you plan to use Frigate+, keep `clean_copy` enabled regardless of your other snapshot settings.
If you are not using Frigate+ and `timestamp`, `bounding_box`, and `crop` are all disabled, the regular snapshot is already effectively clean, so `clean_copy` provides no benefit and only uses additional disk space. You can safely set `clean_copy: False` in this case.
+3 -3
View File
@@ -20,7 +20,7 @@ tls:
TLS certificates can be mounted at `/etc/letsencrypt/live/frigate` using a bind mount or docker volume.
```yaml {3-4}
```yaml
frigate:
...
volumes:
@@ -32,7 +32,7 @@ Within the folder, the private key is expected to be named `privkey.pem` and the
Note that certbot uses symlinks, and those can't be followed by the container unless it has access to the targets as well, so if using certbot you'll also have to mount the `archive` folder for your domain, e.g.:
```yaml {3-5}
```yaml
frigate:
...
volumes:
@@ -46,7 +46,7 @@ Frigate automatically compares the fingerprint of the certificate at `/etc/letse
If you issue Frigate valid certificates you will likely want to configure it to run on port 443 so you can access it without a port number like `https://your-frigate-domain.com` by mapping 8971 to 443.
```yaml {3-4}
```yaml
frigate:
...
ports:
+1 -5
View File
@@ -22,7 +22,7 @@ To create a zone, follow [the steps for a "Motion mask"](masks.md), but use the
Often you will only want alerts to be created when an object enters areas of interest. This is done using zones along with setting required_zones. Let's say you only want to have an alert created when an object enters your entire_yard zone, the config would be:
```yaml {6,8}
```yaml
cameras:
name_of_your_camera:
review:
@@ -108,7 +108,6 @@ cameras:
name_of_your_camera:
zones:
sidewalk:
# highlight-next-line
loitering_time: 4 # unit is in seconds
objects:
- person
@@ -123,7 +122,6 @@ cameras:
name_of_your_camera:
zones:
front_yard:
# highlight-next-line
inertia: 3
objects:
- person
@@ -136,7 +134,6 @@ cameras:
name_of_your_camera:
zones:
driveway_entrance:
# highlight-next-line
inertia: 1
objects:
- car
@@ -199,6 +196,5 @@ cameras:
coordinates: ...
distances: ...
inertia: 1
# highlight-next-line
speed_threshold: 20 # unit is in kph or mph, depending on how unit_system is set (see above)
```
+3 -33
View File
@@ -17,15 +17,15 @@ From here, follow the guides for:
- [Web Interface](#web-interface)
- [Documentation](#documentation)
### Frigate Home Assistant App
### Frigate Home Assistant Add-on
This repository holds the Home Assistant App, for use with Home Assistant OS and compatible installations. It is the piece that allows you to run Frigate from your Home Assistant Supervisor tab.
This repository holds the Home Assistant Add-on, for use with Home Assistant OS and compatible installations. It is the piece that allows you to run Frigate from your Home Assistant Supervisor tab.
Fork [blakeblackshear/frigate-hass-addons](https://github.com/blakeblackshear/frigate-hass-addons) to your own Github profile, then clone the forked repo to your local machine.
### Frigate Home Assistant Integration
This repository holds the custom integration that allows your Home Assistant installation to automatically create entities for your Frigate instance, whether you are running Frigate as a standalone Docker container or as a [Home Assistant App](#frigate-home-assistant-app).
This repository holds the custom integration that allows your Home Assistant installation to automatically create entities for your Frigate instance, whether you are running Frigate as a standalone Docker container or as a [Home Assistant Add-on](#frigate-home-assistant-add-on).
Fork [blakeblackshear/frigate-hass-integration](https://github.com/blakeblackshear/frigate-hass-integration) to your own GitHub profile, then clone the forked repo to your local machine.
@@ -89,14 +89,6 @@ After closing VS Code, you may still have containers running. To close everythin
### Testing
#### Unit Tests
GitHub will execute unit tests on new PRs. You must ensure that all tests pass.
```shell
python3 -u -m unittest
```
#### FFMPEG Hardware Acceleration
The following commands are used inside the container to ensure hardware acceleration is working properly.
@@ -133,28 +125,6 @@ ffmpeg -hwaccel vaapi -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format
ffmpeg -c:v h264_qsv -re -stream_loop -1 -i https://streams.videolan.org/ffmpeg/incoming/720p60.mp4 -f rawvideo -pix_fmt yuv420p pipe: > /dev/null
```
### Submitting a pull request
Code must be formatted, linted and type-tested. GitHub will run these checks on pull requests, so it is advised to run them yourself prior to opening.
**Formatting**
```shell
ruff format frigate migrations docker *.py
```
**Linting**
```shell
ruff check frigate migrations docker *.py
```
**MyPy Static Typing**
```shell
python3 -u -m mypy --config-file frigate/mypy.ini frigate
```
## Web Interface
### Prerequisites
+8 -21
View File
@@ -26,7 +26,7 @@ I may earn a small commission for my endorsement, recommendation, testimonial, o
## Server
My current favorite is the Beelink EQ13 because of the efficient N100 CPU and dual NICs that allow you to setup a dedicated private network for your cameras where they can be blocked from accessing the internet. There are many used workstation options on eBay that work very well. Anything with an Intel CPU (with AVX + AVX2 instructions) and capable of running Debian should work fine. As a bonus, you may want to look for devices with a M.2 or PCIe express slot that is compatible with the Google Coral, Hailo, or other AI accelerators.
My current favorite is the Beelink EQ13 because of the efficient N100 CPU and dual NICs that allow you to setup a dedicated private network for your cameras where they can be blocked from accessing the internet. There are many used workstation options on eBay that work very well. Anything with an Intel CPU and capable of running Debian should work fine. As a bonus, you may want to look for devices with a M.2 or PCIe express slot that is compatible with the Google Coral, Hailo, or other AI accelerators.
Note that many of these mini PCs come with Windows pre-installed, and you will need to install Linux according to the [getting started guide](../guides/getting_started.md).
@@ -103,10 +103,6 @@ Frigate supports multiple different detectors that work on different types of ha
- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs to provide efficient object detection.
**AXERA** <CommunityBadge />
- [AXEngine](#axera): axera models can run on AXERA NPUs via AXEngine, delivering highly efficient object detection.
:::
### Hailo-8
@@ -200,14 +196,13 @@ Inference is done with the `onnx` detector type. Speeds will vary greatly depend
✅ - Accelerated with CUDA Graphs
❌ - Not accelerated with CUDA Graphs
| Name | ✅ YOLOv9 Inference Time | ✅ RF-DETR Inference Time | ❌ YOLO-NAS Inference Time |
| ----------- | ------------------------------------- | ------------------------- | -------------------------- |
| GTX 1070 | s-320: 16 ms | | 320: 14 ms |
| RTX 3050 | t-320: 8 ms s-320: 10 ms s-640: 28 ms | Nano-320: ~ 12 ms | 320: ~ 10 ms 640: ~ 16 ms |
| RTX 3070 | t-320: 6 ms s-320: 8 ms s-640: 25 ms | Nano-320: ~ 9 ms | 320: ~ 8 ms 640: ~ 14 ms |
| RTX 5060 Ti | t-320: 5 ms s-320: 7 ms s-640: 22 ms | Nano-320: ~ 4 ms | |
| RTX A4000 | | | 320: ~ 15 ms |
| Tesla P40 | | | 320: ~ 105 ms |
| Name | ✅ YOLOv9 Inference Time | ✅ RF-DETR Inference Time | ❌ YOLO-NAS Inference Time |
| --------- | ------------------------------------- | ------------------------- | -------------------------- |
| GTX 1070 | s-320: 16 ms | | 320: 14 ms |
| RTX 3050 | t-320: 8 ms s-320: 10 ms s-640: 28 ms | Nano-320: ~ 12 ms | 320: ~ 10 ms 640: ~ 16 ms |
| RTX 3070 | t-320: 6 ms s-320: 8 ms s-640: 25 ms | Nano-320: ~ 9 ms | 320: ~ 8 ms 640: ~ 14 ms |
| RTX A4000 | | | 320: ~ 15 ms |
| Tesla P40 | | | 320: ~ 105 ms |
### Apple Silicon
@@ -293,14 +288,6 @@ The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms fo
| ssd mobilenet | ~ 25 ms |
| yolov5m | ~ 118 ms |
### AXERA
- **AXEngine** Default model is **yolov9**
| Name | AXERA AX650N/AX8850N Inference Time |
| ---------------- | ----------------------------------- |
| yolov9-tiny | ~ 4 ms |
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.
+27 -52
View File
@@ -3,13 +3,11 @@ id: installation
title: Installation
---
import ShmCalculator from '@site/src/components/ShmCalculator'
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App.
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant Add-on](https://www.home-assistant.io/addons/). Note that the Home Assistant Add-on is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant Add-on.
:::tip
If you already have Frigate installed as a Home Assistant App, check out the [getting started guide](../guides/getting_started#configuring-frigate) to configure Frigate.
If you already have Frigate installed as a Home Assistant Add-on, check out the [getting started guide](../guides/getting_started#configuring-frigate) to configure Frigate.
:::
@@ -79,9 +77,22 @@ The default shm size of **128MB** is fine for setups with **2 cameras** detectin
The Frigate container also stores logs in shm, which can take up to **40MB**, so make sure to take this into account in your math as well.
<ShmCalculator/>
You can calculate the **minimum** shm size for each camera with the following formula using the resolution specified for detect:
The shm size cannot be set per container for Home Assistant Apps. However, this is probably not required since by default Home Assistant Supervisor allocates `/dev/shm` with half the size of your total memory. If your machine has 8GB of memory, chances are that Frigate will have access to up to 4GB without any additional configuration.
```console
# Template for one camera without logs, replace <width> and <height>
$ python -c 'print("{:.2f}MB".format((<width> * <height> * 1.5 * 20 + 270480) / 1048576))'
# Example for 1280x720, including logs
$ python -c 'print("{:.2f}MB".format((1280 * 720 * 1.5 * 20 + 270480) / 1048576 + 40))'
66.63MB
# Example for eight cameras detecting at 1280x720, including logs
$ python -c 'print("{:.2f}MB".format(((1280 * 720 * 1.5 * 20 + 270480) / 1048576) * 8 + 40))'
253MB
```
The shm size cannot be set per container for Home Assistant add-ons. However, this is probably not required since by default Home Assistant Supervisor allocates `/dev/shm` with half the size of your total memory. If your machine has 8GB of memory, chances are that Frigate will have access to up to 4GB without any additional configuration.
## Extra Steps for Specific Hardware
@@ -428,42 +439,6 @@ or add these options to your `docker run` command:
Next, you should configure [hardware object detection](/configuration/object_detectors#synaptics) and [hardware video processing](/configuration/hardware_acceleration_video#synaptics).
### AXERA
AXERA accelerators are available in an M.2 form factor, compatible with both Raspberry Pi and Orange Pi. This form factor has also been successfully tested on x86 platforms, making it a versatile choice for various computing environments.
#### Installation
Using AXERA accelerators requires the installation of the AXCL driver. We provide a convenient Linux script to complete this installation.
Follow these steps for installation:
1. Copy or download [this script](https://github.com/ivanshi1108/assets/releases/download/v0.16.2/user_installation.sh).
2. Ensure it has execution permissions with `sudo chmod +x user_installation.sh`
3. Run the script with `./user_installation.sh`
#### Setup
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
Next, grant Docker permissions to access your hardware by adding the following lines to your `docker-compose.yml` file:
```yaml
devices:
- /dev/axcl_host
- /dev/ax_mmb_dev
- /dev/msg_userdev
volumes:
- /usr/bin/axcl:/usr/bin/axcl
- /usr/lib/axcl:/usr/lib/axcl
```
If you are using `docker run`, add this option to your command `--device /dev/axcl_host --device /dev/ax_mmb_dev --device /dev/msg_userdev`
#### Configuration
Finally, configure [hardware object detection](/configuration/object_detectors#axera) to complete the setup.
## Docker
Running through Docker with Docker Compose is the recommended install method.
@@ -535,7 +510,7 @@ The community supported docker image tags for the current stable version are:
- `stable-tensorrt-jp6` - Frigate build optimized for Nvidia Jetson devices running Jetpack 6
- `stable-rk` - Frigate build for SBCs with Rockchip SoC
## Home Assistant App
## Home Assistant Add-on
:::warning
@@ -546,7 +521,7 @@ 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.
- Nvidia GPUs are not supported because HA Apps do not support the Nvidia runtime.
- Nvidia GPUs are not supported because addons do not support the Nvidia runtime.
:::
@@ -556,27 +531,27 @@ See [the network storage guide](/guides/ha_network_storage.md) for instructions
:::
Home Assistant OS users can install via the App repository.
Home Assistant OS users can install via the Add-on repository.
1. In Home Assistant, navigate to _Settings_ > _Apps_ > _App Store_ > _Repositories_
1. In Home Assistant, navigate to _Settings_ > _Add-ons_ > _Add-on Store_ > _Repositories_
2. Add `https://github.com/blakeblackshear/frigate-hass-addons`
3. Install the desired variant of the Frigate App (see below)
3. Install the desired variant of the Frigate Add-on (see below)
4. Setup your network configuration in the `Configuration` tab
5. Start the App
5. Start the Add-on
6. Use the _Open Web UI_ button to access the Frigate UI, then click in the _cog icon_ > _Configuration editor_ and configure Frigate to your liking
There are several variants of the App available:
There are several variants of the Add-on available:
| App Variant | Description |
| Add-on Variant | Description |
| -------------------------- | ---------------------------------------------------------- |
| Frigate | Current release with protection mode on |
| Frigate (Full Access) | Current release with the option to disable protection mode |
| Frigate Beta | Beta release with protection mode on |
| Frigate Beta (Full Access) | Beta release with the option to disable protection mode |
If you are using hardware acceleration for ffmpeg, you **may** need to use the _Full Access_ variant of the App. This is because the Frigate App runs in a container with limited access to the host system. The _Full Access_ variant allows you to disable _Protection mode_ and give Frigate full access to the host system.
If you are using hardware acceleration for ffmpeg, you **may** need to use the _Full Access_ variant of the Add-on. This is because the Frigate Add-on runs in a container with limited access to the host system. The _Full Access_ variant allows you to disable _Protection mode_ and give Frigate full access to the host system.
You can also edit the Frigate configuration file through the [VS Code App](https://github.com/hassio-addons/addon-vscode) or similar. In that case, the configuration file will be at `/addon_configs/<addon_directory>/config.yml`, where `<addon_directory>` is specific to the variant of the Frigate App you are running. See the list of directories [here](../configuration/index.md#accessing-app-config-dir).
You can also edit the Frigate configuration file through the [VS Code Add-on](https://github.com/hassio-addons/addon-vscode) or similar. In that case, the configuration file will be at `/addon_configs/<addon_directory>/config.yml`, where `<addon_directory>` is specific to the variant of the Frigate Add-on you are running. See the list of directories [here](../configuration/index.md#accessing-add-on-config-dir).
## Kubernetes
+2 -5
View File
@@ -34,14 +34,11 @@ For commercial installations it is important to verify the number of supported c
There are many different hardware options for object detection depending on priorities and available hardware. See [the recommended hardware page](./hardware.md#detectors) for more specifics on what hardware is recommended for object detection.
### CPU
Frigate requires a CPU with AVX + AVX2 instructions. Most modern CPUs (post-2011) support AVX and AVX2, but it is generally absent in low-power or budget-oriented processors, particularly older Intel Pentium, Celeron, and Atom-based chips. Specifically, Intel Celeron and Pentium models prior to the 2020 Tiger Lake generation typically lack AVX. Older Intel Xeon models may have AVX, but may lack AVX2.
### Storage
Storage is an important consideration when planning a new installation. To get a more precise estimate of your storage requirements, you can use an IP camera storage calculator. Websites like [IPConfigure Storage Calculator](https://calculator.ipconfigure.com/) can help you determine the necessary disk space based on your camera settings.
#### SSDs (Solid State Drives)
SSDs are an excellent choice for Frigate, offering high speed and responsiveness. The older concern that SSDs would quickly "wear out" from constant video recording is largely no longer valid for modern consumer and enterprise-grade SSDs.
@@ -74,4 +71,4 @@ While supported, using network-attached storage (NAS) for recordings can introdu
- **Basic Minimum: 4GB RAM**: This is generally sufficient for a very basic Frigate setup with a few cameras and a dedicated object detection accelerator, without running any enrichments. Performance might be tight, especially with higher resolution streams or numerous detections.
- **Minimum for Enrichments: 8GB RAM**: If you plan to utilize Frigate's enrichment features (e.g., facial recognition, license plate recognition, or other AI models that run alongside standard object detection), 8GB of RAM should be considered the minimum. Enrichments require additional memory to load and process their respective models and data.
- **Recommended: 16GB RAM**: For most users, especially those with many cameras (8+) or who plan to heavily leverage enrichments, 16GB of RAM is highly recommended. This provides ample headroom for smooth operation, reduces the likelihood of swapping to disk (which can impact performance), and allows for future expansion.
- **Recommended: 16GB RAM**: For most users, especially those with many cameras (8+) or who plan to heavily leverage enrichments, 16GB of RAM is highly recommended. This provides ample headroom for smooth operation, reduces the likelihood of swapping to disk (which can impact performance), and allows for future expansion.
+12 -12
View File
@@ -7,7 +7,7 @@ title: Updating
The current stable version of Frigate is **0.17.0**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.17.0).
Keeping Frigate up to date ensures you benefit from the latest features, performance improvements, and bug fixes. The update process varies slightly depending on your installation method (Docker, Home Assistant App, etc.). Below are instructions for the most common setups.
Keeping Frigate up to date ensures you benefit from the latest features, performance improvements, and bug fixes. The update process varies slightly depending on your installation method (Docker, Home Assistant Addon, etc.). Below are instructions for the most common setups.
## Before You Begin
@@ -67,30 +67,30 @@ If youre running Frigate via Docker (recommended method), follow these steps:
- If youve customized other settings (e.g., `shm-size`), ensure theyre still appropriate after the update.
- Docker will automatically use the updated image when you restart the container, as long as you pulled the correct version.
## Updating the Home Assistant App (formerly Addon)
## Updating the Home Assistant Addon
For users running Frigate as a Home Assistant App:
For users running Frigate as a Home Assistant Addon:
1. **Check for Updates**:
- Navigate to **Settings > Apps** in Home Assistant.
- Find your installed Frigate app (e.g., "Frigate NVR" or "Frigate NVR (Full Access)").
- Navigate to **Settings > Add-ons** in Home Assistant.
- Find your installed Frigate addon (e.g., "Frigate NVR" or "Frigate NVR (Full Access)").
- If an update is available, youll see an "Update" button.
2. **Update the App**:
- Click the "Update" button next to the Frigate app.
2. **Update the Addon**:
- Click the "Update" button next to the Frigate addon.
- Wait for the process to complete. Home Assistant will handle downloading and installing the new version.
3. **Restart the App**:
- After updating, go to the apps page and click "Restart" to apply the changes.
3. **Restart the Addon**:
- After updating, go to the addons page and click "Restart" to apply the changes.
4. **Verify the Update**:
- Check the app logs (under the "Log" tab) to ensure Frigate starts without errors.
- Check the addon logs (under the "Log" tab) to ensure Frigate starts without errors.
- Access the Frigate Web UI to confirm the new version is running.
### Notes
- Ensure your `/config/frigate.yml` is compatible with the new version by reviewing the [Release notes](https://github.com/blakeblackshear/frigate/releases).
- If using custom hardware (e.g., Coral or GPU), verify that configurations still work, as app updates dont modify your hardware settings.
- If using custom hardware (e.g., Coral or GPU), verify that configurations still work, as addon updates dont modify your hardware settings.
## Rolling Back
@@ -101,7 +101,7 @@ If an update causes issues:
3. Revert to the previous image version:
- For Docker: Specify an older tag (e.g., `ghcr.io/blakeblackshear/frigate:0.16.4`) in your `docker run` command.
- For Docker Compose: Edit your `docker-compose.yml`, specify the older version tag (e.g., `ghcr.io/blakeblackshear/frigate:0.16.4`), and re-run `docker compose up -d`.
- For Home Assistant: Restore from the app/addon backup you took before you updated.
- For Home Assistant: Reinstall the previous addon version manually via the repository if needed and restart the addon.
4. Verify the old version is running again.
## Troubleshooting
+8 -8
View File
@@ -37,18 +37,18 @@ The following diagram adds a lot more detail than the simple view explained befo
%%{init: {"themeVariables": {"edgeLabelBackground": "transparent"}}}%%
flowchart TD
RecStore[(Recording<br>store)]
SnapStore[(Snapshot<br>store)]
RecStore[(Recording\nstore)]
SnapStore[(Snapshot\nstore)]
subgraph Acquisition
Cam["Camera"] -->|FFmpeg supported| Stream
Cam -->|"Other streaming<br>protocols"| go2rtc
Cam -->|"Other streaming\nprotocols"| go2rtc
go2rtc("go2rtc") --> Stream
Stream[Capture main and<br>sub streams] --> |detect stream|Decode(Decode and<br>downscale)
Stream[Capture main and\nsub streams] --> |detect stream|Decode(Decode and\ndownscale)
end
subgraph Motion
Decode --> MotionM(Apply<br>motion masks)
MotionM --> MotionD(Motion<br>detection)
Decode --> MotionM(Apply\nmotion masks)
MotionM --> MotionD(Motion\ndetection)
end
subgraph Detection
MotionD --> |motion regions| ObjectD(Object detection)
@@ -60,8 +60,8 @@ flowchart TD
MotionD --> |motion event|Birdseye
ObjectZ --> |object event|Birdseye
MotionD --> |"video segments<br>(retain motion)"|RecStore
MotionD --> |"video segments\n(retain motion)"|RecStore
ObjectZ --> |detection clip|RecStore
Stream -->|"video segments<br>(retain all)"| RecStore
Stream -->|"video segments\n(retain all)"| RecStore
ObjectZ --> |detection snapshot|SnapStore
```
+6 -2
View File
@@ -33,16 +33,19 @@ After adding this to the config, restart Frigate and try to watch the live strea
### What if my video doesn't play?
- Check Logs:
- Access the go2rtc logs in the Frigate UI under Logs in the sidebar.
- If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log.
- Check go2rtc Web Interface: if you don't see any errors in the logs, try viewing the camera through go2rtc's web interface.
- Navigate to port 1984 in your browser to access go2rtc's web interface.
- If using Frigate through Home Assistant, enable the web interface at port 1984.
- If using Docker, forward port 1984 before accessing the web interface.
- Click `stream` for the specific camera to see if the camera's stream is being received.
- Check Video Codec:
- If the camera stream works in go2rtc but not in your browser, the video codec might be unsupported.
- If using H265, switch to H264. Refer to [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#codecs-madness) in go2rtc documentation.
- If unable to switch from H265 to H264, or if the stream format is different (e.g., MJPEG), re-encode the video using [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view.
@@ -55,6 +58,7 @@ After adding this to the config, restart Frigate and try to watch the live strea
```
- Switch to FFmpeg if needed:
- Some camera streams may need to use the ffmpeg module in go2rtc. This has the downside of slower startup times, but has compatibility with more stream types.
```yaml
@@ -97,9 +101,9 @@ After adding this to the config, restart Frigate and try to watch the live strea
:::warning
To access the go2rtc stream externally when utilizing the Frigate App (for
To access the go2rtc stream externally when utilizing the Frigate Add-On (for
instance through VLC), you must first enable the RTSP Restream port.
You can do this by visiting the Frigate App configuration page within Home
You can do this by visiting the Frigate Add-On configuration page within Home
Assistant and revealing the hidden options under the "Show disabled ports"
section.
+8 -50
View File
@@ -9,7 +9,7 @@ title: Getting started
If you already have an environment with Linux and Docker installed, you can continue to [Installing Frigate](#installing-frigate) below.
If you already have Frigate installed through Docker or through a Home Assistant App, you can continue to [Configuring Frigate](#configuring-frigate) below.
If you already have Frigate installed through Docker or through a Home Assistant Add-on, you can continue to [Configuring Frigate](#configuring-frigate) below.
:::
@@ -81,7 +81,7 @@ Now you have a minimal Debian server that requires very little maintenance.
## Installing Frigate
This section shows how to create a minimal directory structure for a Docker installation on Debian. If you have installed Frigate as a Home Assistant App or another way, you can continue to [Configuring Frigate](#configuring-frigate).
This section shows how to create a minimal directory structure for a Docker installation on Debian. If you have installed Frigate as a Home Assistant Add-on or another way, you can continue to [Configuring Frigate](#configuring-frigate).
### Setup directories
@@ -150,7 +150,7 @@ Here is an example configuration with hardware acceleration configured to work w
`docker-compose.yml` (after modifying, you will need to run `docker compose up -d` to apply changes)
```yaml {4,5}
```yaml
services:
frigate:
...
@@ -168,57 +168,17 @@ cameras:
name_of_your_camera:
ffmpeg:
inputs: ...
# highlight-next-line
hwaccel_args: preset-vaapi
detect: ...
```
### Step 4: Configure detectors
By default, Frigate will use a single CPU detector.
In many cases, the integrated graphics on Intel CPUs provides sufficient performance for typical Frigate setups. If you have an Intel processor, you can follow the configuration below.
<details>
<summary>Use Intel OpenVINO detector</summary>
You need to refer to **Configure hardware acceleration** above to enable the container to use the GPU.
```yaml {3-6,9-15,20-21}
mqtt: ...
detectors: # <---- add detectors
ov:
type: openvino # <---- use openvino detector
device: GPU
# We will use the default MobileNet_v2 model from OpenVINO.
model:
width: 300
height: 300
input_tensor: nhwc
input_pixel_format: bgr
path: /openvino-model/ssdlite_mobilenet_v2.xml
labelmap_path: /openvino-model/coco_91cl_bkgr.txt
cameras:
name_of_your_camera:
ffmpeg: ...
detect:
enabled: True # <---- turn on detection
...
```
</details>
If you have a USB Coral, you will need to add a detectors section to your config.
<details>
<summary>Use USB Coral detector</summary>
By default, Frigate will use a single CPU detector. If you have a USB Coral, you will need to add a detectors section to your config.
`docker-compose.yml` (after modifying, you will need to run `docker compose up -d` to apply changes)
```yaml {4-6}
```yaml
services:
frigate:
...
@@ -228,7 +188,7 @@ services:
...
```
```yaml {3-6,11-12}
```yaml
mqtt: ...
detectors: # <---- add detectors
@@ -244,8 +204,6 @@ cameras:
...
```
</details>
More details on available detectors can be found [here](../configuration/object_detectors.md).
Restart Frigate and you should start seeing detections for `person`. If you want to track other objects, they will need to be added according to the [configuration file reference](../configuration/reference.md).
@@ -264,7 +222,7 @@ Note that motion masks should not be used to mark out areas where you do not wan
Your configuration should look similar to this now.
```yaml {16-18}
```yaml
mqtt:
enabled: False
@@ -294,7 +252,7 @@ In order to review activity in the Frigate UI, recordings need to be enabled.
To enable recording video, add the `record` role to a stream and enable it in the config. If record is disabled in the config, it won't be possible to enable it in the UI.
```yaml {16-17}
```yaml
mqtt: ...
detectors: ...
+3 -3
View File
@@ -3,7 +3,7 @@ id: ha_network_storage
title: Home Assistant network storage
---
As of Home Assistant 2023.6, Network Mounted Storage is supported for Apps.
As of Home Assistant 2023.6, Network Mounted Storage is supported for Add-ons.
## Setting Up Remote Storage For Frigate
@@ -14,7 +14,7 @@ As of Home Assistant 2023.6, Network Mounted Storage is supported for Apps.
### Initial Setup
1. Stop the Frigate App
1. Stop the Frigate Add-on
### Move current data
@@ -37,4 +37,4 @@ Keeping the current data is optional, but the data will need to be moved regardl
4. Fill out the additional required info for your particular NAS
5. Connect
6. Move files from `/media/frigate_tmp` to `/media/frigate` if they were kept in previous step
7. Start the Frigate App
7. Start the Frigate Add-on
+3 -3
View File
@@ -99,11 +99,11 @@ services:
...
```
### Home Assistant App
### Home Assistant Add-on
If you are using Home Assistant App, the URL should be one of the following depending on which App variant you are using. Note that if you are using the Proxy App, you should NOT point the integration at the proxy URL. Just enter the same URL used to access Frigate directly from your network.
If you are using Home Assistant Add-on, the URL should be one of the following depending on which Add-on variant you are using. Note that if you are using the Proxy Add-on, you should NOT point the integration at the proxy URL. Just enter the same URL used to access Frigate directly from your network.
| App Variant | URL |
| Add-on Variant | URL |
| -------------------------- | -------------------------------------- |
| Frigate | `http://ccab4aaf-frigate:5000` |
| Frigate (Full Access) | `http://ccab4aaf-frigate-fa:5000` |
+12 -2
View File
@@ -11,8 +11,7 @@ These are the MQTT messages generated by Frigate. The default topic_prefix is `f
Designed to be used as an availability topic with Home Assistant. Possible message are:
"online": published when Frigate is running (on startup)
"stopped": published when Frigate is stopped normally
"offline": published automatically by the MQTT broker if Frigate disconnects unexpectedly (via MQTT Will Message)
"offline": published after Frigate has stopped
### `frigate/restart`
@@ -284,6 +283,17 @@ Topic to activate or deactivate a [profile](/configuration/profiles). Publish a
Topic with the currently active profile name. Published value is the profile name or `none` if no profile is active. This topic is retained.
### `frigate/profiles/available`
Topic with a JSON array of all available profile definitions. Published on startup as a retained message.
```json
[
{ "name": "away", "friendly_name": "Away" },
{ "name": "home", "friendly_name": "Home" }
]
```
### `frigate/notifications/set`
Topic to turn notifications on and off. Expected values are `ON` and `OFF`.
+2 -2
View File
@@ -19,11 +19,11 @@ Once logged in, you can generate an API key for Frigate in Settings.
### Set your API key
In Frigate, you can use an environment variable or a docker secret named `PLUS_API_KEY` to enable the `Frigate+` buttons on the Explore page. Home Assistant App users can set it under Settings > Apps > Frigate > Configuration > Options (be sure to toggle the "Show unused optional configuration options" switch).
In Frigate, you can use an environment variable or a docker secret named `PLUS_API_KEY` to enable the `Frigate+` buttons on the Explore page. Home Assistant Addon users can set it under Settings > Add-ons > Frigate > Configuration > Options (be sure to toggle the "Show unused optional configuration options" switch).
:::warning
You cannot use the `environment_vars` section of your Frigate configuration file to set this environment variable. It must be defined as an environment variable in the docker config or Home Assistant App config.
You cannot use the `environment_vars` section of your Frigate configuration file to set this environment variable. It must be defined as an environment variable in the docker config or Home Assistant Add-on config.
:::
@@ -42,7 +42,3 @@ This is a fork (with fixed errors and new features) of [original Double Take](ht
## [Scrypted - Frigate bridge plugin](https://github.com/apocaliss92/scrypted-frigate-bridge)
[Scrypted - Frigate bridge](https://github.com/apocaliss92/scrypted-frigate-bridge) is an plugin that allows to ingest Frigate detections, motion, videoclips on Scrypted as well as provide templates to export rebroadcast configurations on Frigate.
## [Strix](https://github.com/eduard256/Strix)
[Strix](https://github.com/eduard256/Strix) auto-discovers working stream URLs for IP cameras and generates ready-to-use Frigate configs. It tests thousands of URL patterns against your camera and supports cameras without RTSP or ONVIF. 67K+ camera models from 3.6K+ brands.
+2 -1
View File
@@ -25,9 +25,10 @@ Yes. Subscriptions to Frigate+ provide access to the infrastructure used to trai
### Why can't I submit images to Frigate+?
If you've configured your API key and the Frigate+ Settings page in the UI shows that the key is active, you need to ensure that snapshots are enabled for the cameras you'd like to submit images for.
If you've configured your API key and the Frigate+ Settings page in the UI shows that the key is active, you need to ensure that you've enabled both snapshots and `clean_copy` snapshots for the cameras you'd like to submit images for. Note that `clean_copy` is enabled by default when snapshots are enabled.
```yaml
snapshots:
enabled: true
clean_copy: true
```
+1 -1
View File
@@ -32,7 +32,7 @@ The USB coral can draw up to 900mA and this can be too much for some on-device U
The USB coral has different IDs when it is uninitialized and initialized.
- When running Frigate in a VM, Proxmox lxc, etc. you must ensure both device IDs are mapped.
- When running through the Home Assistant OS you may need to run the Full Access variant of the Frigate App with the _Protection mode_ switch disabled so that the coral can be accessed.
- When running through the Home Assistant OS you may need to run the Full Access variant of the Frigate Add-on with the _Protection mode_ switch disabled so that the coral can be accessed.
### Synology 716+II running DSM 7.2.1-69057 Update 5
-11
View File
@@ -83,17 +83,6 @@ const config: Config = {
},
},
prism: {
magicComments:[
{
className: 'theme-code-block-highlighted-line',
line: 'highlight-next-line',
block: {start: 'highlight-start', end: 'highlight-end'},
},
{
className: 'code-block-error-line',
line: 'highlight-error-line',
},
],
additionalLanguages: ["bash", "json"],
},
languageTabs: [
+12 -12
View File
@@ -6820,9 +6820,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -11471,9 +11471,9 @@
"license": "BSD-2-Clause"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -12313,9 +12313,9 @@
}
},
"node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
"license": "MIT"
},
"node_modules/import-fresh": {
@@ -20609,9 +20609,9 @@
}
},
"node_modules/serve-handler/node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
-201
View File
@@ -1,201 +0,0 @@
import React, { useState, useEffect } from "react";
import Admonition from "@theme/Admonition";
import styles from "./styles.module.css";
const ShmCalculator = () => {
const [width, setWidth] = useState(1280);
const [height, setHeight] = useState(720);
const [cameraCount, setCameraCount] = useState(1);
const [result, setResult] = useState("26.32MB");
const [singleCameraShm, setSingleCameraShm] = useState("26.32MB");
const [totalShm, setTotalShm] = useState("26.32MB");
const calculate = () => {
if (!width || !height || !cameraCount) {
setResult("Please enter valid values");
setSingleCameraShm("-");
setTotalShm("-");
return;
}
// Single camera base SHM calculation (excluding logs)
// Formula: (width * height * 1.5 * 20 + 270480) / 1048576
const singleCameraBase =
(width * height * 1.5 * 20 + 270480) / 1048576;
setSingleCameraShm(`${singleCameraBase.toFixed(2)}mb`);
// Total SHM calculation (multiple cameras, including logs)
const totalBase = singleCameraBase * cameraCount;
const finalResult = totalBase + 40; // Default includes logs +40mb
setTotalShm(`${(totalBase + 40).toFixed(2)}mb`);
// Format result
if (finalResult < 1) {
setResult(`${(finalResult * 1024).toFixed(2)}kb`);
} else if (finalResult >= 1024) {
setResult(`${(finalResult / 1024).toFixed(2)}gb`);
} else {
setResult(`${finalResult.toFixed(2)}mb`);
}
};
const formatWithUnit = (value) => {
const match = value.match(/^([\d.]+)(mb|kb|gb)$/i);
if (match) {
return (
<>
{match[1]}<span className={styles.unit}>{match[2]}</span>
</>
);
}
return value;
};
const applyPreset = (w, h, count) => {
setWidth(w);
setHeight(h);
setCameraCount(count);
calculate();
};
useEffect(() => {
calculate();
}, [width, height, cameraCount]);
return (
<div className={styles.shmCalculator}>
<div className={styles.card}>
<h3 className={styles.title}>SHM Calculator</h3>
<p className={styles.description}>
Calculate required shared memory (SHM) based on camera resolution and
count
</p>
<Admonition type="note">
The resolution below is the <strong>detect</strong> stream resolution,
not the <strong>record</strong> stream resolution. SHM size is
determined by the detect resolution used for object detection.{" "}
<a href="/frigate/camera_setup#choosing-a-detect-resolution">
Learn more about choosing a detect resolution.
</a>
</Admonition>
{width * height > 1280 * 720 && (
<Admonition type="warning">
Using a detect resolution higher than 720p is not recommended.
Higher resolutions do not improve object detection accuracy and will
consume significantly more resources.
</Admonition>
)}
<div className="row">
<div className="col col--6">
<div className={styles.formGroup}>
<label htmlFor="width" className={styles.label}>
Width:
</label>
<input
id="width"
type="number"
min="1"
placeholder="e.g.: 1280"
className={styles.input}
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
/>
</div>
</div>
<div className="col col--6">
<div className={styles.formGroup}>
<label htmlFor="height" className={styles.label}>
Height:
</label>
<input
id="height"
type="number"
min="1"
placeholder="e.g.: 720"
className={styles.input}
value={height}
onChange={(e) => setHeight(Number(e.target.value))}
/>
</div>
</div>
</div>
<div className={styles.formGroup}>
<label htmlFor="cameraCount" className={styles.label}>
Camera Count:
</label>
<input
id="cameraCount"
type="number"
min="1"
placeholder="e.g.: 8"
className={styles.input}
value={cameraCount}
onChange={(e) => setCameraCount(Number(e.target.value))}
/>
</div>
<div className={styles.resultSection}>
<h4>Calculation Result</h4>
<div className={styles.resultValue}>
<span className={styles.resultNumber}>{formatWithUnit(result)}</span>
</div>
<div className={styles.formulaDisplay}>
<p>
<strong>Single Camera:</strong> {formatWithUnit(singleCameraShm)}
</p>
<p>
<strong>Formula:</strong> (width × height × 1.5 × 20 + 270480) ÷
1048576
</p>
{cameraCount > 1 && (
<p>
<strong>Total ({cameraCount} cameras):</strong> {formatWithUnit(totalShm)}
</p>
)}
<p>
<strong>With Logs:</strong> + 40<span className={styles.unit}>mb</span>
</p>
</div>
</div>
<div className={styles.presets}>
<h4>Common Presets</h4>
<div className={styles.presetButtons}>
<button
className="button button--outline button--primary button--sm"
onClick={() => applyPreset(640, 360, 1)}
>
640x360 × 1
</button>
<button
className="button button--outline button--primary button--sm"
onClick={() => applyPreset(1280, 720, 1)}
>
1280x720 × 1
</button>
<button
className="button button--outline button--primary button--sm"
onClick={() => applyPreset(1280, 720, 4)}
>
1280x720 × 4
</button>
<button
className="button button--outline button--primary button--sm"
onClick={() => applyPreset(1280, 720, 8)}
>
1280x720 × 8
</button>
</div>
</div>
</div>
</div>
);
};
export default ShmCalculator;
@@ -1,131 +0,0 @@
.shmCalculator {
margin: 2rem 0;
max-width: 600px;
}
.card {
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-border-color);
border-radius: 12px;
padding: 2rem;
box-shadow: var(--ifm-global-shadow-lw);
}
[data-theme='light'] .card {
background: var(--ifm-color-emphasis-100);
border: 1px solid var(--ifm-color-emphasis-300);
}
.title {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
color: var(--ifm-font-color-base);
font-weight: var(--ifm-font-weight-semibold);
}
.description {
margin: 0 0 1.5rem 0;
color: var(--ifm-font-color-secondary);
font-size: 0.9rem;
}
.formGroup {
margin-bottom: 1rem;
}
.label {
display: block;
margin-bottom: 0.25rem;
color: var(--ifm-font-color-base);
font-weight: var(--ifm-font-weight-semibold);
font-size: 0.9rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--ifm-border-color);
border-radius: 6px;
background: var(--ifm-background-color);
color: var(--ifm-font-color-base);
font-size: 0.95rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
[data-theme='light'] .input {
background: #fff;
border: 1px solid #d0d7de;
}
.input:focus {
outline: none;
border-color: var(--ifm-color-primary);
box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest);
}
.resultSection {
margin-top: 1rem;
padding: 1.5rem;
background: var(--ifm-background-color);
border-radius: 8px;
border: 1px solid var(--ifm-border-color);
}
[data-theme='light'] .resultSection {
background: #f6f8fa;
border: 1px solid #d0d7de;
}
.resultSection h4 {
margin: 0 0 1rem 0;
color: var(--ifm-font-color-base);
font-weight: var(--ifm-font-weight-semibold);
}
.resultValue {
text-align: center;
padding: 1rem;
background: var(--ifm-color-primary);
border-radius: 6px;
margin-bottom: 1rem;
}
.resultNumber {
font-size: 2rem;
font-weight: var(--ifm-font-weight-bold);
color: #fff;
}
.formulaDisplay {
font-size: 0.85rem;
color: var(--ifm-font-color-secondary);
line-height: 1.6;
}
.formulaDisplay p {
margin: 0.25rem 0;
}
.formulaDisplay strong {
color: var(--ifm-font-color-base);
}
.unit {
text-transform: uppercase;
}
.presets {
margin-top: 1.5rem;
}
.presets h4 {
margin: 0 0 0.75rem 0;
color: var(--ifm-font-color-base);
font-weight: var(--ifm-font-weight-semibold);
}
.presetButtons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
-8
View File
@@ -234,11 +234,3 @@
content: "schema";
color: var(--ifm-color-secondary-contrast-foreground);
}
.code-block-error-line {
background-color: #ff000020;
display: block;
margin: 0 calc(-1 * var(--ifm-pre-padding));
padding: 0 var(--ifm-pre-padding);
border-left: 3px solid #ff000080;
}
+498 -2316
View File
File diff suppressed because it is too large Load Diff
+36 -69
View File
@@ -5,7 +5,6 @@ import copy
import json
import logging
import os
import platform
import traceback
import urllib
from datetime import datetime, timedelta
@@ -35,6 +34,7 @@ from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryPa
from frigate.api.defs.request.app_body import (
AppConfigSetBody,
MediaSyncBody,
ProfileSetBody,
)
from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig
@@ -244,29 +244,41 @@ def get_active_profile(request: Request):
return JSONResponse(content={"active_profile": config_obj.active_profile})
@router.put("/profile/set", dependencies=[Depends(require_role(["admin"]))])
def set_profile(request: Request, body: ProfileSetBody):
"""Activate or deactivate a profile."""
profile_manager = request.app.profile_manager
err = profile_manager.activate_profile(body.profile)
if err:
return JSONResponse(
content={"success": False, "message": err},
status_code=400,
)
request.app.dispatcher.publish("profile/state", body.profile or "none", retain=True)
return JSONResponse(
content={
"success": True,
"active_profile": body.profile,
}
)
@router.get("/ffmpeg/presets", dependencies=[Depends(allow_any_authenticated())])
def ffmpeg_presets():
"""Return available ffmpeg preset keys for config UI usage."""
machine = platform.machine().lower()
is_arm64 = machine in ("aarch64", "arm64", "armv8", "armv7l")
if is_arm64:
hwaccel_presets = [
"preset-rpi-64-h264",
"preset-rpi-64-h265",
"preset-jetson-h264",
"preset-jetson-h265",
"preset-rkmpp",
"preset-vaapi",
]
else:
hwaccel_presets = [
"preset-vaapi",
"preset-intel-qsv-h264",
"preset-intel-qsv-h265",
"preset-nvidia",
]
# Whitelist based on documented presets in ffmpeg_presets.md
hwaccel_presets = [
"preset-rpi-64-h264",
"preset-rpi-64-h265",
"preset-vaapi",
"preset-intel-qsv-h264",
"preset-intel-qsv-h265",
"preset-nvidia",
"preset-jetson-h264",
"preset-jetson-h265",
"preset-rkmpp",
]
input_presets = [
"preset-http-jpeg-generic",
"preset-http-mjpeg-generic",
@@ -329,7 +341,7 @@ def config_raw_paths(request: Request):
return JSONResponse(content=raw_paths)
@router.get("/config/raw", dependencies=[Depends(require_role(["admin"]))])
@router.get("/config/raw", dependencies=[Depends(allow_any_authenticated())])
def config_raw():
config_file = find_config_file()
@@ -613,34 +625,6 @@ def config_set(request: Request, body: AppConfigSetBody):
try:
config = FrigateConfig.parse(new_raw_config)
except ValidationError as e:
with open(config_file, "w") as f:
f.write(old_raw_config)
f.close()
logger.error(
f"Config Validation Error:\n\n{str(traceback.format_exc())}"
)
error_messages = []
for err in e.errors():
msg = err.get("msg", "")
# Strip pydantic "Value error, " prefix for cleaner display
if msg.startswith("Value error, "):
msg = msg[len("Value error, ") :]
error_messages.append(msg)
message = (
"; ".join(error_messages)
if error_messages
else "Check logs for error message."
)
return JSONResponse(
content=(
{
"success": False,
"message": f"Error saving config: {message}",
}
),
status_code=400,
)
except Exception:
with open(config_file, "w") as f:
f.write(old_raw_config)
@@ -900,10 +884,7 @@ def sync_media(body: MediaSyncBody = Body(...)):
202 Accepted with job_id, or 409 Conflict if job already running.
"""
job_id = start_media_sync_job(
dry_run=body.dry_run,
media_types=body.media_types,
force=body.force,
verbose=body.verbose,
dry_run=body.dry_run, media_types=body.media_types, force=body.force
)
if job_id is None:
@@ -1112,12 +1093,7 @@ def get_recognized_license_plates(
@router.get("/timeline", dependencies=[Depends(allow_any_authenticated())])
def timeline(
camera: str = "all",
limit: int = 100,
source_id: Optional[str] = None,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None):
clauses = []
selected_columns = [
@@ -1139,9 +1115,6 @@ def timeline(
else:
clauses.append((Timeline.source_id.in_(source_ids)))
# Enforce per-camera access control
clauses.append((Timeline.camera << allowed_cameras))
if len(clauses) == 0:
clauses.append((True))
@@ -1157,10 +1130,7 @@ def timeline(
@router.get("/timeline/hourly", dependencies=[Depends(allow_any_authenticated())])
def hourly_timeline(
params: AppTimelineHourlyQueryParameters = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
"""Get hourly summary for timeline."""
cameras = params.cameras
labels = params.labels
@@ -1178,9 +1148,6 @@ def hourly_timeline(
camera_list = cameras.split(",")
clauses.append((Timeline.camera << camera_list))
# Enforce per-camera access control
clauses.append((Timeline.camera << allowed_cameras))
if labels != "all":
label_list = labels.split(",")
clauses.append((Timeline.data["label"] << label_list))
+2 -68
View File
@@ -73,6 +73,7 @@ def require_admin_by_default():
"/stats",
"/stats/history",
"/config",
"/config/raw",
"/vainfo",
"/nvinfo",
"/labels",
@@ -895,7 +896,6 @@ def create_user(
User.notification_tokens: [],
}
).execute()
request.app.config_publisher.publisher.publish("config/auth", None)
return JSONResponse(content={"username": body.username})
@@ -913,7 +913,6 @@ def delete_user(request: Request, username: str):
)
User.delete_by_id(username)
request.app.config_publisher.publisher.publish("config/auth", None)
return JSONResponse(content={"success": True})
@@ -1033,7 +1032,6 @@ async def update_role(
)
User.set_by_id(username, {User.role: body.role})
request.app.config_publisher.publisher.publish("config/auth", None)
return JSONResponse(content={"success": True})
@@ -1047,16 +1045,7 @@ async def require_camera_access(
current_user = await get_current_user(request)
if isinstance(current_user, JSONResponse):
detail = "Authentication required"
try:
error_payload = json.loads(current_user.body)
detail = (
error_payload.get("message") or error_payload.get("detail") or detail
)
except Exception:
pass
raise HTTPException(status_code=current_user.status_code, detail=detail)
return current_user
role = current_user["role"]
all_camera_names = set(request.app.frigate_config.cameras.keys())
@@ -1074,61 +1063,6 @@ async def require_camera_access(
)
def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]:
owner_cameras: set[str] = set()
for camera_name, camera in request.app.frigate_config.cameras.items():
if stream_name == camera_name:
owner_cameras.add(camera_name)
continue
if stream_name in camera.live.streams.values():
owner_cameras.add(camera_name)
return owner_cameras
async def require_go2rtc_stream_access(
stream_name: Optional[str] = None,
request: Request = None,
):
"""Dependency to enforce go2rtc stream access based on owning camera access."""
if stream_name is None:
return
current_user = await get_current_user(request)
if isinstance(current_user, JSONResponse):
detail = "Authentication required"
try:
error_payload = json.loads(current_user.body)
detail = (
error_payload.get("message") or error_payload.get("detail") or detail
)
except Exception:
pass
raise HTTPException(status_code=current_user.status_code, detail=detail)
role = current_user["role"]
all_camera_names = set(request.app.frigate_config.cameras.keys())
roles_dict = request.app.frigate_config.auth.roles
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
# Admin or full access bypasses
if role == "admin" or not roles_dict.get(role):
return
owner_cameras = _get_stream_owner_cameras(request, stream_name)
if owner_cameras & set(allowed_cameras):
return
raise HTTPException(
status_code=403,
detail=f"Access denied to camera '{stream_name}'. Allowed: {allowed_cameras}",
)
async def get_allowed_cameras_for_filter(request: Request):
"""Dependency to get allowed_cameras for filtering lists."""
current_user = await get_current_user(request)
+5 -92
View File
@@ -20,10 +20,9 @@ from zeep.transports import AsyncTransport
from frigate.api.auth import (
allow_any_authenticated,
require_go2rtc_stream_access,
require_camera_access,
require_role,
)
from frigate.api.defs.request.app_body import CameraSetBody
from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig
from frigate.config.camera.updater import (
@@ -81,27 +80,14 @@ def go2rtc_streams():
@router.get(
"/go2rtc/streams/{stream_name}",
dependencies=[Depends(require_go2rtc_stream_access)],
"/go2rtc/streams/{camera_name}", dependencies=[Depends(require_camera_access)]
)
def go2rtc_camera_stream(request: Request, stream_name: str):
def go2rtc_camera_stream(request: Request, camera_name: str):
r = requests.get(
"http://127.0.0.1:1984/api/streams",
params={
"src": stream_name,
"video": "all",
"audio": "all",
"microphone": "",
},
f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=all&microphone"
)
if not r.ok:
camera_config = request.app.frigate_config.cameras.get(stream_name)
if camera_config is None:
for camera_name, camera in request.app.frigate_config.cameras.items():
if stream_name in camera.live.streams.values():
camera_config = request.app.frigate_config.cameras.get(camera_name)
break
camera_config = request.app.frigate_config.cameras.get(camera_name)
if camera_config and camera_config.enabled:
logger.error("Failed to fetch streams from go2rtc")
@@ -1169,76 +1155,3 @@ async def delete_camera(
},
status_code=200,
)
_SUB_COMMAND_FEATURES = {"motion_mask", "object_mask", "zone"}
@router.put(
"/camera/{camera_name}/set/{feature}",
dependencies=[Depends(require_role(["admin"]))],
)
@router.put(
"/camera/{camera_name}/set/{feature}/{sub_command}",
dependencies=[Depends(require_role(["admin"]))],
)
def camera_set(
request: Request,
camera_name: str,
feature: str,
body: CameraSetBody,
sub_command: str | None = None,
):
"""Set a camera feature state. Use camera_name='*' to target all cameras."""
dispatcher = request.app.dispatcher
frigate_config: FrigateConfig = request.app.frigate_config
if feature == "profile":
if camera_name != "*":
return JSONResponse(
content={
"success": False,
"message": "Profile feature requires camera_name='*'",
},
status_code=400,
)
dispatcher._receive("profile/set", body.value)
return JSONResponse(content={"success": True})
if feature not in dispatcher._camera_settings_handlers:
return JSONResponse(
content={"success": False, "message": f"Unknown feature: {feature}"},
status_code=400,
)
if sub_command and feature not in _SUB_COMMAND_FEATURES:
return JSONResponse(
content={
"success": False,
"message": f"Feature '{feature}' does not support sub-commands",
},
status_code=400,
)
if camera_name == "*":
cameras = list(frigate_config.cameras.keys())
elif camera_name not in frigate_config.cameras:
return JSONResponse(
content={
"success": False,
"message": f"Camera '{camera_name}' not found",
},
status_code=404,
)
else:
cameras = [camera_name]
for cam in cameras:
topic = (
f"{cam}/{feature}/{sub_command}/set"
if sub_command
else f"{cam}/{feature}/set"
)
dispatcher._receive(topic, body.value)
return JSONResponse(content={"success": True})
+46 -445
View File
@@ -26,11 +26,6 @@ from frigate.api.defs.response.chat_response import (
from frigate.api.defs.tags import Tags
from frigate.api.event import events
from frigate.genai.utils import build_assistant_message_for_conversation
from frigate.jobs.vlm_watch import (
get_vlm_watch_job,
start_vlm_watch_job,
stop_vlm_watch_job,
)
logger = logging.getLogger(__name__)
@@ -87,16 +82,6 @@ class ToolExecuteRequest(BaseModel):
arguments: Dict[str, Any]
class VLMMonitorRequest(BaseModel):
"""Request model for starting a VLM watch job."""
camera: str
condition: str
max_duration_minutes: int = 60
labels: List[str] = []
zones: List[str] = []
def get_tool_definitions() -> List[Dict[str, Any]]:
"""
Get OpenAI-compatible tool definitions for Frigate.
@@ -110,11 +95,9 @@ def get_tool_definitions() -> List[Dict[str, Any]]:
"function": {
"name": "search_objects",
"description": (
"Search the historical record of detected objects in Frigate. "
"Use this ONLY for questions about the PAST — e.g. 'did anyone come by today?', "
"'when was the last car?', 'show me detections from yesterday'. "
"Do NOT use this for monitoring or alerting requests about future events — "
"use start_camera_watch instead for those. "
"Search for detected objects in Frigate by camera, object label, time range, "
"zones, and other filters. Use this to answer questions about when "
"objects were detected, what objects appeared, or to find specific object detections. "
"An 'object' in Frigate represents a tracked detection (e.g., a person, package, car). "
"When the user asks about a specific name (person, delivery company, animal, etc.), "
"filter by sub_label only and do not set label."
@@ -157,70 +140,15 @@ def get_tool_definitions() -> List[Dict[str, Any]]:
"required": [],
},
},
{
"type": "function",
"function": {
"name": "set_camera_state",
"description": (
"Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). "
"Use camera='*' to apply to all cameras at once. "
"Only call this tool when the user explicitly asks to change a camera setting. "
"Requires admin privileges."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera name to target, or '*' to target all cameras.",
},
"feature": {
"type": "string",
"enum": [
"detect",
"record",
"snapshots",
"audio",
"motion",
"enabled",
"birdseye",
"birdseye_mode",
"improve_contrast",
"ptz_autotracker",
"motion_contour_area",
"motion_threshold",
"notifications",
"audio_transcription",
"review_alerts",
"review_detections",
"object_descriptions",
"review_descriptions",
"profile",
],
"description": (
"The feature to change. Most features accept ON or OFF. "
"birdseye_mode accepts CONTINUOUS, MOTION, or OBJECTS. "
"motion_contour_area and motion_threshold accept a number. "
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
),
},
"value": {
"type": "string",
"description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.",
},
},
"required": ["camera", "feature", "value"],
},
},
},
{
"type": "function",
"function": {
"name": "get_live_context",
"description": (
"Get the current live image and detection information for a camera: objects being tracked, "
"Get the current detection information for a camera: objects being tracked, "
"zones, timestamps. Use this to understand what is visible in the live view. "
"Call this when answering questions about what is happening right now on a specific camera."
"Call this when the user has included a live image (via include_live_image) or "
"when answering questions about what is happening right now on a specific camera."
),
"parameters": {
"type": "object",
@@ -234,65 +162,6 @@ def get_tool_definitions() -> List[Dict[str, Any]]:
},
},
},
{
"type": "function",
"function": {
"name": "start_camera_watch",
"description": (
"Start a continuous VLM watch job that monitors a camera and sends a notification "
"when a specified condition is met. Use this when the user wants to be alerted about "
"a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. "
"Only one watch job can run at a time. Returns a job ID."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera ID to monitor.",
},
"condition": {
"type": "string",
"description": (
"Natural-language description of the condition to watch for, "
"e.g. 'a person arrives at the front door'."
),
},
"max_duration_minutes": {
"type": "integer",
"description": "Maximum time to watch before giving up (minutes, default 60).",
"default": 60,
},
"labels": {
"type": "array",
"items": {"type": "string"},
"description": "Object labels that should trigger a VLM check (e.g. ['person', 'car']). If omitted, any detection on the camera triggers a check.",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "Zone names to filter by. If specified, only detections in these zones trigger a VLM check.",
},
},
"required": ["camera", "condition"],
},
},
},
{
"type": "function",
"function": {
"name": "stop_camera_watch",
"description": (
"Cancel the currently running VLM watch job. Use this when the user wants to "
"stop a previously started watch, e.g. 'stop watching the front door'."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
]
@@ -386,7 +255,6 @@ async def _execute_search_objects(
description="Execute a tool function call from an LLM.",
)
async def execute_tool(
request: Request,
body: ToolExecuteRequest = Body(...),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
) -> JSONResponse:
@@ -404,12 +272,6 @@ async def execute_tool(
if tool_name == "search_objects":
return await _execute_search_objects(arguments, allowed_cameras)
if tool_name == "set_camera_state":
result = await _execute_set_camera_state(request, arguments)
return JSONResponse(
content=result, status_code=200 if result.get("success") else 400
)
return JSONResponse(
content={
"success": False,
@@ -459,54 +321,12 @@ async def _execute_get_live_context(
"stationary": obj_dict.get("stationary", False),
}
result: Dict[str, Any] = {
return {
"camera": camera,
"timestamp": frame_time,
"detections": list(tracked_objects_dict.values()),
}
# Grab live frame and handle based on provider configuration
image_url = await _get_live_frame_image_url(request, camera, allowed_cameras)
if image_url:
genai_manager = request.app.genai_manager
if genai_manager.tool_client is genai_manager.vision_client:
# Same provider handles both roles — pass image URL so it can
# be injected as a user message (images can't be in tool results)
result["_image_url"] = image_url
elif genai_manager.vision_client is not None:
# Separate vision provider — have it describe the image,
# providing detection context so it knows what to focus on
frame_bytes = _decode_data_url(image_url)
if frame_bytes:
detections = result.get("detections", [])
if detections:
detection_lines = []
for d in detections:
parts = [d.get("label", "unknown")]
if d.get("sub_label"):
parts.append(f"({d['sub_label']})")
if d.get("zones"):
parts.append(f"in {', '.join(d['zones'])}")
detection_lines.append(" ".join(parts))
context = (
"The following objects are currently being tracked: "
+ "; ".join(detection_lines)
+ "."
)
else:
context = "No objects are currently being tracked."
description = genai_manager.vision_client._send(
f"Describe what you see in this security camera image. "
f"{context} Focus on the scene, any visible activity, "
f"and details about the tracked objects.",
[frame_bytes],
)
if description:
result["image_description"] = description
return result
except Exception as e:
logger.error(f"Error executing get_live_context: {e}", exc_info=True)
return {
@@ -522,8 +342,8 @@ async def _get_live_frame_image_url(
"""
Fetch the current live frame for a camera as a base64 data URL.
Returns None if the frame cannot be retrieved. Used by get_live_context
to attach the live image to the conversation.
Returns None if the frame cannot be retrieved. Used when include_live_image
is set to attach the image to the first user message.
"""
if (
camera not in allowed_cameras
@@ -538,12 +358,12 @@ async def _get_live_frame_image_url(
if frame is None:
return None
height, width = frame.shape[:2]
target_height = 480
if height > target_height:
scale = target_height / height
max_dimension = 1024
if height > max_dimension or width > max_dimension:
scale = max_dimension / max(height, width)
frame = cv2.resize(
frame,
(int(width * scale), target_height),
(int(width * scale), int(height * scale)),
interpolation=cv2.INTER_AREA,
)
_, img_encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
@@ -554,57 +374,6 @@ async def _get_live_frame_image_url(
return None
def _decode_data_url(data_url: str) -> Optional[bytes]:
"""Decode a base64 data URL to raw bytes."""
try:
# Format: data:image/jpeg;base64,<data>
_, encoded = data_url.split(",", 1)
return base64.b64decode(encoded)
except (ValueError, Exception) as e:
logger.debug("Failed to decode data URL: %s", e)
return None
async def _execute_set_camera_state(
request: Request,
arguments: Dict[str, Any],
) -> Dict[str, Any]:
role = request.headers.get("remote-role", "")
if "admin" not in [r.strip() for r in role.split(",")]:
return {"error": "Admin privileges required to change camera settings."}
camera = arguments.get("camera", "").strip()
feature = arguments.get("feature", "").strip()
value = arguments.get("value", "").strip()
if not camera or not feature or not value:
return {"error": "camera, feature, and value are all required."}
dispatcher = request.app.dispatcher
frigate_config = request.app.frigate_config
if feature == "profile":
if camera != "*":
return {"error": "Profile feature requires camera='*'."}
dispatcher._receive("profile/set", value)
return {"success": True, "camera": camera, "feature": feature, "value": value}
if feature not in dispatcher._camera_settings_handlers:
return {"error": f"Unknown feature: {feature}"}
if camera == "*":
cameras = list(frigate_config.cameras.keys())
elif camera not in frigate_config.cameras:
return {"error": f"Camera '{camera}' not found."}
else:
cameras = [camera]
for cam in cameras:
dispatcher._receive(f"{cam}/{feature}/set", value)
return {"success": True, "camera": camera, "feature": feature, "value": value}
async def _execute_tool_internal(
tool_name: str,
arguments: Dict[str, Any],
@@ -629,8 +398,6 @@ async def _execute_tool_internal(
except (json.JSONDecodeError, AttributeError) as e:
logger.warning(f"Failed to extract tool result: {e}")
return {"error": "Failed to parse tool result"}
elif tool_name == "set_camera_state":
return await _execute_set_camera_state(request, arguments)
elif tool_name == "get_live_context":
camera = arguments.get("camera")
if not camera:
@@ -641,91 +408,26 @@ async def _execute_tool_internal(
)
return {"error": "Camera parameter is required"}
return await _execute_get_live_context(request, camera, allowed_cameras)
elif tool_name == "start_camera_watch":
return await _execute_start_camera_watch(request, arguments)
elif tool_name == "stop_camera_watch":
return _execute_stop_camera_watch()
else:
logger.error(
"Tool call failed: unknown tool %r. Expected one of: search_objects, get_live_context, "
"start_camera_watch, stop_camera_watch. Arguments received: %s",
"Tool call failed: unknown tool %r. Expected one of: search_objects, get_live_context. "
"Arguments received: %s",
tool_name,
json.dumps(arguments),
)
return {"error": f"Unknown tool: {tool_name}"}
async def _execute_start_camera_watch(
request: Request,
arguments: Dict[str, Any],
) -> Dict[str, Any]:
camera = arguments.get("camera", "").strip()
condition = arguments.get("condition", "").strip()
max_duration_minutes = int(arguments.get("max_duration_minutes", 60))
labels = arguments.get("labels") or []
zones = arguments.get("zones") or []
if not camera or not condition:
return {"error": "camera and condition are required."}
config = request.app.frigate_config
if camera not in config.cameras:
return {"error": f"Camera '{camera}' not found."}
genai_manager = request.app.genai_manager
vision_client = genai_manager.vision_client or genai_manager.tool_client
if vision_client is None:
return {"error": "No vision/GenAI provider configured."}
try:
job_id = start_vlm_watch_job(
camera=camera,
condition=condition,
max_duration_minutes=max_duration_minutes,
config=config,
frame_processor=request.app.detected_frames_processor,
genai_manager=genai_manager,
dispatcher=request.app.dispatcher,
labels=labels,
zones=zones,
)
except RuntimeError as e:
logger.error("Failed to start VLM watch job: %s", e, exc_info=True)
return {"error": "Failed to start VLM watch job."}
return {
"success": True,
"job_id": job_id,
"message": (
f"Now watching '{camera}' for: {condition}. "
f"You'll receive a notification when the condition is met (timeout: {max_duration_minutes} min)."
),
}
def _execute_stop_camera_watch() -> Dict[str, Any]:
cancelled = stop_vlm_watch_job()
if cancelled:
return {"success": True, "message": "Watch job cancelled."}
return {"success": False, "message": "No active watch job to cancel."}
async def _execute_pending_tools(
pending_tool_calls: List[Dict[str, Any]],
request: Request,
allowed_cameras: List[str],
) -> tuple[List[ToolCall], List[Dict[str, Any]], List[Dict[str, Any]]]:
) -> tuple[List[ToolCall], List[Dict[str, Any]]]:
"""
Execute a list of tool calls.
Returns:
(ToolCall list for API response,
tool result dicts for conversation,
extra messages to inject after tool results e.g. user messages with images)
Execute a list of tool calls; return (ToolCall list for API response, tool result dicts for conversation).
"""
tool_calls_out: List[ToolCall] = []
tool_results: List[Dict[str, Any]] = []
extra_messages: List[Dict[str, Any]] = []
for tool_call in pending_tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call.get("arguments") or {}
@@ -762,27 +464,6 @@ async def _execute_pending_tools(
for evt in tool_result
if isinstance(evt, dict)
]
# Extract _image_url from get_live_context results — images can
# only be sent in user messages, not tool results
if isinstance(tool_result, dict) and "_image_url" in tool_result:
image_url = tool_result.pop("_image_url")
extra_messages.append(
{
"role": "user",
"content": [
{
"type": "text",
"text": f"Here is the current live image from camera '{tool_result.get('camera', 'unknown')}'.",
},
{
"type": "image_url",
"image_url": {"url": image_url},
},
],
}
)
result_content = (
json.dumps(tool_result)
if isinstance(tool_result, (dict, list))
@@ -818,7 +499,7 @@ async def _execute_pending_tools(
"content": error_content,
}
)
return (tool_calls_out, tool_results, extra_messages)
return (tool_calls_out, tool_results)
@router.post(
@@ -874,13 +555,7 @@ async def chat_completion(
if camera_config.friendly_name
else camera_id.replace("_", " ").title()
)
zone_names = list(camera_config.zones.keys())
if zone_names:
cameras_info.append(
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
)
else:
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
cameras_section = ""
if cameras_info:
@@ -890,6 +565,14 @@ async def chat_completion(
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
)
live_image_note = ""
if body.include_live_image:
live_image_note = (
f"\n\nThe first user message includes a live image from camera "
f"'{body.include_live_image}'. Use get_live_context for that camera to get "
"current detection details (objects, zones) to aid in understanding the image."
)
system_prompt = f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
Current server local date and time: {current_date_str} at {current_time_str}
@@ -899,7 +582,7 @@ Do not start your response with phrases like "I will check...", "Let me see...",
Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields.
When users ask about "today", "yesterday", "this week", etc., use the current date above as reference.
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
Always be accurate with time calculations based on the current date provided.{cameras_section}"""
Always be accurate with time calculations based on the current date provided.{cameras_section}{live_image_note}"""
conversation.append(
{
@@ -908,6 +591,7 @@ Always be accurate with time calculations based on the current date provided.{ca
}
)
first_user_message_seen = False
for msg in body.messages:
msg_dict = {
"role": msg.role,
@@ -918,6 +602,21 @@ Always be accurate with time calculations based on the current date provided.{ca
if msg.name:
msg_dict["name"] = msg.name
if (
msg.role == "user"
and not first_user_message_seen
and body.include_live_image
):
first_user_message_seen = True
image_url = await _get_live_frame_image_url(
request, body.include_live_image, allowed_cameras
)
if image_url:
msg_dict["content"] = [
{"type": "text", "text": msg.content},
{"type": "image_url", "image_url": {"url": image_url}},
]
conversation.append(msg_dict)
tool_iterations = 0
@@ -975,16 +674,11 @@ Always be accurate with time calculations based on the current date provided.{ca
msg.get("content"), pending
)
)
(
executed_calls,
tool_results,
extra_msgs,
) = await _execute_pending_tools(
executed_calls, tool_results = await _execute_pending_tools(
pending, request, allowed_cameras
)
stream_tool_calls.extend(executed_calls)
conversation.extend(tool_results)
conversation.extend(extra_msgs)
yield (
json.dumps(
{
@@ -1091,12 +785,11 @@ Always be accurate with time calculations based on the current date provided.{ca
f"Tool calls detected (iteration {tool_iterations}/{max_iterations}): "
f"{len(pending_tool_calls)} tool(s) to execute"
)
executed_calls, tool_results, extra_msgs = await _execute_pending_tools(
executed_calls, tool_results = await _execute_pending_tools(
pending_tool_calls, request, allowed_cameras
)
tool_calls.extend(executed_calls)
conversation.extend(tool_results)
conversation.extend(extra_msgs)
logger.debug(
f"Added {len(tool_results)} tool result(s) to conversation. "
f"Continuing with next LLM call..."
@@ -1126,95 +819,3 @@ Always be accurate with time calculations based on the current date provided.{ca
},
status_code=500,
)
# ---------------------------------------------------------------------------
# VLM Monitor endpoints
# ---------------------------------------------------------------------------
@router.post(
"/vlm/monitor",
dependencies=[Depends(allow_any_authenticated())],
summary="Start a VLM watch job",
description=(
"Start monitoring a camera with the vision provider. "
"The VLM analyzes live frames until the specified condition is met, "
"then sends a notification. Only one watch job can run at a time."
),
)
async def start_vlm_monitor(
request: Request,
body: VLMMonitorRequest,
) -> JSONResponse:
config = request.app.frigate_config
genai_manager = request.app.genai_manager
if body.camera not in config.cameras:
return JSONResponse(
content={"success": False, "message": f"Camera '{body.camera}' not found."},
status_code=404,
)
vision_client = genai_manager.vision_client or genai_manager.tool_client
if vision_client is None:
return JSONResponse(
content={
"success": False,
"message": "No vision/GenAI provider configured.",
},
status_code=400,
)
try:
job_id = start_vlm_watch_job(
camera=body.camera,
condition=body.condition,
max_duration_minutes=body.max_duration_minutes,
config=config,
frame_processor=request.app.detected_frames_processor,
genai_manager=genai_manager,
dispatcher=request.app.dispatcher,
labels=body.labels,
zones=body.zones,
)
except RuntimeError as e:
logger.error("Failed to start VLM watch job: %s", e, exc_info=True)
return JSONResponse(
content={"success": False, "message": "Failed to start VLM watch job."},
status_code=409,
)
return JSONResponse(
content={"success": True, "job_id": job_id},
status_code=201,
)
@router.get(
"/vlm/monitor",
dependencies=[Depends(allow_any_authenticated())],
summary="Get current VLM watch job",
description="Returns the current (or most recently completed) VLM watch job.",
)
async def get_vlm_monitor() -> JSONResponse:
job = get_vlm_watch_job()
if job is None:
return JSONResponse(content={"active": False}, status_code=200)
return JSONResponse(content={"active": True, **job.to_dict()}, status_code=200)
@router.delete(
"/vlm/monitor",
dependencies=[Depends(allow_any_authenticated())],
summary="Cancel the current VLM watch job",
description="Cancels the running watch job if one exists.",
)
async def cancel_vlm_monitor() -> JSONResponse:
cancelled = stop_vlm_watch_job()
if not cancelled:
return JSONResponse(
content={"success": False, "message": "No active watch job to cancel."},
status_code=404,
)
return JSONResponse(content={"success": True}, status_code=200)
-171
View File
@@ -338,82 +338,6 @@ async def recognize_face(request: Request, file: UploadFile):
)
@router.post(
"/faces/{name}/reclassify",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Reclassify a face image to a different name",
description="""Moves a single face image from one person's folder to another.
The image is moved and renamed, and the face classifier is cleared to
incorporate the change. Returns a success message or an error if the
image or target name is invalid.""",
)
def reclassify_face_image(request: Request, name: str, body: dict = None):
if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse(
status_code=400,
content={"message": "Face recognition is not enabled.", "success": False},
)
json: dict[str, Any] = body or {}
image_id = sanitize_filename(json.get("id", ""))
new_name = sanitize_filename(json.get("new_name", ""))
if not image_id or not new_name:
return JSONResponse(
content=(
{
"success": False,
"message": "Both 'id' and 'new_name' are required.",
}
),
status_code=400,
)
if new_name == name:
return JSONResponse(
content=(
{
"success": False,
"message": "New name must differ from the current name.",
}
),
status_code=400,
)
source_folder = os.path.join(FACE_DIR, sanitize_filename(name))
source_file = os.path.join(source_folder, image_id)
if not os.path.isfile(source_file):
return JSONResponse(
content=(
{
"success": False,
"message": f"Image not found: {image_id}",
}
),
status_code=404,
)
target_filename = f"{new_name}-{datetime.datetime.now().timestamp()}.webp"
target_folder = os.path.join(FACE_DIR, new_name)
os.makedirs(target_folder, exist_ok=True)
shutil.move(source_file, os.path.join(target_folder, target_filename))
# Clean up empty source folder
if os.path.exists(source_folder) and not os.listdir(source_folder):
os.rmdir(source_folder)
context: EmbeddingsContext = request.app.embeddings
context.clear_face_classifier()
return JSONResponse(
content=({"success": True, "message": "Successfully reclassified face."}),
status_code=200,
)
@router.post(
"/faces/{name}/delete",
response_model=GenericResponse,
@@ -863,101 +787,6 @@ def delete_classification_dataset_images(
)
@router.post(
"/classification/{name}/dataset/{category}/reclassify",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Reclassify a dataset image to a different category",
description="""Moves a single dataset image from one category to another.
The image is re-saved as PNG in the target category and removed from the source.""",
)
def reclassify_classification_image(
request: Request, name: str, category: str, body: dict = None
):
config: FrigateConfig = request.app.frigate_config
if name not in config.classification.custom:
return JSONResponse(
content=(
{
"success": False,
"message": f"{name} is not a known classification model.",
}
),
status_code=404,
)
json: dict[str, Any] = body or {}
image_id = sanitize_filename(json.get("id", ""))
new_category = sanitize_filename(json.get("new_category", ""))
if not image_id or not new_category:
return JSONResponse(
content=(
{
"success": False,
"message": "Both 'id' and 'new_category' are required.",
}
),
status_code=400,
)
if new_category == category:
return JSONResponse(
content=(
{
"success": False,
"message": "New category must differ from the current category.",
}
),
status_code=400,
)
sanitized_name = sanitize_filename(name)
source_folder = os.path.join(
CLIPS_DIR, sanitized_name, "dataset", sanitize_filename(category)
)
source_file = os.path.join(source_folder, image_id)
if not os.path.isfile(source_file):
return JSONResponse(
content=(
{
"success": False,
"message": f"Image not found: {image_id}",
}
),
status_code=404,
)
random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
timestamp = datetime.datetime.now().timestamp()
new_name = f"{new_category}-{timestamp}-{random_id}.png"
target_folder = os.path.join(CLIPS_DIR, sanitized_name, "dataset", new_category)
os.makedirs(target_folder, exist_ok=True)
img = cv2.imread(source_file)
cv2.imwrite(os.path.join(target_folder, new_name), img)
os.unlink(source_file)
# Clean up empty source folder (unless it is "none")
if (
os.path.exists(source_folder)
and not os.listdir(source_folder)
and category.lower() != "none"
):
os.rmdir(source_folder)
# Mark dataset as changed so UI knows retraining is needed
write_training_metadata(sanitized_name, 0)
return JSONResponse(
content=({"success": True, "message": "Successfully reclassified image."}),
status_code=200,
)
@router.put(
"/classification/{name}/dataset/{old_category}/rename",
response_model=GenericResponse,
@@ -35,7 +35,7 @@ class MediaEventsSnapshotQueryParams(BaseModel):
bbox: Optional[int] = None
crop: Optional[int] = None
height: Optional[int] = None
quality: Optional[int] = None
quality: Optional[int] = 70
class MediaMjpegFeedQueryParams(BaseModel):
+4 -6
View File
@@ -30,8 +30,10 @@ class AppPutRoleBody(BaseModel):
role: str
class CameraSetBody(BaseModel):
value: str = Field(..., description="The value to set for the feature")
class ProfileSetBody(BaseModel):
profile: Optional[str] = Field(
default=None, description="Profile name to activate, or null to deactivate"
)
class MediaSyncBody(BaseModel):
@@ -45,7 +47,3 @@ class MediaSyncBody(BaseModel):
force: bool = Field(
default=False, description="If True, bypass safety threshold checks"
)
verbose: bool = Field(
default=False,
description="If True, write full orphan file list to disk",
)
+7
View File
@@ -32,6 +32,13 @@ class ChatCompletionRequest(BaseModel):
le=10,
description="Maximum number of tool call iterations (default: 5)",
)
include_live_image: Optional[str] = Field(
default=None,
description=(
"If set, the current live frame from this camera is attached to the first "
"user message as multimodal content. Use with get_live_context for detection info."
),
)
stream: bool = Field(
default=False,
description="If true, stream the final assistant response in the body as newline-delimited JSON.",
+31 -16
View File
@@ -1,6 +1,5 @@
"""Event apis."""
import asyncio
import base64
import datetime
import json
@@ -13,6 +12,7 @@ from pathlib import Path
from typing import List
from urllib.parse import unquote
import cv2
import numpy as np
from fastapi import APIRouter, Request
from fastapi.params import Depends
@@ -61,7 +61,7 @@ from frigate.const import CLIPS_DIR, TRIGGER_DIR
from frigate.embeddings import EmbeddingsContext
from frigate.models import Event, ReviewSegment, Timeline, Trigger
from frigate.track.object_processing import TrackedObject
from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image
from frigate.util.file import get_event_thumbnail_bytes
from frigate.util.time import get_dst_transitions, get_tz_modifiers
logger = logging.getLogger(__name__)
@@ -1081,8 +1081,30 @@ async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = N
content=({"success": False, "message": message}), status_code=400
)
# load clean.webp or clean.png (legacy)
try:
image, is_clean_snapshot = load_event_snapshot_image(event, clean_only=True)
filename_webp = f"{event.camera}-{event.id}-clean.webp"
filename_png = f"{event.camera}-{event.id}-clean.png"
image_path = None
if os.path.exists(os.path.join(CLIPS_DIR, filename_webp)):
image_path = os.path.join(CLIPS_DIR, filename_webp)
elif os.path.exists(os.path.join(CLIPS_DIR, filename_png)):
image_path = os.path.join(CLIPS_DIR, filename_png)
if image_path is None:
logger.error(f"Unable to find clean snapshot for event: {event.id}")
return JSONResponse(
content=(
{
"success": False,
"message": "Unable to find clean snapshot for event",
}
),
status_code=400,
)
image = cv2.imread(image_path)
except Exception:
logger.error(f"Unable to load clean snapshot for event: {event.id}")
return JSONResponse(
@@ -1092,22 +1114,17 @@ async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = N
status_code=400,
)
if not is_clean_snapshot or image is None or image.size == 0:
logger.error(f"Unable to find clean snapshot for event: {event.id}")
if image is None or image.size == 0:
logger.error(f"Unable to load clean snapshot for event: {event.id}")
return JSONResponse(
content=(
{
"success": False,
"message": "Unable to find clean snapshot for event",
}
{"success": False, "message": "Unable to load clean snapshot for event"}
),
status_code=400,
)
try:
plus_id = await asyncio.to_thread(
request.app.frigate_config.plus_api.upload_image, image, event.camera
)
plus_id = request.app.frigate_config.plus_api.upload_image(image, event.camera)
except Exception as ex:
logger.exception(ex)
return JSONResponse(
@@ -1123,8 +1140,7 @@ async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = N
box = event.data["box"]
try:
await asyncio.to_thread(
request.app.frigate_config.plus_api.add_annotation,
request.app.frigate_config.plus_api.add_annotation(
event.plus_id,
box,
event.label,
@@ -1214,8 +1230,7 @@ async def false_positive(request: Request, event_id: str):
)
try:
await asyncio.to_thread(
request.app.frigate_config.plus_api.add_false_positive,
request.app.frigate_config.plus_api.add_false_positive(
event.plus_id,
region,
box,
-19
View File
@@ -46,7 +46,6 @@ from frigate.record.export import (
DEFAULT_TIME_LAPSE_FFMPEG_ARGS,
PlaybackSourceEnum,
RecordingExporter,
validate_ffmpeg_args,
)
from frigate.util.time import is_current_hour
@@ -548,24 +547,6 @@ def export_recording_custom(
export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
# Validate user-provided ffmpeg args to prevent injection
for args_label, args_value in [
("input", ffmpeg_input_args),
("output", ffmpeg_output_args),
]:
if args_value is not None:
valid, message = validate_ffmpeg_args(args_value)
if not valid:
return JSONResponse(
content=(
{
"success": False,
"message": f"Invalid ffmpeg {args_label} arguments: {message}",
}
),
status_code=400,
)
# Set default values if not provided (timelapse defaults)
if ffmpeg_input_args is None:
ffmpeg_input_args = ""
+2 -4
View File
@@ -29,13 +29,11 @@ from frigate.api import (
review,
)
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 (
EventMetadataPublisher,
)
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.config.profile_manager import ProfileManager
from frigate.debug_replay import DebugReplayManager
from frigate.embeddings import EmbeddingsContext
from frigate.genai import GenAIClientManager
@@ -71,8 +69,8 @@ def create_fastapi_app(
event_metadata_updater: EventMetadataPublisher,
config_publisher: CameraConfigUpdatePublisher,
replay_manager: DebugReplayManager,
dispatcher: Optional[Dispatcher] = None,
profile_manager: Optional[ProfileManager] = None,
dispatcher=None,
profile_manager=None,
enforce_default_admin: bool = True,
):
logger.info("Starting FastAPI app")
+116 -207
View File
@@ -35,9 +35,9 @@ from frigate.api.defs.query.media_query_parameters import (
from frigate.api.defs.tags import Tags
from frigate.camera.state import CameraState
from frigate.config import FrigateConfig
from frigate.config.camera.snapshots import SnapshotsConfig
from frigate.const import (
CACHE_DIR,
CLIPS_DIR,
INSTALL_DIR,
MAX_SEGMENT_DURATION,
PREVIEW_FRAME_TYPE,
@@ -45,18 +45,11 @@ from frigate.const import (
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
from frigate.output.preview import get_most_recent_preview_frame
from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.file import (
get_event_snapshot_bytes,
get_event_snapshot_path,
get_event_thumbnail_bytes,
load_event_snapshot_image,
)
from frigate.util.image import get_image_from_recording, get_image_quality_params
from frigate.util.media import get_keyframe_before
from frigate.util.file import get_event_thumbnail_bytes
from frigate.util.image import get_image_from_recording
logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.media])
@@ -117,24 +110,6 @@ def imagestream(
)
def _resolve_snapshot_settings(
snapshot_config: SnapshotsConfig, params: MediaEventsSnapshotQueryParams
) -> dict[str, Any]:
return {
"timestamp": snapshot_config.timestamp
if params.timestamp is None
else bool(params.timestamp),
"bounding_box": snapshot_config.bounding_box
if params.bbox is None
else bool(params.bbox),
"crop": snapshot_config.crop if params.crop is None else bool(params.crop),
"height": snapshot_config.height if params.height is None else params.height,
"quality": snapshot_config.quality
if params.quality is None
else params.quality,
}
@router.get("/{camera_name}/ptz/info", dependencies=[Depends(require_camera_access)])
async def camera_ptz_info(request: Request, camera_name: str):
if camera_name in request.app.frigate_config.cameras:
@@ -172,7 +147,14 @@ async def latest_frame(
"paths": params.paths,
"regions": params.regions,
}
quality_params = get_image_quality_params(extension.value, params.quality)
quality = params.quality
if extension == Extension.png:
quality_params = None
elif extension == Extension.webp:
quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), quality]
else: # jpg or jpeg
quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), quality]
if camera_name in request.app.frigate_config.cameras:
frame = frame_processor.get_current_frame(camera_name, draw_options)
@@ -610,33 +592,6 @@ async def vod_ts(
if recording.end_time > end_ts:
duration -= int((recording.end_time - end_ts) * 1000)
# nginx-vod-module pushes clipFrom forward to the next keyframe,
# which can leave too few frames and produce an empty/unplayable
# segment. Snap clipFrom back to the preceding keyframe so the
# segment always starts with a decodable frame.
if "clipFrom" in clip:
keyframe_ms = get_keyframe_before(recording.path, clip["clipFrom"])
if keyframe_ms is not None:
gained = clip["clipFrom"] - keyframe_ms
clip["clipFrom"] = keyframe_ms
duration += gained
logger.debug(
"VOD: snapped clipFrom to keyframe at %sms for %s, duration now %sms",
keyframe_ms,
recording.path,
duration,
)
else:
# could not read keyframes, remove clipFrom to use full recording
logger.debug(
"VOD: no keyframe info for %s, removing clipFrom to use full recording",
recording.path,
)
del clip["clipFrom"]
duration = int(recording.duration * 1000)
if recording.end_time > end_ts:
duration -= int((recording.end_time - end_ts) * 1000)
if duration < min_duration_ms:
# skip if the clip has no valid duration (too short to contain frames)
logger.debug(
@@ -774,7 +729,7 @@ async def vod_clip(
@router.get(
"/events/{event_id}/snapshot.jpg",
description="Returns a snapshot image for the specified object id.",
description="Returns a snapshot image for the specified object id. NOTE: The query params only take affect while the event is in-progress. Once the event has ended the snapshot configuration is used.",
)
async def event_snapshot(
request: Request,
@@ -793,22 +748,11 @@ async def event_snapshot(
content={"success": False, "message": "Snapshot not available"},
status_code=404,
)
snapshot_settings = _resolve_snapshot_settings(
request.app.frigate_config.cameras[event.camera].snapshots, params
)
jpg_bytes, frame_time = get_event_snapshot_bytes(
event,
ext="jpg",
timestamp=snapshot_settings["timestamp"],
bounding_box=snapshot_settings["bounding_box"],
crop=snapshot_settings["crop"],
height=snapshot_settings["height"],
quality=snapshot_settings["quality"],
timestamp_style=request.app.frigate_config.cameras[
event.camera
].timestamp_style,
colormap=request.app.frigate_config.model.colormap,
)
# read snapshot from disk
with open(
os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg"), "rb"
) as image_file:
jpg_bytes = image_file.read()
except DoesNotExist:
# see if the object is currently being tracked
try:
@@ -819,16 +763,13 @@ async def event_snapshot(
if event_id in camera_state.tracked_objects:
tracked_obj = camera_state.tracked_objects.get(event_id)
if tracked_obj is not None:
snapshot_settings = _resolve_snapshot_settings(
camera_state.camera_config.snapshots, params
)
jpg_bytes, frame_time = tracked_obj.get_img_bytes(
ext="jpg",
timestamp=snapshot_settings["timestamp"],
bounding_box=snapshot_settings["bounding_box"],
crop=snapshot_settings["crop"],
height=snapshot_settings["height"],
quality=snapshot_settings["quality"],
timestamp=params.timestamp,
bounding_box=params.bbox,
crop=params.crop,
height=params.height,
quality=params.quality,
)
await require_camera_access(camera_state.name, request=request)
except Exception:
@@ -866,6 +807,7 @@ async def event_snapshot(
@router.get(
"/events/{event_id}/thumbnail.{extension}",
dependencies=[Depends(require_camera_access)],
)
async def event_thumbnail(
request: Request,
@@ -909,12 +851,11 @@ async def event_thumbnail(
status_code=404,
)
img_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
img = cv2.imdecode(img_as_np, flags=1)
# android notifications prefer a 2:1 ratio
if format == "android":
img = cv2.copyMakeBorder(
img_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
img = cv2.imdecode(img_as_np, flags=1)
thumbnail = cv2.copyMakeBorder(
img,
0,
0,
@@ -924,14 +865,14 @@ async def event_thumbnail(
(0, 0, 0),
)
quality_params = None
if extension in (Extension.jpg, Extension.jpeg):
quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), 70]
elif extension == Extension.webp:
quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), 60]
quality_params = None
if extension in (Extension.jpg, Extension.jpeg):
quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), 70]
elif extension == Extension.webp:
quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), 60]
_, encoded = cv2.imencode(f".{extension.value}", img, quality_params)
thumbnail_bytes = encoded.tobytes()
_, img = cv2.imencode(f".{extension.value}", thumbnail, quality_params)
thumbnail_bytes = img.tobytes()
return Response(
thumbnail_bytes,
@@ -1084,20 +1025,18 @@ def clear_region_grid(request: Request, camera_name: str):
@router.get(
"/events/{event_id}/snapshot-clean.webp",
dependencies=[Depends(require_camera_access)],
)
async def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
webp_bytes = None
event_complete = False
try:
event = Event.get(Event.id == event_id)
event_complete = event.end_time is not None
await require_camera_access(event.camera, request=request)
snapshot_config = request.app.frigate_config.cameras[event.camera].snapshots
if not (snapshot_config.enabled and event.has_snapshot):
return JSONResponse(
content={
"success": False,
"message": "Snapshots must be enabled in the config",
"message": "Snapshots and clean_copy must be enabled in the config",
},
status_code=404,
)
@@ -1129,10 +1068,54 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool =
)
if webp_bytes is None:
try:
image_path, is_clean_snapshot = get_event_snapshot_path(
event, clean_only=True
# webp
clean_snapshot_path_webp = os.path.join(
CLIPS_DIR, f"{event.camera}-{event.id}-clean.webp"
)
if not is_clean_snapshot or image_path is None:
# png (legacy)
clean_snapshot_path_png = os.path.join(
CLIPS_DIR, f"{event.camera}-{event.id}-clean.png"
)
if os.path.exists(clean_snapshot_path_webp):
with open(clean_snapshot_path_webp, "rb") as image_file:
webp_bytes = image_file.read()
elif os.path.exists(clean_snapshot_path_png):
# convert png to webp and save for future use
png_image = cv2.imread(clean_snapshot_path_png, cv2.IMREAD_UNCHANGED)
if png_image is None:
return JSONResponse(
content={
"success": False,
"message": "Invalid png snapshot",
},
status_code=400,
)
ret, webp_data = cv2.imencode(
".webp", png_image, [int(cv2.IMWRITE_WEBP_QUALITY), 60]
)
if not ret:
return JSONResponse(
content={
"success": False,
"message": "Unable to convert png to webp",
},
status_code=400,
)
webp_bytes = webp_data.tobytes()
# save the converted webp for future requests
try:
with open(clean_snapshot_path_webp, "wb") as f:
f.write(webp_bytes)
except Exception as e:
logger.warning(
f"Failed to save converted webp for event {event.id}: {e}"
)
# continue since we now have the data to return
else:
return JSONResponse(
content={
"success": False,
@@ -1140,34 +1123,6 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool =
},
status_code=404,
)
if image_path.endswith(".webp"):
with open(image_path, "rb") as image_file:
webp_bytes = image_file.read()
else:
image = load_event_snapshot_image(event, clean_only=True)[0]
if image is None:
return JSONResponse(
content={
"success": False,
"message": "Unable to load clean snapshot for event",
},
status_code=400,
)
ret, webp_data = cv2.imencode(
".webp", image, get_image_quality_params("webp", None)
)
if not ret:
return JSONResponse(
content={
"success": False,
"message": "Unable to convert snapshot to webp",
},
status_code=400,
)
webp_bytes = webp_data.tobytes()
except Exception:
logger.error(f"Unable to load clean snapshot for event: {event.id}")
return JSONResponse(
@@ -1180,7 +1135,7 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool =
headers = {
"Content-Type": "image/webp",
"Cache-Control": "private, max-age=31536000" if event_complete else "no-cache",
"Cache-Control": "private, max-age=31536000",
}
if download:
@@ -1196,7 +1151,7 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool =
@router.get(
"/events/{event_id}/clip.mp4",
"/events/{event_id}/clip.mp4", dependencies=[Depends(require_camera_access)]
)
async def event_clip(
request: Request,
@@ -1210,8 +1165,6 @@ async def event_clip(
content={"success": False, "message": "Event not found"}, status_code=404
)
await require_camera_access(event.camera, request=request)
if not event.has_clip:
return JSONResponse(
content={"success": False, "message": "Clip not available"}, status_code=404
@@ -1228,36 +1181,9 @@ async def event_clip(
@router.get(
"/review/{review_id}/clip.mp4",
"/events/{event_id}/preview.gif", dependencies=[Depends(require_camera_access)]
)
async def review_clip(
request: Request,
review_id: str,
padding: int = Query(0, description="Padding to apply to clip."),
):
try:
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == review_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Review not found"}, status_code=404
)
await require_camera_access(review.camera, request=request)
end_ts = (
datetime.now().timestamp()
if review.end_time is None
else review.end_time + padding
)
return await recording_clip(
request, review.camera, review.start_time - padding, end_ts
)
@router.get(
"/events/{event_id}/preview.gif",
)
async def event_preview(request: Request, event_id: str):
def event_preview(request: Request, event_id: str):
try:
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
@@ -1265,8 +1191,6 @@ async def event_preview(request: Request, event_id: str):
content={"success": False, "message": "Event not found"}, status_code=404
)
await require_camera_access(event.camera, request=request)
start_ts = event.start_time
end_ts = start_ts + (
min(event.end_time - event.start_time, 20) if event.end_time else 20
@@ -1289,25 +1213,25 @@ def preview_gif(
):
if datetime.fromtimestamp(start_ts) < datetime.now().replace(minute=0, second=0):
# has preview mp4
try:
preview: Previews = (
Previews.select(
Previews.camera,
Previews.path,
Previews.duration,
Previews.start_time,
Previews.end_time,
)
.where(
Previews.start_time.between(start_ts, end_ts)
| Previews.end_time.between(start_ts, end_ts)
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
)
.where(Previews.camera == camera_name)
.limit(1)
.get()
preview: Previews = (
Previews.select(
Previews.camera,
Previews.path,
Previews.duration,
Previews.start_time,
Previews.end_time,
)
except DoesNotExist:
.where(
Previews.start_time.between(start_ts, end_ts)
| Previews.end_time.between(start_ts, end_ts)
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
)
.where(Previews.camera == camera_name)
.limit(1)
.get()
)
if not preview:
return JSONResponse(
content={"success": False, "message": "Preview not found"},
status_code=404,
@@ -1364,9 +1288,9 @@ def preview_gif(
status_code=404,
)
file_start = f"preview_{camera_name}-"
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
file_start = f"preview_{camera_name}"
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
selected_previews = []
for file in sorted(os.listdir(preview_dir)):
@@ -1546,9 +1470,9 @@ def preview_mp4(
status_code=404,
)
file_start = f"preview_{camera_name}-"
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
file_start = f"preview_{camera_name}"
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
selected_previews = []
for file in sorted(os.listdir(preview_dir)):
@@ -1625,8 +1549,8 @@ def preview_mp4(
)
@router.get("/review/{event_id}/preview")
async def review_preview(
@router.get("/review/{event_id}/preview", dependencies=[Depends(require_camera_access)])
def review_preview(
request: Request,
event_id: str,
format: str = Query(default="gif", enum=["gif", "mp4"]),
@@ -1639,8 +1563,6 @@ async def review_preview(
status_code=404,
)
await require_camera_access(review.camera, request=request)
padding = 8
start_ts = review.start_time - padding
end_ts = (
@@ -1654,14 +1576,12 @@ async def review_preview(
@router.get(
"/preview/{file_name}/thumbnail.jpg",
dependencies=[Depends(allow_any_authenticated())],
"/preview/{file_name}/thumbnail.jpg", dependencies=[Depends(require_camera_access)]
)
@router.get(
"/preview/{file_name}/thumbnail.webp",
dependencies=[Depends(allow_any_authenticated())],
"/preview/{file_name}/thumbnail.webp", dependencies=[Depends(require_camera_access)]
)
async def preview_thumbnail(request: Request, file_name: str):
def preview_thumbnail(file_name: str):
"""Get a thumbnail from the cached preview frames."""
if len(file_name) > 1000:
return JSONResponse(
@@ -1671,17 +1591,6 @@ async def preview_thumbnail(request: Request, file_name: str):
status_code=403,
)
# Extract camera name from preview filename (format: preview_{camera}-{timestamp}.ext)
if not file_name.startswith("preview_"):
return JSONResponse(
content={"success": False, "message": "Invalid preview filename"},
status_code=400,
)
# Use rsplit to handle camera names containing dashes (e.g. front-door)
name_part = file_name[len("preview_") :].rsplit(".", 1)[0] # strip extension
camera_name = name_part.rsplit("-", 1)[0] # split off timestamp
await require_camera_access(camera_name, request=request)
safe_file_name_current = sanitize_filename(file_name)
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
+3 -3
View File
@@ -145,9 +145,9 @@ def preview_hour(
def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: float):
"""Get list of cached preview frames"""
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
file_start = f"preview_{camera_name}-"
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
file_start = f"preview_{camera_name}"
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
selected_previews = []
for file in sorted(os.listdir(preview_dir)):
+28 -6
View File
@@ -1,6 +1,5 @@
"""Recording APIs."""
import datetime as dt
import logging
from datetime import datetime, timedelta
from functools import reduce
@@ -98,22 +97,45 @@ def all_recordings_summary(
days: dict[str, bool] = {}
for period_start, period_end, period_offset in dst_periods:
day_expr = ((Recordings.start_time + period_offset) / 86400).cast("int")
hours_offset = int(period_offset / 60 / 60)
minutes_offset = int(period_offset / 60 - hours_offset * 60)
period_hour_modifier = f"{hours_offset} hour"
period_minute_modifier = f"{minutes_offset} minute"
period_query = (
Recordings.select(day_expr.alias("day_idx"))
Recordings.select(
fn.strftime(
"%Y-%m-%d",
fn.datetime(
Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
).alias("day")
)
.where(
(Recordings.camera << camera_list)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
.distinct()
.group_by(
fn.strftime(
"%Y-%m-%d",
fn.datetime(
Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
)
)
.order_by(Recordings.start_time.desc())
.namedtuples()
)
for g in period_query:
day_str = (dt.date(1970, 1, 1) + dt.timedelta(days=g.day_idx)).isoformat()
days[day_str] = True
days[g.day] = True
return JSONResponse(content=dict(sorted(days.items())))
+1 -43
View File
@@ -8,7 +8,7 @@ from multiprocessing import Queue
from multiprocessing.managers import DictProxy, SyncManager
from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path
from typing import Callable, Optional
from typing import Optional
import psutil
import uvicorn
@@ -81,7 +81,6 @@ from frigate.timeline import TimelineProcessor
from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.builtin import empty_and_close_queue
from frigate.util.image import UntrackedSharedMemory
from frigate.util.process import FrigateProcess
from frigate.util.services import set_file_limit
from frigate.version import VERSION
from frigate.watchdog import FrigateWatchdog
@@ -498,47 +497,6 @@ class FrigateApp:
def start_watchdog(self) -> None:
self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event)
# (attribute on self, key in self.processes, factory)
specs: list[tuple[str, str, Callable[[], FrigateProcess]]] = [
(
"embedding_process",
"embeddings",
lambda: EmbeddingProcess(
self.config, self.embeddings_metrics, self.stop_event
),
),
(
"recording_process",
"recording",
lambda: RecordProcess(self.config, self.stop_event),
),
(
"review_segment_process",
"review_segment",
lambda: ReviewProcess(self.config, self.stop_event),
),
(
"output_processor",
"output",
lambda: OutputProcess(self.config, self.stop_event),
),
]
for attr, key, factory in specs:
if not hasattr(self, attr):
continue
def on_restart(
proc: FrigateProcess, _attr: str = attr, _key: str = key
) -> None:
setattr(self, _attr, proc)
self.processes[_key] = proc.pid or 0
self.frigate_watchdog.register(
key, getattr(self, attr), factory, on_restart
)
self.frigate_watchdog.start()
def init_auth(self) -> None:
+22 -23
View File
@@ -1,27 +1,26 @@
import multiprocessing as mp
import queue
from multiprocessing.managers import SyncManager, ValueProxy
from multiprocessing.managers import SyncManager
from multiprocessing.sharedctypes import Synchronized
from multiprocessing.synchronize import Event
class CameraMetrics:
camera_fps: ValueProxy[float]
detection_fps: ValueProxy[float]
detection_frame: ValueProxy[float]
process_fps: ValueProxy[float]
skipped_fps: ValueProxy[float]
read_start: ValueProxy[float]
audio_rms: ValueProxy[float]
audio_dBFS: ValueProxy[float]
camera_fps: Synchronized
detection_fps: Synchronized
detection_frame: Synchronized
process_fps: Synchronized
skipped_fps: Synchronized
read_start: Synchronized
audio_rms: Synchronized
audio_dBFS: Synchronized
frame_queue: queue.Queue
frame_queue: mp.Queue
process_pid: ValueProxy[int]
capture_process_pid: ValueProxy[int]
ffmpeg_pid: ValueProxy[int]
reconnects_last_hour: ValueProxy[int]
stalls_last_hour: ValueProxy[int]
process_pid: Synchronized
capture_process_pid: Synchronized
ffmpeg_pid: Synchronized
reconnects_last_hour: Synchronized
stalls_last_hour: Synchronized
def __init__(self, manager: SyncManager):
self.camera_fps = manager.Value("d", 0)
@@ -57,14 +56,14 @@ class PTZMetrics:
reset: Event
def __init__(self, *, autotracker_enabled: bool):
self.autotracker_enabled = mp.Value("i", autotracker_enabled) # type: ignore[assignment]
self.autotracker_enabled = mp.Value("i", autotracker_enabled)
self.start_time = mp.Value("d", 0) # type: ignore[assignment]
self.stop_time = mp.Value("d", 0) # type: ignore[assignment]
self.frame_time = mp.Value("d", 0) # type: ignore[assignment]
self.zoom_level = mp.Value("d", 0) # type: ignore[assignment]
self.max_zoom = mp.Value("d", 0) # type: ignore[assignment]
self.min_zoom = mp.Value("d", 0) # type: ignore[assignment]
self.start_time = mp.Value("d", 0)
self.stop_time = mp.Value("d", 0)
self.frame_time = mp.Value("d", 0)
self.zoom_level = mp.Value("d", 0)
self.max_zoom = mp.Value("d", 0)
self.min_zoom = mp.Value("d", 0)
self.tracking_active = mp.Event()
self.motor_stopped = mp.Event()
+2 -8
View File
@@ -37,9 +37,6 @@ class CameraActivityManager:
self.__init_camera(camera_config)
def __init_camera(self, camera_config: CameraConfig) -> None:
if camera_config.name is None:
return
self.last_camera_activity[camera_config.name] = {}
self.camera_all_object_counts[camera_config.name] = Counter()
self.camera_active_object_counts[camera_config.name] = Counter()
@@ -117,7 +114,7 @@ class CameraActivityManager:
self.last_camera_activity = new_activity
def compare_camera_activity(
self, camera: str, new_activity: list[dict[str, Any]]
self, camera: str, new_activity: dict[str, Any]
) -> None:
all_objects = Counter(
obj["label"].replace("-verified", "") for obj in new_activity
@@ -178,9 +175,6 @@ class AudioActivityManager:
self.__init_camera(camera_config)
def __init_camera(self, camera_config: CameraConfig) -> None:
if camera_config.name is None:
return
self.current_audio_detections[camera_config.name] = {}
def update_activity(self, new_activity: dict[str, dict[str, Any]]) -> None:
@@ -208,7 +202,7 @@ class AudioActivityManager:
def compare_audio_activity(
self, camera: str, new_detections: list[tuple[str, float]], now: float
) -> bool:
) -> None:
camera_config = self.config.cameras.get(camera)
if camera_config is None:
return False
+5 -5
View File
@@ -102,7 +102,7 @@ class CameraMaintainer(threading.Thread):
f"recommend increasing it to at least {shm_stats['min_shm']}MB."
)
return int(shm_stats["shm_frame_count"])
return shm_stats["shm_frame_count"]
def __start_camera_processor(
self, name: str, config: CameraConfig, runtime: bool = False
@@ -152,10 +152,10 @@ class CameraMaintainer(threading.Thread):
camera_stop_event,
self.config.logger,
)
self.camera_processes[name] = camera_process
self.camera_processes[config.name] = camera_process
camera_process.start()
self.camera_metrics[name].process_pid.value = camera_process.pid
logger.info(f"Camera processor started for {name}: {camera_process.pid}")
self.camera_metrics[config.name].process_pid.value = camera_process.pid
logger.info(f"Camera processor started for {config.name}: {camera_process.pid}")
def __start_camera_capture(
self, name: str, config: CameraConfig, runtime: bool = False
@@ -219,7 +219,7 @@ class CameraMaintainer(threading.Thread):
logger.info(f"Closing frame queue for {camera}")
empty_and_close_queue(self.camera_metrics[camera].frame_queue)
def run(self) -> None:
def run(self):
self.__init_historical_regions()
# start camera processes
+70 -48
View File
@@ -31,26 +31,26 @@ logger = logging.getLogger(__name__)
class CameraState:
def __init__(
self,
name: str,
name,
config: FrigateConfig,
frame_manager: SharedMemoryFrameManager,
ptz_autotracker_thread: PtzAutoTrackerThread,
) -> None:
):
self.name = name
self.config = config
self.camera_config = config.cameras[name]
self.frame_manager = frame_manager
self.best_objects: dict[str, TrackedObject] = {}
self.tracked_objects: dict[str, TrackedObject] = {}
self.frame_cache: dict[float, dict[str, Any]] = {}
self.zone_objects: defaultdict[str, list[Any]] = defaultdict(list)
self.frame_cache = {}
self.zone_objects = defaultdict(list)
self._current_frame = np.zeros(self.camera_config.frame_shape_yuv, np.uint8)
self.current_frame_lock = threading.Lock()
self.current_frame_time = 0.0
self.motion_boxes: list[tuple[int, int, int, int]] = []
self.regions: list[tuple[int, int, int, int]] = []
self.previous_frame_id: str | None = None
self.callbacks: defaultdict[str, list[Callable]] = defaultdict(list)
self.motion_boxes = []
self.regions = []
self.previous_frame_id = None
self.callbacks = defaultdict(list)
self.ptz_autotracker_thread = ptz_autotracker_thread
self.prev_enabled = self.camera_config.enabled
@@ -62,10 +62,10 @@ class CameraState:
motion_boxes = self.motion_boxes.copy()
regions = self.regions.copy()
frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420) # type: ignore[assignment]
frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420)
# draw on the frame
if draw_options.get("mask"):
mask_overlay = np.where(self.camera_config.motion.rasterized_mask == [0]) # type: ignore[attr-defined]
mask_overlay = np.where(self.camera_config.motion.rasterized_mask == [0])
frame_copy[mask_overlay] = [0, 0, 0]
if draw_options.get("bounding_boxes"):
@@ -97,7 +97,7 @@ class CameraState:
and obj["id"]
== self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
self.name
].obj_data["id"] # type: ignore[attr-defined]
].obj_data["id"]
and obj["frame_time"] == frame_time
):
thickness = 5
@@ -109,12 +109,10 @@ class CameraState:
if (
self.camera_config.onvif.autotracking.zooming
!= ZoomingModeEnum.disabled
and self.camera_config.detect.width is not None
and self.camera_config.detect.height is not None
):
max_target_box = self.ptz_autotracker_thread.ptz_autotracker.tracked_object_metrics[
self.name
]["max_target_box"] # type: ignore[index]
]["max_target_box"]
side_length = max_target_box * (
max(
self.camera_config.detect.width,
@@ -223,14 +221,14 @@ class CameraState:
)
if draw_options.get("timestamp"):
ts_color = self.camera_config.timestamp_style.color
color = self.camera_config.timestamp_style.color
draw_timestamp(
frame_copy,
frame_time,
self.camera_config.timestamp_style.format,
font_effect=self.camera_config.timestamp_style.effect,
font_thickness=self.camera_config.timestamp_style.thickness,
font_color=(ts_color.blue, ts_color.green, ts_color.red),
font_color=(color.blue, color.green, color.red),
position=self.camera_config.timestamp_style.position,
)
@@ -275,10 +273,10 @@ class CameraState:
return frame_copy
def finished(self, obj_id: str) -> None:
def finished(self, obj_id):
del self.tracked_objects[obj_id]
def on(self, event_type: str, callback: Callable[..., Any]) -> None:
def on(self, event_type: str, callback: Callable):
self.callbacks[event_type].append(callback)
def update(
@@ -288,7 +286,7 @@ class CameraState:
current_detections: dict[str, dict[str, Any]],
motion_boxes: list[tuple[int, int, int, int]],
regions: list[tuple[int, int, int, int]],
) -> None:
):
current_frame = self.frame_manager.get(
frame_name, self.camera_config.frame_shape_yuv
)
@@ -315,7 +313,7 @@ class CameraState:
f"{self.name}: New object, adding {frame_time} to frame cache for {id}"
)
self.frame_cache[frame_time] = {
"frame": np.copy(current_frame), # type: ignore[arg-type]
"frame": np.copy(current_frame),
"object_id": id,
}
@@ -358,8 +356,7 @@ class CameraState:
if thumb_update and current_frame is not None:
# ensure this frame is stored in the cache
if (
updated_obj.thumbnail_data is not None
and updated_obj.thumbnail_data["frame_time"] == frame_time
updated_obj.thumbnail_data["frame_time"] == frame_time
and frame_time not in self.frame_cache
):
logger.debug(
@@ -400,7 +397,7 @@ class CameraState:
# TODO: can i switch to looking this up and only changing when an event ends?
# maintain best objects
camera_activity: dict[str, Any] = {
camera_activity: dict[str, list[Any]] = {
"motion": len(motion_boxes) > 0,
"objects": [],
}
@@ -414,7 +411,10 @@ class CameraState:
sub_label = None
if obj.obj_data.get("sub_label"):
if obj.obj_data["sub_label"][0] in self.config.model.all_attributes:
if (
obj.obj_data.get("sub_label")[0]
in self.config.model.all_attributes
):
label = obj.obj_data["sub_label"][0]
else:
label = f"{object_type}-verified"
@@ -449,19 +449,14 @@ class CameraState:
# if the object is a higher score than the current best score
# or the current object is older than desired, use the new object
if (
current_best.thumbnail_data is not None
and obj.thumbnail_data is not None
and is_better_thumbnail(
is_better_thumbnail(
object_type,
current_best.thumbnail_data,
obj.thumbnail_data,
self.camera_config.frame_shape,
)
or (
current_best.thumbnail_data is not None
and (now - current_best.thumbnail_data["frame_time"])
> self.camera_config.best_image_timeout
)
or (now - current_best.thumbnail_data["frame_time"])
> self.camera_config.best_image_timeout
):
self.send_mqtt_snapshot(obj, object_type)
else:
@@ -477,9 +472,7 @@ class CameraState:
if obj.thumbnail_data is not None
}
current_best_frames = {
obj.thumbnail_data["frame_time"]
for obj in self.best_objects.values()
if obj.thumbnail_data is not None
obj.thumbnail_data["frame_time"] for obj in self.best_objects.values()
}
thumb_frames_to_delete = [
t
@@ -539,24 +532,53 @@ class CameraState:
) -> None:
img_frame = frame if frame is not None else self.get_current_frame()
ret, webp = cv2.imencode(
".webp", img_frame, [int(cv2.IMWRITE_WEBP_QUALITY), 80]
)
# write clean snapshot if enabled
if self.camera_config.snapshots.clean_copy:
ret, webp = cv2.imencode(
".webp", img_frame, [int(cv2.IMWRITE_WEBP_QUALITY), 80]
)
if ret:
with open(
os.path.join(
CLIPS_DIR,
f"{self.name}-{event_id}-clean.webp",
),
"wb",
) as p:
p.write(webp.tobytes())
if ret:
with open(
os.path.join(
CLIPS_DIR,
f"{self.camera_config.name}-{event_id}-clean.webp",
),
"wb",
) as p:
p.write(webp.tobytes())
# write jpg snapshot with optional annotations
if draw.get("boxes") and isinstance(draw.get("boxes"), list):
for box in draw.get("boxes"):
x = int(box["box"][0] * self.camera_config.detect.width)
y = int(box["box"][1] * self.camera_config.detect.height)
width = int(box["box"][2] * self.camera_config.detect.width)
height = int(box["box"][3] * self.camera_config.detect.height)
draw_box_with_label(
img_frame,
x,
y,
x + width,
y + height,
label,
f"{box.get('score', '-')}% {int(width * height)}",
thickness=2,
color=box.get("color", (255, 0, 0)),
)
ret, jpg = cv2.imencode(".jpg", img_frame)
with open(
os.path.join(CLIPS_DIR, f"{self.camera_config.name}-{event_id}.jpg"),
"wb",
) as j:
j.write(jpg.tobytes())
# create thumbnail with max height of 175 and save
width = int(175 * img_frame.shape[1] / img_frame.shape[0])
thumb = cv2.resize(img_frame, dsize=(width, 175), interpolation=cv2.INTER_AREA)
thumb_path = os.path.join(THUMB_DIR, self.name)
thumb_path = os.path.join(THUMB_DIR, self.camera_config.name)
os.makedirs(thumb_path, exist_ok=True)
cv2.imwrite(os.path.join(thumb_path, f"{event_id}.webp"), thumb)
+11 -1
View File
@@ -1,3 +1,4 @@
import json
import logging
import threading
from typing import Any, Callable
@@ -38,7 +39,6 @@ class MqttClient(Communicator):
)
def stop(self) -> None:
self.publish("available", "stopped", retain=True)
self.client.disconnect()
def _set_initial_topics(self) -> None:
@@ -169,6 +169,16 @@ class MqttClient(Communicator):
self.config.active_profile or "none",
retain=True,
)
available_profiles = [
{"name": name, "friendly_name": defn.friendly_name}
for name, defn in sorted(self.config.profiles.items())
]
self.publish(
"profiles/available",
json.dumps(available_profiles),
retain=True,
)
self.publish("available", "online", retain=True)
def on_mqtt_command(
+11 -92
View File
@@ -17,7 +17,6 @@ from titlecase import titlecase
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.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
@@ -59,7 +58,6 @@ class WebPushClient(Communicator):
for c in self.config.cameras.values()
}
self.last_notification_time: float = 0
self.user_cameras: dict[str, set[str]] = {}
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
self.notification_thread = threading.Thread(
target=self._process_notifications, daemon=True
@@ -80,12 +78,13 @@ class WebPushClient(Communicator):
for sub in user["notification_tokens"]:
self.web_pushers[user["username"]].append(WebPusher(sub))
# notification and auth config updater
self.global_config_subscriber = ConfigSubscriber("config/")
# notification config updater
self.global_config_subscriber = ConfigSubscriber(
"config/notifications", exact=True
)
self.config_subscriber = CameraConfigUpdateSubscriber(
self.config, self.config.cameras, [CameraConfigUpdateEnum.notifications]
)
self._refresh_user_cameras()
def subscribe(self, receiver: Callable) -> None:
"""Wrapper for allowing dispatcher to subscribe."""
@@ -165,19 +164,13 @@ class WebPushClient(Communicator):
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Wrapper for publishing when client is in valid state."""
# check for updated global config (notifications, auth)
while True:
config_topic, config_payload = (
self.global_config_subscriber.check_for_update()
)
if config_topic is None:
break
if config_topic == "config/notifications" and config_payload:
self.config.notifications = config_payload
elif config_topic == "config/auth":
if isinstance(config_payload, AuthConfig):
self.config.auth = config_payload
self._refresh_user_cameras()
# check for updated notification config
_, updated_notification_config = (
self.global_config_subscriber.check_for_update()
)
if updated_notification_config:
self.config.notifications = updated_notification_config
updates = self.config_subscriber.check_for_updates()
@@ -217,15 +210,6 @@ class WebPushClient(Communicator):
logger.debug(f"Notifications for {camera} are currently suspended.")
return
self.send_trigger(decoded)
elif topic == "camera_monitoring":
decoded = json.loads(payload)
camera = decoded["camera"]
if not self.config.cameras[camera].notifications.enabled:
return
if self.is_camera_suspended(camera):
logger.debug(f"Notifications for {camera} are currently suspended.")
return
self.send_camera_monitoring(decoded)
elif topic == "notification_test":
if not self.config.notifications.enabled and not any(
cam.notifications.enabled for cam in self.config.cameras.values()
@@ -307,31 +291,6 @@ class WebPushClient(Communicator):
except Exception as e:
logger.error(f"Error processing notification: {str(e)}")
def _refresh_user_cameras(self) -> None:
"""Rebuild the user-to-cameras access cache from the database."""
all_camera_names = set(self.config.cameras.keys())
roles_dict = self.config.auth.roles
updated: dict[str, set[str]] = {}
for user in User.select(User.username, User.role).dicts().iterator():
allowed = User.get_allowed_cameras(
user["role"], roles_dict, all_camera_names
)
updated[user["username"]] = set(allowed)
logger.debug(
"User %s has access to cameras: %s",
user["username"],
", ".join(allowed),
)
self.user_cameras = updated
def _user_has_camera_access(self, username: str, camera: str) -> bool:
"""Check if a user has access to a specific camera based on cached roles."""
allowed = self.user_cameras.get(username)
if allowed is None:
logger.debug(f"No camera access information found for user {username}")
return False
return camera in allowed
def _within_cooldown(self, camera: str) -> bool:
now = datetime.datetime.now().timestamp()
if now - self.last_notification_time < self.config.notifications.cooldown:
@@ -459,14 +418,6 @@ class WebPushClient(Communicator):
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
for user in self.web_pushers:
if not self._user_has_camera_access(user, camera):
logger.debug(
"Skipping notification for user %s - no access to camera %s",
user,
camera,
)
continue
self.send_push_notification(
user=user,
payload=payload,
@@ -514,14 +465,6 @@ class WebPushClient(Communicator):
)
for user in self.web_pushers:
if not self._user_has_camera_access(user, camera):
logger.debug(
"Skipping notification for user %s - no access to camera %s",
user,
camera,
)
continue
self.send_push_notification(
user=user,
payload=payload,
@@ -534,30 +477,6 @@ class WebPushClient(Communicator):
self.cleanup_registrations()
def send_camera_monitoring(self, payload: dict[str, Any]) -> None:
camera: str = payload["camera"]
camera_name: str = getattr(
self.config.cameras[camera], "friendly_name", None
) or titlecase(camera.replace("_", " "))
self.check_registrations()
text: str = payload.get("message") or payload.get("reasoning", "")
title = f"{camera_name}: Monitoring Alert"
message = (text[:197] + "...") if len(text) > 200 else text
logger.debug(f"Sending camera monitoring push notification for {camera_name}")
for user in self.web_pushers:
self.send_push_notification(
user=user,
payload=payload,
title=title,
message=message,
)
self.cleanup_registrations()
def stop(self) -> None:
logger.info("Closing notification queue")
self.notification_thread.join()
+1 -1
View File
@@ -141,7 +141,7 @@ class CameraConfig(FrigateBaseModel):
snapshots: SnapshotsConfig = Field(
default_factory=SnapshotsConfig,
title="Snapshots",
description="Settings for API-generated snapshots of tracked objects for this camera.",
description="Settings for saved JPEG snapshots of tracked objects for this camera.",
)
timestamp_style: TimestampStyleConfig = Field(
default_factory=TimestampStyleConfig,
+2 -3
View File
@@ -49,8 +49,8 @@ class StationaryConfig(FrigateBaseModel):
class DetectConfig(FrigateBaseModel):
enabled: bool = Field(
default=False,
title="Enable object detection",
description="Enable or disable object detection for all cameras; can be overridden per-camera.",
title="Detection enabled",
description="Enable or disable object detection for all cameras; can be overridden per-camera. Detection must be enabled for object tracking to run.",
)
height: Optional[int] = Field(
default=None,
@@ -71,7 +71,6 @@ class DetectConfig(FrigateBaseModel):
default=None,
title="Minimum initialization frames",
description="Number of consecutive detection hits required before creating a tracked object. Increase to reduce false initializations. Default value is fps divided by 2.",
ge=2,
)
max_disappeared: Optional[int] = Field(
default=None,
+1 -6
View File
@@ -92,7 +92,7 @@ class PtzAutotrackConfig(FrigateBaseModel):
class OnvifConfig(FrigateBaseModel):
host: EnvString = Field(
host: str = Field(
default="",
title="ONVIF host",
description="Host (and optional scheme) for the ONVIF service for this camera.",
@@ -117,11 +117,6 @@ class OnvifConfig(FrigateBaseModel):
title="Disable TLS verify",
description="Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only).",
)
profile: Optional[str] = Field(
default=None,
title="ONVIF profile",
description="Specific ONVIF media profile to use for PTZ control, matched by token or name. If not set, the first profile with valid PTZ configuration is selected automatically.",
)
autotracking: PtzAutotrackConfig = Field(
default_factory=PtzAutotrackConfig,
title="Autotracking",
+1 -1
View File
@@ -188,7 +188,7 @@ class ReviewConfig(FrigateBaseModel):
detections: DetectionsConfig = Field(
default_factory=DetectionsConfig,
title="Detections config",
description="Settings for which tracked objects generate detections (non-alert) and how detections are retained.",
description="Settings for creating detection events (non-alert) and how long to keep them.",
)
genai: GenAIReviewConfig = Field(
default_factory=GenAIReviewConfig,
+14 -9
View File
@@ -29,23 +29,28 @@ class RetainConfig(FrigateBaseModel):
class SnapshotsConfig(FrigateBaseModel):
enabled: bool = Field(
default=False,
title="Enable snapshots",
title="Snapshots enabled",
description="Enable or disable saving snapshots for all cameras; can be overridden per-camera.",
)
clean_copy: bool = Field(
default=True,
title="Save clean copy",
description="Save an unannotated clean copy of snapshots in addition to annotated ones.",
)
timestamp: bool = Field(
default=False,
title="Timestamp overlay",
description="Overlay a timestamp on snapshots from API.",
description="Overlay a timestamp on saved snapshots.",
)
bounding_box: bool = Field(
default=True,
title="Bounding box overlay",
description="Draw bounding boxes for tracked objects on snapshots from API.",
description="Draw bounding boxes for tracked objects on saved snapshots.",
)
crop: bool = Field(
default=False,
title="Crop snapshot",
description="Crop snapshots from API to the detected object's bounding box.",
description="Crop saved snapshots to the detected object's bounding box.",
)
required_zones: list[str] = Field(
default_factory=list,
@@ -55,17 +60,17 @@ class SnapshotsConfig(FrigateBaseModel):
height: Optional[int] = Field(
default=None,
title="Snapshot height",
description="Height (pixels) to resize snapshots from API to; leave empty to preserve original size.",
description="Height (pixels) to resize saved snapshots to; leave empty to preserve original size.",
)
retain: RetainConfig = Field(
default_factory=RetainConfig,
title="Snapshot retention",
description="Retention settings for snapshots including default days and per-object overrides.",
description="Retention settings for saved snapshots including default days and per-object overrides.",
)
quality: int = Field(
default=60,
title="Snapshot quality",
description="Encode quality for saved snapshots (0-100).",
default=70,
title="JPEG quality",
description="JPEG encode quality for saved snapshots (0-100).",
ge=0,
le=100,
)
-9
View File
@@ -18,12 +18,10 @@ class CameraConfigUpdateEnum(str, Enum):
detect = "detect"
enabled = "enabled"
ffmpeg = "ffmpeg"
live = "live"
motion = "motion" # includes motion and motion masks
notifications = "notifications"
objects = "objects"
object_genai = "object_genai"
onvif = "onvif"
record = "record"
remove = "remove" # for removing a camera
review = "review"
@@ -32,7 +30,6 @@ class CameraConfigUpdateEnum(str, Enum):
face_recognition = "face_recognition"
lpr = "lpr"
snapshots = "snapshots"
timestamp_style = "timestamp_style"
zones = "zones"
@@ -110,8 +107,6 @@ class CameraConfigUpdateSubscriber:
config.enabled = updated_config
elif update_type == CameraConfigUpdateEnum.object_genai:
config.objects.genai = updated_config
elif update_type == CameraConfigUpdateEnum.live:
config.live = updated_config
elif update_type == CameraConfigUpdateEnum.motion:
config.motion = updated_config
elif update_type == CameraConfigUpdateEnum.notifications:
@@ -132,10 +127,6 @@ class CameraConfigUpdateSubscriber:
config.lpr = updated_config
elif update_type == CameraConfigUpdateEnum.snapshots:
config.snapshots = updated_config
elif update_type == CameraConfigUpdateEnum.onvif:
config.onvif = updated_config
elif update_type == CameraConfigUpdateEnum.timestamp_style:
config.timestamp_style = updated_config
elif update_type == CameraConfigUpdateEnum.zones:
config.zones = updated_config
+10 -31
View File
@@ -25,7 +25,6 @@ from frigate.plus import PlusApi
from frigate.util.builtin import (
deep_merge,
get_ffmpeg_arg_list,
load_labels,
)
from frigate.util.config import (
CURRENT_CONFIG_VERSION,
@@ -41,7 +40,7 @@ from frigate.util.services import auto_detect_hwaccel
from .auth import AuthConfig
from .base import FrigateBaseModel
from .camera import CameraConfig, CameraLiveConfig
from .camera.audio import AudioConfig, AudioFilterConfig
from .camera.audio import AudioConfig
from .camera.birdseye import BirdseyeConfig
from .camera.detect import DetectConfig
from .camera.ffmpeg import FfmpegConfig
@@ -445,7 +444,7 @@ class FrigateConfig(FrigateBaseModel):
# GenAI config (named provider configs: name -> GenAIConfig)
genai: Dict[str, GenAIConfig] = Field(
default_factory=dict,
title="Generative AI configuration",
title="Generative AI configuration (named providers).",
description="Settings for integrated generative AI providers used to generate object descriptions and review summaries.",
)
@@ -474,7 +473,7 @@ class FrigateConfig(FrigateBaseModel):
live: CameraLiveConfig = Field(
default_factory=CameraLiveConfig,
title="Live playback",
description="Settings to control the jsmpeg live stream resolution and quality. This does not affect restreamed cameras that use go2rtc for live view.",
description="Settings used by the Web UI to control live stream resolution and quality.",
)
motion: Optional[MotionConfig] = Field(
default=None,
@@ -499,7 +498,7 @@ class FrigateConfig(FrigateBaseModel):
snapshots: SnapshotsConfig = Field(
default_factory=SnapshotsConfig,
title="Snapshots",
description="Settings for API-generated snapshots of tracked objects for all cameras; can be overridden per-camera.",
description="Settings for saved JPEG snapshots of tracked objects for all cameras; can be overridden per-camera.",
)
timestamp_style: TimestampStyleConfig = Field(
default_factory=TimestampStyleConfig,
@@ -614,21 +613,6 @@ class FrigateConfig(FrigateBaseModel):
if self.ffmpeg.hwaccel_args == "auto":
self.ffmpeg.hwaccel_args = auto_detect_hwaccel()
# Populate global audio filters for all audio labels
all_audio_labels = {
label
for label in load_labels("/audio-labelmap.txt", prefill=521).values()
if label
}
if self.audio.filters is None:
self.audio.filters = {}
for key in sorted(all_audio_labels - self.audio.filters.keys()):
self.audio.filters[key] = AudioFilterConfig()
self.audio.filters = dict(sorted(self.audio.filters.items()))
# Global config to propagate down to camera level
global_config = self.model_dump(
include={
@@ -764,7 +748,7 @@ class FrigateConfig(FrigateBaseModel):
)
# Default min_initialized configuration
min_initialized = max(int(camera_config.detect.fps / 2), 2)
min_initialized = int(camera_config.detect.fps / 2)
if camera_config.detect.min_initialized is None:
camera_config.detect.min_initialized = min_initialized
@@ -807,16 +791,6 @@ class FrigateConfig(FrigateBaseModel):
camera_config.review.genai.enabled
)
if camera_config.audio.filters is None:
camera_config.audio.filters = {}
for key in sorted(all_audio_labels - camera_config.audio.filters.keys()):
camera_config.audio.filters[key] = AudioFilterConfig()
camera_config.audio.filters = dict(
sorted(camera_config.audio.filters.items())
)
# Add default filters
object_keys = camera_config.objects.track
if camera_config.objects.filters is None:
@@ -959,6 +933,11 @@ class FrigateConfig(FrigateBaseModel):
f"Camera {camera.name} has audio transcription enabled, but audio detection is not enabled for this camera. Audio detection must be enabled for cameras with audio transcription when it is disabled globally."
)
if self.plus_api and not self.snapshots.clean_copy:
logger.warning(
"Frigate+ is configured but clean snapshots are not enabled, submissions to Frigate+ will not be possible./"
)
# Validate auth roles against cameras
camera_names = set(self.cameras.keys())
+2 -4
View File
@@ -24,10 +24,8 @@ EnvString = Annotated[str, AfterValidator(validate_env_string)]
def validate_env_vars(v: dict[str, str], info: ValidationInfo) -> dict[str, str]:
if isinstance(info.context, dict) and info.context.get("install", False):
for k, val in v.items():
os.environ[k] = val
if k.startswith("FRIGATE_"):
FRIGATE_ENV_VARS[k] = val
for k, v in v.items():
os.environ[k] = v
return v
+1 -1
View File
@@ -17,7 +17,7 @@ class MqttConfig(FrigateBaseModel):
title="Enable MQTT",
description="Enable or disable MQTT integration for state, events, and snapshots.",
)
host: EnvString = Field(
host: str = Field(
default="",
title="MQTT host",
description="Hostname or IP address of the MQTT broker.",
@@ -53,7 +53,7 @@ class AudioTranscriptionModelRunner:
self.downloader = ModelDownloader(
model_name="sherpa-onnx",
download_path=download_path,
file_names=list(self.model_files.keys()),
file_names=self.model_files.keys(),
download_func=self.__download_models,
)
self.downloader.ensure_model_files()
+22 -30
View File
@@ -21,7 +21,7 @@ class FaceRecognizer(ABC):
def __init__(self, config: FrigateConfig) -> None:
self.config = config
self.landmark_detector: cv2.face.Facemark | None = None
self.landmark_detector: cv2.face.FacemarkLBF = None
self.init_landmark_detector()
@abstractmethod
@@ -38,14 +38,13 @@ class FaceRecognizer(ABC):
def classify(self, face_image: np.ndarray) -> tuple[str, float] | None:
pass
@redirect_output_to_logger(logger, logging.DEBUG) # type: ignore[misc]
@redirect_output_to_logger(logger, logging.DEBUG)
def init_landmark_detector(self) -> None:
landmark_model = os.path.join(MODEL_CACHE_DIR, "facedet/landmarkdet.yaml")
if os.path.exists(landmark_model):
landmark_detector = cv2.face.createFacemarkLBF()
landmark_detector.loadModel(landmark_model)
self.landmark_detector = landmark_detector
self.landmark_detector = cv2.face.createFacemarkLBF()
self.landmark_detector.loadModel(landmark_model)
def align_face(
self,
@@ -53,10 +52,8 @@ class FaceRecognizer(ABC):
output_width: int,
output_height: int,
) -> np.ndarray:
if not self.landmark_detector:
raise ValueError("Landmark detector not initialized")
# landmark is run on grayscale images
if image.ndim == 3:
land_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
else:
@@ -134,11 +131,8 @@ class FaceRecognizer(ABC):
def similarity_to_confidence(
cosine_similarity: float,
median: float = 0.3,
range_width: float = 0.6,
slope_factor: float = 12,
) -> float:
cosine_similarity: float, median=0.3, range_width=0.6, slope_factor=12
):
"""
Default sigmoid function to map cosine similarity to confidence.
@@ -157,14 +151,14 @@ def similarity_to_confidence(
bias = median
# Calculate confidence
confidence: float = 1 / (1 + np.exp(-slope * (cosine_similarity - bias)))
confidence = 1 / (1 + np.exp(-slope * (cosine_similarity - bias)))
return confidence
class FaceNetRecognizer(FaceRecognizer):
def __init__(self, config: FrigateConfig):
super().__init__(config)
self.mean_embs: dict[str, np.ndarray] = {}
self.mean_embs: dict[int, np.ndarray] = {}
self.face_embedder: FaceNetEmbedding = FaceNetEmbedding()
self.model_builder_queue: queue.Queue | None = None
@@ -174,7 +168,7 @@ class FaceNetRecognizer(FaceRecognizer):
def run_build_task(self) -> None:
self.model_builder_queue = queue.Queue()
def build_model() -> None:
def build_model():
face_embeddings_map: dict[str, list[np.ndarray]] = {}
idx = 0
@@ -193,7 +187,7 @@ class FaceNetRecognizer(FaceRecognizer):
img = cv2.imread(os.path.join(face_folder, image))
if img is None:
continue # type: ignore[unreachable]
continue
img = self.align_face(img, img.shape[1], img.shape[0])
emb = self.face_embedder([img])[0].squeeze()
@@ -201,13 +195,12 @@ class FaceNetRecognizer(FaceRecognizer):
idx += 1
assert self.model_builder_queue is not None
self.model_builder_queue.put(face_embeddings_map)
thread = threading.Thread(target=build_model, daemon=True)
thread.start()
def build(self) -> None:
def build(self):
if not self.landmark_detector:
self.init_landmark_detector()
return None
@@ -233,7 +226,7 @@ class FaceNetRecognizer(FaceRecognizer):
logger.debug("Finished building ArcFace model")
def classify(self, face_image: np.ndarray) -> tuple[str, float] | None:
def classify(self, face_image):
if not self.landmark_detector:
return None
@@ -252,7 +245,7 @@ class FaceNetRecognizer(FaceRecognizer):
img = self.align_face(face_image, face_image.shape[1], face_image.shape[0])
embedding = self.face_embedder([img])[0].squeeze()
score: float = 0
score = 0
label = ""
for name, mean_emb in self.mean_embs.items():
@@ -275,7 +268,7 @@ class FaceNetRecognizer(FaceRecognizer):
class ArcFaceRecognizer(FaceRecognizer):
def __init__(self, config: FrigateConfig):
super().__init__(config)
self.mean_embs: dict[str, np.ndarray] = {}
self.mean_embs: dict[int, np.ndarray] = {}
self.face_embedder: ArcfaceEmbedding = ArcfaceEmbedding(config.face_recognition)
self.model_builder_queue: queue.Queue | None = None
@@ -285,7 +278,7 @@ class ArcFaceRecognizer(FaceRecognizer):
def run_build_task(self) -> None:
self.model_builder_queue = queue.Queue()
def build_model() -> None:
def build_model():
face_embeddings_map: dict[str, list[np.ndarray]] = {}
idx = 0
@@ -304,21 +297,20 @@ class ArcFaceRecognizer(FaceRecognizer):
img = cv2.imread(os.path.join(face_folder, image))
if img is None:
continue # type: ignore[unreachable]
continue
img = self.align_face(img, img.shape[1], img.shape[0])
emb = self.face_embedder([img])[0].squeeze() # type: ignore[arg-type]
emb = self.face_embedder([img])[0].squeeze()
face_embeddings_map[name].append(emb)
idx += 1
assert self.model_builder_queue is not None
self.model_builder_queue.put(face_embeddings_map)
thread = threading.Thread(target=build_model, daemon=True)
thread.start()
def build(self) -> None:
def build(self):
if not self.landmark_detector:
self.init_landmark_detector()
return None
@@ -344,7 +336,7 @@ class ArcFaceRecognizer(FaceRecognizer):
logger.debug("Finished building ArcFace model")
def classify(self, face_image: np.ndarray) -> tuple[str, float] | None:
def classify(self, face_image):
if not self.landmark_detector:
return None
@@ -361,9 +353,9 @@ class ArcFaceRecognizer(FaceRecognizer):
# align face and run recognition
img = self.align_face(face_image, face_image.shape[1], face_image.shape[0])
embedding = self.face_embedder([img])[0].squeeze() # type: ignore[arg-type]
embedding = self.face_embedder([img])[0].squeeze()
score: float = 0
score = 0
label = ""
for name, mean_emb in self.mean_embs.items():
@@ -10,7 +10,7 @@ import random
import re
import string
from pathlib import Path
from typing import Any, List, Tuple
from typing import Any, List, Optional, Tuple
import cv2
import numpy as np
@@ -22,35 +22,19 @@ from frigate.comms.event_metadata_updater import (
EventMetadataPublisher,
EventMetadataTypeEnum,
)
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig
from frigate.config.classification import LicensePlateRecognitionConfig
from frigate.const import CLIPS_DIR, MODEL_CACHE_DIR
from frigate.data_processing.common.license_plate.model import LicensePlateModelRunner
from frigate.embeddings.onnx.lpr_embedding import LPR_EMBEDDING_SIZE
from frigate.types import TrackedObjectUpdateTypesEnum
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
from frigate.util.image import area
from ...types import DataProcessorMetrics
logger = logging.getLogger(__name__)
WRITE_DEBUG_IMAGES = False
class LicensePlateProcessingMixin:
# Attributes expected from consuming classes (set before super().__init__)
config: FrigateConfig
metrics: DataProcessorMetrics
model_runner: LicensePlateModelRunner
lpr_config: LicensePlateRecognitionConfig
requestor: InterProcessRequestor
detected_license_plates: dict[str, dict[str, Any]]
camera_current_cars: dict[str, list[str]]
sub_label_publisher: EventMetadataPublisher
def __init__(self, *args: Any, **kwargs: Any) -> None:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.plate_rec_speed = InferenceSpeed(self.metrics.alpr_speed)
self.plates_rec_second = EventsPerSecond()
@@ -113,7 +97,7 @@ class LicensePlateProcessingMixin:
)
try:
outputs = self.model_runner.detection_model([normalized_image])[0] # type: ignore[arg-type]
outputs = self.model_runner.detection_model([normalized_image])[0]
except Exception as e:
logger.warning(f"Error running LPR box detection model: {e}")
return []
@@ -121,18 +105,18 @@ class LicensePlateProcessingMixin:
outputs = outputs[0, :, :]
if False:
current_time = int(datetime.datetime.now().timestamp()) # type: ignore[unreachable]
current_time = int(datetime.datetime.now().timestamp())
cv2.imwrite(
f"debug/frames/probability_map_{current_time}.jpg",
(outputs * 255).astype(np.uint8),
)
boxes, _ = self._boxes_from_bitmap(outputs, outputs > self.mask_thresh, w, h)
return self._filter_polygon(boxes, (h, w)) # type: ignore[return-value,arg-type]
return self._filter_polygon(boxes, (h, w))
def _classify(
self, images: List[np.ndarray]
) -> Tuple[List[np.ndarray], List[Tuple[str, float]]] | None:
) -> Tuple[List[np.ndarray], List[Tuple[str, float]]]:
"""
Classify the orientation or category of each detected license plate.
@@ -154,15 +138,15 @@ class LicensePlateProcessingMixin:
norm_images.append(norm_img)
try:
outputs = self.model_runner.classification_model(norm_images) # type: ignore[arg-type]
outputs = self.model_runner.classification_model(norm_images)
except Exception as e:
logger.warning(f"Error running LPR classification model: {e}")
return None
return
return self._process_classification_output(images, outputs)
def _recognize(
self, camera: str, images: List[np.ndarray]
self, camera: string, images: List[np.ndarray]
) -> Tuple[List[str], List[List[float]]]:
"""
Recognize the characters on the detected license plates using the recognition model.
@@ -195,7 +179,7 @@ class LicensePlateProcessingMixin:
norm_images.append(norm_image)
try:
outputs = self.model_runner.recognition_model(norm_images) # type: ignore[arg-type]
outputs = self.model_runner.recognition_model(norm_images)
except Exception as e:
logger.warning(f"Error running LPR recognition model: {e}")
return [], []
@@ -426,8 +410,7 @@ class LicensePlateProcessingMixin:
)
if sorted_data:
plates, confs, areas_list = zip(*sorted_data)
return list(plates), list(confs), list(areas_list)
return map(list, zip(*sorted_data))
return [], [], []
@@ -549,7 +532,7 @@ class LicensePlateProcessingMixin:
# Add the last box
merged_boxes.append(current_box)
return np.array(merged_boxes, dtype=np.int32) # type: ignore[return-value]
return np.array(merged_boxes, dtype=np.int32)
def _boxes_from_bitmap(
self, output: np.ndarray, mask: np.ndarray, dest_width: int, dest_height: int
@@ -577,42 +560,38 @@ class LicensePlateProcessingMixin:
boxes = []
scores = []
for index in range(len(contours)): # type: ignore[arg-type]
contour = contours[index] # type: ignore[index]
for index in range(len(contours)):
contour = contours[index]
# get minimum bounding box (rotated rectangle) around the contour and the smallest side length.
points, sside = self._get_min_boxes(contour)
if sside < self.min_size:
continue
points = np.array(points, dtype=np.float32) # type: ignore[assignment]
points = np.array(points, dtype=np.float32)
score = self._box_score(output, contour)
if self.box_thresh > score:
continue
points = self._expand_box(points) # type: ignore[assignment]
points = self._expand_box(points)
# Get the minimum area rectangle again after expansion
points, sside = self._get_min_boxes(points.reshape(-1, 1, 2)) # type: ignore[attr-defined]
points, sside = self._get_min_boxes(points.reshape(-1, 1, 2))
if sside < self.min_size + 2:
continue
points = np.array(points, dtype=np.float32) # type: ignore[assignment]
points = np.array(points, dtype=np.float32)
# normalize and clip box coordinates to fit within the destination image size.
points[:, 0] = np.clip( # type: ignore[call-overload]
np.round(points[:, 0] / width * dest_width), # type: ignore[call-overload]
0,
dest_width,
points[:, 0] = np.clip(
np.round(points[:, 0] / width * dest_width), 0, dest_width
)
points[:, 1] = np.clip( # type: ignore[call-overload]
np.round(points[:, 1] / height * dest_height), # type: ignore[call-overload]
0,
dest_height,
points[:, 1] = np.clip(
np.round(points[:, 1] / height * dest_height), 0, dest_height
)
boxes.append(points.astype("int32")) # type: ignore[attr-defined]
boxes.append(points.astype("int32"))
scores.append(score)
return np.array(boxes, dtype="int32"), scores
@@ -653,7 +632,7 @@ class LicensePlateProcessingMixin:
x1, y1 = np.clip(contour.min(axis=0), 0, [w - 1, h - 1])
x2, y2 = np.clip(contour.max(axis=0), 0, [w - 1, h - 1])
mask = np.zeros((y2 - y1 + 1, x2 - x1 + 1), dtype=np.uint8)
cv2.fillPoly(mask, [contour - [x1, y1]], 1) # type: ignore[call-overload]
cv2.fillPoly(mask, [contour - [x1, y1]], 1)
return cv2.mean(bitmap[y1 : y2 + 1, x1 : x2 + 1], mask)[0]
@staticmethod
@@ -711,7 +690,7 @@ class LicensePlateProcessingMixin:
Returns:
bool: Whether the polygon is valid or not.
"""
return bool(
return (
point[:, 0].min() >= 0
and point[:, 0].max() < width
and point[:, 1].min() >= 0
@@ -756,7 +735,7 @@ class LicensePlateProcessingMixin:
return np.array([tl, tr, br, bl])
@staticmethod
def _sort_boxes(boxes: list[np.ndarray]) -> list[np.ndarray]:
def _sort_boxes(boxes):
"""
Sort polygons based on their position in the image. If boxes are close in vertical
position (within 5 pixels), sort them by horizontal position.
@@ -858,16 +837,16 @@ class LicensePlateProcessingMixin:
results = [["", 0.0]] * len(images)
indices = np.argsort(np.array([x.shape[1] / x.shape[0] for x in images]))
stacked_outputs = np.stack(outputs)
outputs = np.stack(outputs)
stacked_outputs = [
(labels[idx], stacked_outputs[i, idx])
for i, idx in enumerate(stacked_outputs.argmax(axis=1))
outputs = [
(labels[idx], outputs[i, idx])
for i, idx in enumerate(outputs.argmax(axis=1))
]
for i in range(0, len(images), self.batch_size):
for j in range(len(stacked_outputs)):
label, score = stacked_outputs[j]
for j in range(len(outputs)):
label, score = outputs[j]
results[indices[i + j]] = [label, score]
# make sure we have high confidence if we need to flip a box
if "180" in label and score >= 0.7:
@@ -875,10 +854,10 @@ class LicensePlateProcessingMixin:
images[indices[i + j]], cv2.ROTATE_180
)
return images, results # type: ignore[return-value]
return images, results
def _preprocess_recognition_image(
self, camera: str, image: np.ndarray, max_wh_ratio: float
self, camera: string, image: np.ndarray, max_wh_ratio: float
) -> np.ndarray:
"""
Preprocess an image for recognition by dynamically adjusting its width.
@@ -946,7 +925,7 @@ class LicensePlateProcessingMixin:
input_w = int(input_h * max_wh_ratio)
# check for model-specific input width
model_input_w = self.model_runner.recognition_model.runner.get_input_width() # type: ignore[union-attr]
model_input_w = self.model_runner.recognition_model.runner.get_input_width()
if isinstance(model_input_w, int) and model_input_w > 0:
input_w = model_input_w
@@ -966,7 +945,7 @@ class LicensePlateProcessingMixin:
padded_image[:, :, :resized_w] = resized_image
if False:
current_time = int(datetime.datetime.now().timestamp() * 1000) # type: ignore[unreachable]
current_time = int(datetime.datetime.now().timestamp() * 1000)
cv2.imwrite(
f"debug/frames/preprocessed_recognition_{current_time}.jpg",
image,
@@ -1004,9 +983,8 @@ class LicensePlateProcessingMixin:
np.linalg.norm(points[1] - points[2]),
)
)
pts_std = np.array(
[[0, 0], [crop_width, 0], [crop_width, crop_height], [0, crop_height]],
dtype=np.float32,
pts_std = np.float32(
[[0, 0], [crop_width, 0], [crop_width, crop_height], [0, crop_height]]
)
matrix = cv2.getPerspectiveTransform(points, pts_std)
image = cv2.warpPerspective(
@@ -1022,15 +1000,15 @@ class LicensePlateProcessingMixin:
return image
def _detect_license_plate(
self, camera: str, input: np.ndarray
) -> tuple[int, int, int, int] | None:
self, camera: string, input: np.ndarray
) -> tuple[int, int, int, int]:
"""
Use a lightweight YOLOv9 model to detect license plates for users without Frigate+
Return the dimensions of the detected plate as [x1, y1, x2, y2].
"""
try:
predictions = self.model_runner.yolov9_detection_model(input) # type: ignore[arg-type]
predictions = self.model_runner.yolov9_detection_model(input)
except Exception as e:
logger.warning(f"Error running YOLOv9 license plate detection model: {e}")
return None
@@ -1095,7 +1073,7 @@ class LicensePlateProcessingMixin:
logger.debug(
f"{camera}: Found license plate. Bounding box: {expanded_box.astype(int)}"
)
return tuple(expanded_box.astype(int)) # type: ignore[return-value]
return tuple(expanded_box.astype(int))
else:
return None # No detection above the threshold
@@ -1119,7 +1097,7 @@ class LicensePlateProcessingMixin:
f" Variant {i + 1}: '{p['plate']}' (conf: {p['conf']:.3f}, area: {p['area']})"
)
clusters: list[list[dict[str, Any]]] = []
clusters = []
for i, plate in enumerate(plates):
merged = False
for j, cluster in enumerate(clusters):
@@ -1154,7 +1132,7 @@ class LicensePlateProcessingMixin:
)
# Best cluster: largest size, tiebroken by max conf
def cluster_score(c: list[dict[str, Any]]) -> tuple[int, float]:
def cluster_score(c):
return (len(c), max(v["conf"] for v in c))
best_cluster_idx = max(
@@ -1200,7 +1178,7 @@ class LicensePlateProcessingMixin:
def lpr_process(
self, obj_data: dict[str, Any], frame: np.ndarray, dedicated_lpr: bool = False
) -> None:
):
"""Look for license plates in image."""
self.metrics.alpr_pps.value = self.plates_rec_second.eps()
self.metrics.yolov9_lpr_pps.value = self.plates_det_second.eps()
@@ -1217,7 +1195,7 @@ class LicensePlateProcessingMixin:
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
# apply motion mask
rgb[self.config.cameras[camera].motion.rasterized_mask == 0] = [0, 0, 0] # type: ignore[attr-defined]
rgb[self.config.cameras[obj_data].motion.rasterized_mask == 0] = [0, 0, 0]
if WRITE_DEBUG_IMAGES:
cv2.imwrite(
@@ -1283,7 +1261,7 @@ class LicensePlateProcessingMixin:
"stationary", False
):
logger.debug(
f"{camera}: Skipping LPR for non-stationary {obj_data['label']} object {id} with no position changes. (Detected in {self.config.cameras[camera].detect.min_initialized + 1} concurrent frames, threshold to run is {self.config.cameras[camera].detect.min_initialized + 2} frames)" # type: ignore[operator]
f"{camera}: Skipping LPR for non-stationary {obj_data['label']} object {id} with no position changes. (Detected in {self.config.cameras[camera].detect.min_initialized + 1} concurrent frames, threshold to run is {self.config.cameras[camera].detect.min_initialized + 2} frames)"
)
return
@@ -1310,7 +1288,7 @@ class LicensePlateProcessingMixin:
if time_since_stationary > self.stationary_scan_duration:
return
license_plate = None
license_plate: Optional[dict[str, Any]] = None
if "license_plate" not in self.config.cameras[camera].objects.track:
logger.debug(f"{camera}: Running manual license_plate detection.")
@@ -1323,7 +1301,7 @@ class LicensePlateProcessingMixin:
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
# apply motion mask
rgb[self.config.cameras[camera].motion.rasterized_mask == 0] = [0, 0, 0] # type: ignore[attr-defined]
rgb[self.config.cameras[camera].motion.rasterized_mask == 0] = [0, 0, 0]
left, top, right, bottom = car_box
car = rgb[top:bottom, left:right]
@@ -1400,10 +1378,10 @@ class LicensePlateProcessingMixin:
if attr.get("label") != "license_plate":
continue
if license_plate is None or attr.get( # type: ignore[unreachable]
if license_plate is None or attr.get(
"score", 0.0
) > license_plate.get("score", 0.0):
license_plate = attr # type: ignore[assignment]
license_plate = attr
# no license plates detected in this frame
if not license_plate:
@@ -1411,9 +1389,9 @@ class LicensePlateProcessingMixin:
# we are using dedicated lpr with frigate+
if obj_data.get("label") == "license_plate":
license_plate = obj_data # type: ignore[assignment]
license_plate = obj_data
license_plate_box = license_plate.get("box") # type: ignore[attr-defined]
license_plate_box = license_plate.get("box")
# check that license plate is valid
if (
@@ -1442,7 +1420,7 @@ class LicensePlateProcessingMixin:
0, [license_plate_frame.shape[1], license_plate_frame.shape[0]] * 2
)
plate_box = tuple(int(x) for x in expanded_box) # type: ignore[assignment]
plate_box = tuple(int(x) for x in expanded_box)
# Crop using the expanded box
license_plate_frame = license_plate_frame[
@@ -1618,7 +1596,7 @@ class LicensePlateProcessingMixin:
sub_label = next(
(
label
for label, plates_list in self.lpr_config.known_plates.items() # type: ignore[union-attr]
for label, plates_list in self.lpr_config.known_plates.items()
if any(
re.match(f"^{plate}$", rep_plate)
or Levenshtein.distance(plate, rep_plate)
@@ -1671,16 +1649,14 @@ class LicensePlateProcessingMixin:
frame_bgr = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
_, encoded_img = cv2.imencode(".jpg", frame_bgr)
self.sub_label_publisher.publish(
(base64.b64encode(encoded_img.tobytes()).decode("ASCII"), id, camera),
(base64.b64encode(encoded_img).decode("ASCII"), id, camera),
EventMetadataTypeEnum.save_lpr_snapshot.value,
)
def handle_request(
self, topic: str, request_data: dict[str, Any]
) -> dict[str, Any] | None:
return None
def handle_request(self, topic, request_data) -> dict[str, Any] | None:
return
def lpr_expire(self, object_id: str, camera: str) -> None:
def lpr_expire(self, object_id: str, camera: str):
if object_id in self.detected_license_plates:
self.detected_license_plates.pop(object_id)
@@ -1697,7 +1673,7 @@ class CTCDecoder:
for each decoded character sequence.
"""
def __init__(self, character_dict_path: str | None = None) -> None:
def __init__(self, character_dict_path=None):
"""
Initializes the CTCDecoder.
:param character_dict_path: Path to the character dictionary file.
@@ -1,4 +1,3 @@
from frigate.comms.inter_process import InterProcessRequestor
from frigate.embeddings.onnx.lpr_embedding import (
LicensePlateDetector,
PaddleOCRClassification,
@@ -10,12 +9,7 @@ from ...types import DataProcessorModelRunner
class LicensePlateModelRunner(DataProcessorModelRunner):
def __init__(
self,
requestor: InterProcessRequestor,
device: str = "CPU",
model_size: str = "small",
):
def __init__(self, requestor, device: str = "CPU", model_size: str = "small"):
super().__init__(requestor, device, model_size)
self.detection_model = PaddleOCRDetection(
model_size=model_size, requestor=requestor, device=device
+2 -2
View File
@@ -17,7 +17,7 @@ class PostProcessorApi(ABC):
self,
config: FrigateConfig,
metrics: DataProcessorMetrics,
model_runner: DataProcessorModelRunner | None,
model_runner: DataProcessorModelRunner,
) -> None:
self.config = config
self.metrics = metrics
@@ -41,7 +41,7 @@ class PostProcessorApi(ABC):
@abstractmethod
def handle_request(
self, topic: str, request_data: dict[str, Any]
) -> dict[str, Any] | str | None:
) -> dict[str, Any] | None:
"""Handle metadata requests.
Args:
request_data (dict): containing data about requested change to process.
@@ -4,7 +4,7 @@ import logging
import os
import threading
import time
from typing import Any, Optional
from typing import Optional
from peewee import DoesNotExist
@@ -17,7 +17,6 @@ from frigate.const import (
UPDATE_EVENT_DESCRIPTION,
)
from frigate.data_processing.types import PostProcessDataEnum
from frigate.embeddings.embeddings import Embeddings
from frigate.types import TrackedObjectUpdateTypesEnum
from frigate.util.audio import get_audio_from_recording
@@ -32,7 +31,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
self,
config: FrigateConfig,
requestor: InterProcessRequestor,
embeddings: Embeddings,
embeddings,
metrics: DataProcessorMetrics,
):
super().__init__(config, metrics, None)
@@ -41,7 +40,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
self.embeddings = embeddings
self.recognizer = None
self.transcription_lock = threading.Lock()
self.transcription_thread: threading.Thread | None = None
self.transcription_thread = None
self.transcription_running = False
# faster-whisper handles model downloading automatically
@@ -70,7 +69,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
self.recognizer = None
def process_data(
self, data: dict[str, Any], data_type: PostProcessDataEnum
self, data: dict[str, any], data_type: PostProcessDataEnum
) -> None:
"""Transcribe audio from a recording.
@@ -142,13 +141,13 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
except Exception as e:
logger.error(f"Error in audio transcription post-processing: {e}")
def __transcribe_audio(self, audio_data: bytes) -> Optional[str]:
def __transcribe_audio(self, audio_data: bytes) -> Optional[tuple[str, float]]:
"""Transcribe WAV audio data using faster-whisper."""
if not self.recognizer:
logger.debug("Recognizer not initialized")
return None
try: # type: ignore[unreachable]
try:
# Save audio data to a temporary wav (faster-whisper expects a file)
temp_wav = os.path.join(CACHE_DIR, f"temp_audio_{int(time.time())}.wav")
with open(temp_wav, "wb") as f:
@@ -177,7 +176,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
logger.error(f"Error transcribing audio: {e}")
return None
def _transcription_wrapper(self, event: dict[str, Any]) -> None:
def _transcription_wrapper(self, event: dict[str, any]) -> None:
"""Wrapper to run transcription and reset running flag when done."""
try:
self.process_data(
@@ -195,7 +194,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
self.requestor.send_data(UPDATE_AUDIO_TRANSCRIPTION_STATE, "idle")
def handle_request(self, topic: str, request_data: dict[str, Any]) -> str | None:
def handle_request(self, topic: str, request_data: dict[str, any]) -> str | None:
if topic == "transcribe_audio":
event = request_data["event"]
@@ -29,7 +29,7 @@ from .api import PostProcessorApi
logger = logging.getLogger(__name__)
class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): # type: ignore[misc]
class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
def __init__(
self,
config: FrigateConfig,
@@ -71,7 +71,7 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
# don't run LPR post processing for now
return
event_id = data["event_id"] # type: ignore[unreachable]
event_id = data["event_id"]
camera_name = data["camera"]
if data_type == PostProcessDataEnum.recording:
@@ -225,7 +225,7 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
logger.debug(f"Post processing plate: {event_id}, {frame_time}")
self.lpr_process(keyframe_obj_data, frame)
def handle_request(self, topic: str, request_data: dict) -> dict[str, Any] | None:
def handle_request(self, topic, request_data) -> dict[str, Any] | None:
if topic == EmbeddingsRequestEnum.reprocess_plate.value:
event = request_data["event"]
@@ -242,5 +242,3 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
"message": "Successfully requested reprocessing of license plate.",
"success": True,
}
return None
@@ -20,11 +20,11 @@ from frigate.genai import GenAIClient
from frigate.models import Event
from frigate.types import TrackedObjectUpdateTypesEnum
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image
from frigate.util.file import get_event_thumbnail_bytes
from frigate.util.image import create_thumbnail, ensure_jpeg_bytes
if TYPE_CHECKING:
from frigate.embeddings.embeddings import Embeddings
from frigate.embeddings import Embeddings
from ..post.api import PostProcessorApi
from ..types import DataProcessorMetrics
@@ -103,19 +103,16 @@ class ObjectDescriptionProcessor(PostProcessorApi):
logger.debug(f"{camera} sending early request to GenAI")
self.early_request_sent[data["id"]] = True
# Copy thumbnails to avoid holding references after cleanup
thumbnails_copy = [
data["thumbnail"][:] if data.get("thumbnail") else None
for data in self.tracked_events[data["id"]]
if data.get("thumbnail")
]
threading.Thread(
target=self._genai_embed_description,
name=f"_genai_embed_description_{event.id}",
daemon=True,
args=(
event,
thumbnails_copy,
[
data["thumbnail"]
for data in self.tracked_events[data["id"]]
],
),
).start()
@@ -139,7 +136,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
):
self._process_genai_description(event, camera_config, thumbnail)
else:
self.cleanup_event(str(event.id))
self.cleanup_event(event.id)
def __regenerate_description(self, event_id: str, source: str, force: bool) -> None:
"""Regenerate the description for an event."""
@@ -149,17 +146,17 @@ class ObjectDescriptionProcessor(PostProcessorApi):
logger.error(f"Event {event_id} not found for description regeneration")
return
camera_config = self.config.cameras[str(event.camera)]
if self.genai_client is None:
logger.error("GenAI not enabled")
return
camera_config = self.config.cameras[event.camera]
if not camera_config.objects.genai.enabled and not force:
logger.error(f"GenAI not enabled for camera {event.camera}")
return
thumbnail = get_event_thumbnail_bytes(event)
if thumbnail is None:
logger.error("No thumbnail available for %s", event.id)
return
# ensure we have a jpeg to pass to the model
thumbnail = ensure_jpeg_bytes(thumbnail)
@@ -175,21 +172,14 @@ class ObjectDescriptionProcessor(PostProcessorApi):
embed_image = (
[snapshot_image]
if event.has_snapshot and source == "snapshot"
# Copy thumbnails to avoid holding references
else (
[
data["thumbnail"][:] if data.get("thumbnail") else None
for data in self.tracked_events[event_id]
if data.get("thumbnail")
]
[data["thumbnail"] for data in self.tracked_events[event_id]]
if len(self.tracked_events.get(event_id, [])) > 0
else [thumbnail]
)
)
self._genai_embed_description(
event, [img for img in embed_image if img is not None]
)
self._genai_embed_description(event, embed_image)
def process_data(self, frame_data: dict, data_type: PostProcessDataEnum) -> None:
"""Process a frame update."""
@@ -234,42 +224,51 @@ class ObjectDescriptionProcessor(PostProcessorApi):
def _read_and_crop_snapshot(self, event: Event) -> bytes | None:
"""Read, decode, and crop the snapshot image."""
try:
img, _ = load_event_snapshot_image(event)
if img is None:
logger.error(f"Cannot load snapshot for {event.id}, file not found")
return None
snapshot_file = os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg")
# Crop snapshot based on region
# provide full image if region doesn't exist (manual events)
height, width = img.shape[:2]
x1_rel, y1_rel, width_rel, height_rel = event.data.get( # type: ignore[attr-defined]
"region", [0, 0, 1, 1]
if not os.path.isfile(snapshot_file):
logger.error(
f"Cannot load snapshot for {event.id}, file not found: {snapshot_file}"
)
x1, y1 = int(x1_rel * width), int(y1_rel * height)
return None
cropped_image = img[
y1 : y1 + int(height_rel * height),
x1 : x1 + int(width_rel * width),
]
try:
with open(snapshot_file, "rb") as image_file:
snapshot_image = image_file.read()
_, buffer = cv2.imencode(".jpg", cropped_image)
img = cv2.imdecode(
np.frombuffer(snapshot_image, dtype=np.int8),
cv2.IMREAD_COLOR,
)
return buffer.tobytes()
# Crop snapshot based on region
# provide full image if region doesn't exist (manual events)
height, width = img.shape[:2]
x1_rel, y1_rel, width_rel, height_rel = event.data.get(
"region", [0, 0, 1, 1]
)
x1, y1 = int(x1_rel * width), int(y1_rel * height)
cropped_image = img[
y1 : y1 + int(height_rel * height),
x1 : x1 + int(width_rel * width),
]
_, buffer = cv2.imencode(".jpg", cropped_image)
return buffer.tobytes()
except Exception:
return None
def _process_genai_description(
self, event: Event, camera_config: CameraConfig, thumbnail: bytes
self, event: Event, camera_config: CameraConfig, thumbnail
) -> None:
event_id = str(event.id)
if event.has_snapshot and camera_config.objects.genai.use_snapshot:
snapshot_image = self._read_and_crop_snapshot(event)
if not snapshot_image:
return
num_thumbnails = len(self.tracked_events.get(event_id, []))
num_thumbnails = len(self.tracked_events.get(event.id, []))
# ensure we have a jpeg to pass to the model
thumbnail = ensure_jpeg_bytes(thumbnail)
@@ -277,35 +276,30 @@ class ObjectDescriptionProcessor(PostProcessorApi):
embed_image = (
[snapshot_image]
if event.has_snapshot and camera_config.objects.genai.use_snapshot
# Copy thumbnails to avoid holding references after cleanup
else (
[
data["thumbnail"][:] if data.get("thumbnail") else None
for data in self.tracked_events[event_id]
if data.get("thumbnail")
]
[data["thumbnail"] for data in self.tracked_events[event.id]]
if num_thumbnails > 0
else [thumbnail]
)
)
if camera_config.objects.genai.debug_save_thumbnails and num_thumbnails > 0:
logger.debug(f"Saving {num_thumbnails} thumbnails for event {event_id}")
logger.debug(f"Saving {num_thumbnails} thumbnails for event {event.id}")
Path(os.path.join(CLIPS_DIR, f"genai-requests/{event_id}")).mkdir(
Path(os.path.join(CLIPS_DIR, f"genai-requests/{event.id}")).mkdir(
parents=True, exist_ok=True
)
for idx, data in enumerate(self.tracked_events[event_id], 1):
for idx, data in enumerate(self.tracked_events[event.id], 1):
jpg_bytes: bytes | None = data["thumbnail"]
if jpg_bytes is None:
logger.warning(f"Unable to save thumbnail {idx} for {event_id}.")
logger.warning(f"Unable to save thumbnail {idx} for {event.id}.")
else:
with open(
os.path.join(
CLIPS_DIR,
f"genai-requests/{event_id}/{idx}.jpg",
f"genai-requests/{event.id}/{idx}.jpg",
),
"wb",
) as j:
@@ -314,7 +308,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
# Generate the description. Call happens in a thread since it is network bound.
threading.Thread(
target=self._genai_embed_description,
name=f"_genai_embed_description_{event_id}",
name=f"_genai_embed_description_{event.id}",
daemon=True,
args=(
event,
@@ -323,12 +317,12 @@ class ObjectDescriptionProcessor(PostProcessorApi):
).start()
# Clean up tracked events and early request state
self.cleanup_event(event_id)
self.cleanup_event(event.id)
def _genai_embed_description(self, event: Event, thumbnails: list[bytes]) -> None:
"""Embed the description for an event."""
start = datetime.datetime.now().timestamp()
camera_config = self.config.cameras[str(event.camera)]
camera_config = self.config.cameras[event.camera]
description = self.genai_client.generate_object_description(
camera_config, thumbnails, event
)
@@ -350,7 +344,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
# Embed the description
if self.config.semantic_search.enabled:
self.embeddings.embed_description(str(event.id), description)
self.embeddings.embed_description(event.id, description)
# Check semantic trigger for this description
if self.semantic_trigger_processor is not None:
@@ -48,8 +48,8 @@ class ReviewDescriptionProcessor(PostProcessorApi):
self.metrics = metrics
self.genai_client = client
self.review_desc_speed = InferenceSpeed(self.metrics.review_desc_speed)
self.review_desc_dps = EventsPerSecond()
self.review_desc_dps.start()
self.review_descs_dps = EventsPerSecond()
self.review_descs_dps.start()
def calculate_frame_count(
self,
@@ -59,7 +59,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
) -> int:
"""Calculate optimal number of frames based on context size, image source, and resolution.
Token usage varies by resolution: larger images (ultra-wide aspect ratios) use more tokens.
Token usage varies by resolution: larger images (ultrawide aspect ratios) use more tokens.
Estimates ~1 token per 1250 pixels. Targets 98% context utilization with safety margin.
Capped at 20 frames.
"""
@@ -68,11 +68,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
detect_width = camera_config.detect.width
detect_height = camera_config.detect.height
if not detect_width or not detect_height:
aspect_ratio = 16 / 9
else:
aspect_ratio = detect_width / detect_height
aspect_ratio = detect_width / detect_height
if image_source == ImageSourceEnum.recordings:
if aspect_ratio >= 1:
@@ -103,10 +99,8 @@ class ReviewDescriptionProcessor(PostProcessorApi):
return min(max(max_frames, 3), 20)
def process_data(
self, data: dict[str, Any], data_type: PostProcessDataEnum
) -> None:
self.metrics.review_desc_dps.value = self.review_desc_dps.eps()
def process_data(self, data, data_type):
self.metrics.review_desc_dps.value = self.review_descs_dps.eps()
if data_type != PostProcessDataEnum.review:
return
@@ -192,7 +186,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
)
# kickoff analysis
self.review_desc_dps.update()
self.review_descs_dps.update()
threading.Thread(
target=run_analysis,
args=(
@@ -208,7 +202,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
),
).start()
def handle_request(self, topic: str, request_data: dict[str, Any]) -> str | None:
def handle_request(self, topic, request_data):
if topic == EmbeddingsRequestEnum.summarize_review.value:
start_ts = request_data["start_ts"]
end_ts = request_data["end_ts"]
@@ -330,10 +324,10 @@ class ReviewDescriptionProcessor(PostProcessorApi):
end_time: float,
) -> list[str]:
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
file_start = f"preview_{camera}-"
start_file = f"{file_start}{start_time}.webp"
end_file = f"{file_start}{end_time}.webp"
all_frames: list[str] = []
file_start = f"preview_{camera}"
start_file = f"{file_start}-{start_time}.webp"
end_file = f"{file_start}-{end_time}.webp"
all_frames = []
for file in sorted(os.listdir(preview_dir)):
if not file.startswith(file_start):
@@ -469,13 +463,6 @@ class ReviewDescriptionProcessor(PostProcessorApi):
thumbs = []
for idx, thumb_path in enumerate(frame_paths):
thumb_data = cv2.imread(thumb_path)
if thumb_data is None:
logger.warning( # type: ignore[unreachable]
"Could not read preview frame at %s, skipping", thumb_path
)
continue
ret, jpg = cv2.imencode(
".jpg", thumb_data, [int(cv2.IMWRITE_JPEG_QUALITY), 100]
)
@@ -494,12 +481,13 @@ class ReviewDescriptionProcessor(PostProcessorApi):
return thumbs
@staticmethod
def run_analysis(
requestor: InterProcessRequestor,
genai_client: GenAIClient,
review_inference_speed: InferenceSpeed,
camera_config: CameraConfig,
final_data: dict[str, Any],
final_data: dict[str, str],
thumbs: list[bytes],
genai_config: GenAIReviewConfig,
labelmap_objects: list[str],
@@ -533,7 +521,7 @@ def run_analysis(
for i, verified_label in enumerate(final_data["data"]["verified_objects"]):
object_type = verified_label.replace("-verified", "").replace("_", " ")
name = titlecase(sub_labels_list[i].replace("_", " "))
unified_objects.append(f"{name} {object_type}")
unified_objects.append(f"{name} ({object_type})")
for label in objects_list:
if "-verified" in label:
@@ -19,7 +19,6 @@ from frigate.config import FrigateConfig
from frigate.const import CONFIG_DIR
from frigate.data_processing.types import PostProcessDataEnum
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.embeddings.embeddings import Embeddings
from frigate.embeddings.util import ZScoreNormalization
from frigate.models import Event, Trigger
from frigate.util.builtin import cosine_distance
@@ -41,8 +40,8 @@ class SemanticTriggerProcessor(PostProcessorApi):
requestor: InterProcessRequestor,
sub_label_publisher: EventMetadataPublisher,
metrics: DataProcessorMetrics,
embeddings: Embeddings,
) -> None:
embeddings,
):
super().__init__(config, metrics, None)
self.db = db
self.embeddings = embeddings
@@ -237,14 +236,11 @@ class SemanticTriggerProcessor(PostProcessorApi):
return
# Skip the event if not an object
if event.data.get("type") != "object": # type: ignore[attr-defined]
if event.data.get("type") != "object":
return
thumbnail_bytes = get_event_thumbnail_bytes(event)
if thumbnail_bytes is None:
return
nparr = np.frombuffer(thumbnail_bytes, np.uint8)
thumbnail = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
@@ -266,10 +262,8 @@ class SemanticTriggerProcessor(PostProcessorApi):
thumbnail,
)
def handle_request(
self, topic: str, request_data: dict[str, Any]
) -> dict[str, Any] | str | None:
def handle_request(self, topic, request_data):
return None
def expire_object(self, object_id: str, camera: str) -> None:
def expire_object(self, object_id, camera):
pass
+6 -9
View File
@@ -4,23 +4,20 @@ from pydantic import BaseModel, ConfigDict, Field
class ReviewMetadata(BaseModel):
model_config = ConfigDict(extra="ignore", protected_namespaces=())
title: str = Field(
description="A short title characterizing what took place and where, under 10 words."
)
title: str = Field(description="A concise title for the activity.")
scene: str = Field(
description="A chronological narrative of what happens from start to finish."
description="A comprehensive description of the setting and entities, including relevant context and plausible inferences if supported by visual evidence."
)
shortSummary: str = Field(
description="A brief 2-sentence summary of the scene, suitable for notifications."
description="A brief 2-sentence summary of the scene, suitable for notifications. Should capture the key activity and context without full detail."
)
confidence: float = Field(
ge=0.0,
description="Confidence in the analysis, from 0 to 1.",
description="A float between 0 and 1 representing your overall confidence in this analysis."
)
potential_threat_level: int = Field(
ge=0,
le=2,
description="Threat level: 0 = normal, 1 = suspicious, 2 = critical threat.",
le=3,
description="An integer representing the potential threat level (1-3). 1: Minor anomaly. 2: Moderate concern. 3: High threat. Only include this field if a clear security concern is observable; otherwise, omit it.",
)
other_concerns: list[str] | None = Field(
default=None,
@@ -4,7 +4,7 @@ import logging
import os
import queue
import threading
from typing import Any, Optional
from typing import Optional
import numpy as np
@@ -39,11 +39,11 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
self.config = config
self.camera_config = camera_config
self.requestor = requestor
self.stream: Any = None
self.whisper_model: FasterWhisperASR | None = None
self.stream = None
self.whisper_model = None
self.model_runner = model_runner
self.transcription_segments: list[str] = []
self.audio_queue: queue.Queue[tuple[dict[str, Any], np.ndarray]] = queue.Queue()
self.transcription_segments = []
self.audio_queue = queue.Queue()
self.stop_event = stop_event
def __build_recognizer(self) -> None:
@@ -142,10 +142,10 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
logger.error(f"Error processing audio stream: {e}")
return None
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None:
def process_frame(self, obj_data: dict[str, any], frame: np.ndarray) -> None:
pass
def process_audio(self, obj_data: dict[str, Any], audio: np.ndarray) -> bool | None:
def process_audio(self, obj_data: dict[str, any], audio: np.ndarray) -> bool | None:
if audio is None or audio.size == 0:
logger.debug("No audio data provided for transcription")
return None
@@ -269,13 +269,13 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
)
def handle_request(
self, topic: str, request_data: dict[str, Any]
) -> dict[str, Any] | None:
self, topic: str, request_data: dict[str, any]
) -> dict[str, any] | None:
if topic == "clear_audio_recognizer":
self.stream = None
self.__build_recognizer()
return {"message": "Audio recognizer cleared and rebuilt", "success": True}
return None
def expire_object(self, object_id: str, camera: str) -> None:
def expire_object(self, object_id: str) -> None:
pass

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