mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-20 18:59:01 +03:00
Compare commits
191
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50a6ffb478 | ||
|
|
148e11afc5 | ||
|
|
c35cee2d2f | ||
|
|
1a01513223 | ||
|
|
06ad72860c | ||
|
|
03d0139497 | ||
|
|
4772e6a2ab | ||
|
|
909b40ba96 | ||
|
|
0cf9d7d5b1 | ||
|
|
c0124938b3 | ||
|
|
b1c410bc3e | ||
|
|
80c4ce2b5d | ||
|
|
04a2f42d11 | ||
|
|
3f6d5bcf22 | ||
|
|
f5937d8370 | ||
|
|
4b42039568 | ||
|
|
334245bd3c | ||
|
|
de593c8e3f | ||
|
|
854ef320de | ||
|
|
91ef3b2ceb | ||
|
|
6c5801ac83 | ||
|
|
d27ee166bc | ||
|
|
573a5ede62 | ||
|
|
a8b6ea5005 | ||
|
|
a89c7d8819 | ||
|
|
5d67ba76fd | ||
|
|
2c9a25e678 | ||
|
|
d8f599c377 | ||
|
|
5bd74666a7 | ||
|
|
c2b50af170 | ||
|
|
ca2242dac2 | ||
|
|
4cbe124b61 | ||
|
|
7b6d0c5e42 | ||
|
|
57c0473e6e | ||
|
|
6251b758b4 | ||
|
|
59f570f436 | ||
|
|
ec7040bed5 | ||
|
|
b6c03c99de | ||
|
|
74c89beaf9 | ||
|
|
6d2b84e202 | ||
|
|
a8da4c4521 | ||
|
|
acd10d0e08 | ||
|
|
373bcadef8 | ||
|
|
34a06ac77b | ||
|
|
23820718ee | ||
|
|
411798004a | ||
|
|
68de18f10d | ||
|
|
cedcbdba07 | ||
|
|
c6991db432 | ||
|
|
a9a2eecebb | ||
|
|
ede8b74371 | ||
|
|
e2bfa26719 | ||
|
|
c93dad9bd9 | ||
|
|
416a9b7692 | ||
|
|
d11c26970d | ||
|
|
e78da2758d | ||
|
|
a05f35c747 | ||
|
|
7972b3240c | ||
|
|
c1f315a250 | ||
|
|
7cec1f81b9 | ||
|
|
12dc698357 | ||
|
|
e23a57db3e | ||
|
|
5e3a625227 | ||
|
|
97186e9819 | ||
|
|
94a0d0a72b | ||
|
|
04201ce63a | ||
|
|
861c36957f | ||
|
|
45b4e3736d | ||
|
|
5cf6adbff4 | ||
|
|
bcc493680f | ||
|
|
b9ecb1f433 | ||
|
|
cb0563823d | ||
|
|
97718b8760 | ||
|
|
1b7ead885e | ||
|
|
6e21caaa0e | ||
|
|
ae9b307dfc | ||
|
|
7700d73f09 | ||
|
|
f658dbb158 | ||
|
|
2ace8d3670 | ||
|
|
dc27d4ad16 | ||
|
|
7708523865 | ||
|
|
aea91a91d5 | ||
|
|
01c16a9250 | ||
|
|
d4731c1dea | ||
|
|
65ca90db20 | ||
|
|
722ef6a1fe | ||
|
|
bd289f3146 | ||
|
|
c08ec9652f | ||
|
|
7485b48f0e | ||
|
|
6d7b1ce384 | ||
|
|
e80da2b297 | ||
|
|
49ffd0b01a | ||
|
|
bf2dcfd622 | ||
|
|
09df43a675 | ||
|
|
dfc6ff9202 | ||
|
|
bc29c4ba71 | ||
|
|
3ec2305e6a | ||
|
|
5a214eb0d1 | ||
|
|
310b5dfe05 | ||
|
|
8b035be132 | ||
|
|
be79ad89b6 | ||
|
|
687fefb343 | ||
|
|
7864c446fc | ||
|
|
b147b53522 | ||
|
|
d2b2faa2d7 | ||
|
|
614a6b39d4 | ||
|
|
f29ee53fb4 | ||
|
|
324953d3a5 | ||
|
|
0c988da485 | ||
|
|
544d3c6139 | ||
|
|
104e623923 | ||
|
|
192aba901a | ||
|
|
947ddfa542 | ||
|
|
9eb037c369 | ||
|
|
59fc8449ed | ||
|
|
a0b8271532 | ||
|
|
19480867fb | ||
|
|
5254bfd00e | ||
|
|
e75b8ca6cc | ||
|
|
29ffcea851 | ||
|
|
925cb049ce | ||
|
|
c575e6b38f | ||
|
|
d568207b1e | ||
|
|
29d071f676 | ||
|
|
e5494bbb0f | ||
|
|
8d010c52e0 | ||
|
|
39c878b871 | ||
|
|
c0c64feae3 | ||
|
|
f0ecc5b0da | ||
|
|
a8ea77bebc | ||
|
|
b9a35dc873 | ||
|
|
dbf997692f | ||
|
|
f6f4c67372 | ||
|
|
36efcf938d | ||
|
|
665fc76397 | ||
|
|
00fd1ccee3 | ||
|
|
9d3bc82e87 | ||
|
|
983ee58b40 | ||
|
|
34b5e3c5b0 | ||
|
|
4c5124e615 | ||
|
|
d8d5f1c0b2 | ||
|
|
7f061ba5b1 | ||
|
|
bfb275fc47 | ||
|
|
d838d13d3a | ||
|
|
f79e07da9e | ||
|
|
6291107532 | ||
|
|
acbec0bd59 | ||
|
|
fe22d994bc | ||
|
|
5de553ca06 | ||
|
|
680361c35c | ||
|
|
e6405a2566 | ||
|
|
5f75289c5b | ||
|
|
0723380d4a | ||
|
|
a2eabdbeef | ||
|
|
ec49ccf83a | ||
|
|
6007ec82b5 | ||
|
|
0a6c440f24 | ||
|
|
aa5085caa0 | ||
|
|
3dd584b5ef | ||
|
|
e1ae32640c | ||
|
|
68fe7884b7 | ||
|
|
7b2c75be49 | ||
|
|
b405877587 | ||
|
|
d88c0e260e | ||
|
|
90ad5213c7 | ||
|
|
6c47e837e3 | ||
|
|
5cec1be91d | ||
|
|
e8b9225175 | ||
|
|
119137c4fe | ||
|
|
1188d87588 | ||
|
|
9cbd80d981 | ||
|
|
c4a5ac0e77 | ||
|
|
41e290449e | ||
|
|
b6f78bd1f2 | ||
|
|
bde518e861 | ||
|
|
4e71a835cb | ||
|
|
537e723c30 | ||
|
|
f316244495 | ||
|
|
d1f3a807d3 | ||
|
|
ab3cef813c | ||
|
|
c338533c83 | ||
|
|
8c67704ffb | ||
|
|
d311974949 | ||
|
|
0dd1e94d60 | ||
|
|
720e949fef | ||
|
|
c687aa5119 | ||
|
|
e064024a31 | ||
|
|
96c70eee4c | ||
|
|
0310a9654d | ||
|
|
dd8282ff3c | ||
|
|
984d654c40 |
@@ -1,17 +1,18 @@
|
||||
_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
|
||||
@@ -25,6 +26,45 @@
|
||||
|
||||
- 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
|
||||
|
||||
@@ -35,5 +75,6 @@
|
||||
- [ ] 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`)
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push amd64 standard build
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
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@v5
|
||||
uses: docker/build-push-action@v7
|
||||
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@v6
|
||||
uses: docker/bake-action@v7
|
||||
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@v6
|
||||
uses: docker/bake-action@v7
|
||||
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@v6
|
||||
uses: docker/bake-action@v7
|
||||
with:
|
||||
source: .
|
||||
push: true
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
- name: AMD/ROCm general build
|
||||
env:
|
||||
HSA_OVERRIDE: 0
|
||||
uses: docker/bake-action@v6
|
||||
uses: docker/bake-action@v7
|
||||
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@v6
|
||||
uses: docker/bake-action@v7
|
||||
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@v6
|
||||
uses: docker/bake-action@v7
|
||||
with:
|
||||
source: .
|
||||
push: true
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
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.');
|
||||
@@ -27,6 +27,9 @@ 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
|
||||
|
||||
Vendored
+17
@@ -6,6 +6,23 @@
|
||||
"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
@@ -0,0 +1,140 @@
|
||||
# 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)
|
||||
@@ -266,6 +266,12 @@ 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"
|
||||
|
||||
@@ -73,6 +73,7 @@ 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 \
|
||||
|
||||
+14
-13
@@ -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-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz"
|
||||
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"
|
||||
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-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz"
|
||||
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"
|
||||
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/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
|
||||
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
|
||||
|
||||
# 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,18 +115,19 @@ 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/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
|
||||
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
|
||||
# npu packages
|
||||
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
|
||||
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
|
||||
|
||||
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..."
|
||||
echo '{}' > "${config_path}"
|
||||
: > "${config_path}"
|
||||
fi
|
||||
|
||||
# Convert YAML to JSON for jq processing
|
||||
@@ -65,23 +65,25 @@ function setup_homekit_config() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# 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}"
|
||||
}
|
||||
# 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"
|
||||
|
||||
# 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}"
|
||||
}
|
||||
# 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
|
||||
|
||||
# Clean up temp files
|
||||
rm -f "${temp_json}" "${cleaned_json}"
|
||||
rm -f "${temp_json}"
|
||||
}
|
||||
|
||||
set_libva_version
|
||||
|
||||
@@ -63,6 +63,9 @@ 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 '';
|
||||
|
||||
@@ -59,12 +59,14 @@ 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 and gfx11xx only (RDNA2/RDNA3)
|
||||
# Copy MIOpen database files for gfx10xx, gfx11xx, and gfx12xx only (RDNA2/RDNA3/RDNA4)
|
||||
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 rocBLAS library files for gfx10xx and gfx11xx only
|
||||
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 --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/ /
|
||||
|
||||
#######################################################################
|
||||
|
||||
@@ -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.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'
|
||||
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'
|
||||
onnx==1.16.*; platform_machine == 'x86_64'
|
||||
onnxruntime-gpu==1.24.*; platform_machine == 'x86_64'
|
||||
protobuf==3.20.3; platform_machine == 'x86_64'
|
||||
|
||||
@@ -44,13 +44,21 @@ 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.
|
||||
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.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
environment_vars:
|
||||
VARIABLE_NAME: variable_value
|
||||
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}"
|
||||
```
|
||||
|
||||
#### TensorFlow Thread Configuration
|
||||
|
||||
@@ -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 Add-on options
|
||||
3. A `jwt_secret` option from the Home Assistant App 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
|
||||
```yaml {11-16}
|
||||
cameras:
|
||||
front_door:
|
||||
# ... camera config
|
||||
|
||||
@@ -52,6 +52,10 @@ 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:
|
||||
|
||||
@@ -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
|
||||
```yaml {8-10,12-14}
|
||||
# Include all cameras by default in Birdseye view
|
||||
birdseye:
|
||||
enabled: True
|
||||
@@ -48,6 +48,7 @@ By default birdseye shows all cameras that have had the configured activity in t
|
||||
```yaml
|
||||
birdseye:
|
||||
enabled: True
|
||||
# highlight-next-line
|
||||
inactivity_threshold: 15
|
||||
```
|
||||
|
||||
@@ -78,9 +79,11 @@ birdseye:
|
||||
cameras:
|
||||
front:
|
||||
birdseye:
|
||||
# highlight-next-line
|
||||
order: 1
|
||||
back:
|
||||
birdseye:
|
||||
# highlight-next-line
|
||||
order: 2
|
||||
```
|
||||
|
||||
@@ -92,7 +95,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
|
||||
```yaml {3-4}
|
||||
birdseye:
|
||||
enabled: True
|
||||
layout:
|
||||
@@ -103,7 +106,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
|
||||
```yaml {3-4}
|
||||
birdseye:
|
||||
enabled: True
|
||||
layout:
|
||||
|
||||
@@ -23,6 +23,7 @@ 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
|
||||
```
|
||||
|
||||
@@ -30,7 +31,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
|
||||
```yaml {3,10}
|
||||
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.
|
||||
@@ -96,6 +97,7 @@ 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
|
||||
@@ -274,7 +276,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:
|
||||
|
||||
@@ -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
|
||||
```yaml {4-8}
|
||||
cameras:
|
||||
back:
|
||||
ffmpeg: ...
|
||||
@@ -91,6 +91,8 @@ 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. Inference should be usable on virtually any machine that can run Frigate.
|
||||
Object classification models are lightweight and run very fast on CPU.
|
||||
|
||||
Training the model does briefly use a high amount of system resources for about 1–3 minutes per training run. On lower-power devices, training may take longer.
|
||||
|
||||
A CPU with AVX instructions is required for training and inference.
|
||||
A CPU with AVX + AVX2 instructions is required for training and inference.
|
||||
|
||||
## Classes
|
||||
|
||||
@@ -27,7 +27,6 @@ For object classification:
|
||||
### Classification Type
|
||||
|
||||
- **Sub label**:
|
||||
|
||||
- Applied to the object’s `sub_label` field.
|
||||
- Ideal for a single, more specific identity or type.
|
||||
- Example: `cat` → `Leo`, `Charlie`, `None`.
|
||||
@@ -103,8 +102,19 @@ 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 90–100% 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 don’t 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 model’s Recent Classification tab to gather balanced examples across times of day, weather, and distances.
|
||||
- **Preprocessing**: Ensure examples reflect object crops similar to Frigate’s 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.
|
||||
@@ -119,6 +129,7 @@ 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. Inference should be usable on virtually any machine that can run Frigate.
|
||||
State classification models are lightweight and run very fast on CPU.
|
||||
|
||||
Training the model does briefly use a high amount of system resources for about 1–3 minutes per training run. On lower-power devices, training may take longer.
|
||||
|
||||
A CPU with AVX instructions is required for training and inference.
|
||||
A CPU with AVX + AVX2 instructions is required for training and inference.
|
||||
|
||||
## Classes
|
||||
|
||||
@@ -70,10 +70,21 @@ 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.
|
||||
- **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.
|
||||
- **Favor hard examples**: When images appear in the Recent Classifications tab, prioritize images scoring below 90–100% 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.
|
||||
|
||||
## Debugging Classification Models
|
||||
|
||||
@@ -85,6 +96,7 @@ 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
|
||||
```
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ 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.
|
||||
@@ -143,17 +145,14 @@ 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).
|
||||
|
||||
|
||||
@@ -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,6 +220,29 @@ 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,6 +80,7 @@ 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"
|
||||
```
|
||||
|
||||
@@ -104,7 +105,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
|
||||
```yaml {4,5}
|
||||
review:
|
||||
genai:
|
||||
enabled: true
|
||||
@@ -116,7 +117,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
|
||||
```yaml {4}
|
||||
review:
|
||||
genai:
|
||||
enabled: true
|
||||
|
||||
@@ -10,6 +10,7 @@ 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.
|
||||
|
||||
@@ -67,7 +68,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 Add-on 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 App 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.
|
||||
|
||||
@@ -116,12 +117,13 @@ services:
|
||||
frigate:
|
||||
...
|
||||
image: ghcr.io/blakeblackshear/frigate:stable
|
||||
# highlight-next-line
|
||||
privileged: true
|
||||
```
|
||||
|
||||
##### Docker Run CLI - Privileged
|
||||
|
||||
```bash
|
||||
```bash {4}
|
||||
docker run -d \
|
||||
--name frigate \
|
||||
...
|
||||
@@ -135,7 +137,7 @@ Only recent versions of Docker support the `CAP_PERFMON` capability. You can tes
|
||||
|
||||
##### Docker Compose - CAP_PERFMON
|
||||
|
||||
```yaml
|
||||
```yaml {5,6}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -146,7 +148,7 @@ services:
|
||||
|
||||
##### Docker Run CLI - CAP_PERFMON
|
||||
|
||||
```bash
|
||||
```bash {4}
|
||||
docker run -d \
|
||||
--name frigate \
|
||||
...
|
||||
@@ -188,7 +190,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 Add-on 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 App users](advanced.md#environment_vars).
|
||||
|
||||
### Via VAAPI
|
||||
|
||||
@@ -213,7 +215,7 @@ Additional configuration is needed for the Docker container to be able to access
|
||||
|
||||
#### Docker Compose - Nvidia GPU
|
||||
|
||||
```yaml
|
||||
```yaml {5-12}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -230,7 +232,7 @@ services:
|
||||
|
||||
#### Docker Run CLI - Nvidia GPU
|
||||
|
||||
```bash
|
||||
```bash {4}
|
||||
docker run -d \
|
||||
--name frigate \
|
||||
...
|
||||
@@ -292,7 +294,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 Add-on, you may need to use the full access variant and turn off _Protection mode_ for hardware acceleration.
|
||||
If you are using the HA App, 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
|
||||
@@ -309,7 +311,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
|
||||
```yaml {4-5}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -319,7 +321,7 @@ services:
|
||||
|
||||
Or with `docker run`:
|
||||
|
||||
```bash
|
||||
```bash {4}
|
||||
docker run -d \
|
||||
--name frigate \
|
||||
...
|
||||
@@ -351,7 +353,7 @@ You will need to use the image with the nvidia container runtime:
|
||||
|
||||
### Docker Run CLI - Jetson
|
||||
|
||||
```bash
|
||||
```bash {3}
|
||||
docker run -d \
|
||||
...
|
||||
--runtime nvidia
|
||||
@@ -360,7 +362,7 @@ docker run -d \
|
||||
|
||||
### Docker Compose - Jetson
|
||||
|
||||
```yaml
|
||||
```yaml {5}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -451,14 +453,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:
|
||||
@@ -480,7 +482,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
|
||||
```yaml {2}
|
||||
ffmpeg:
|
||||
hwaccel_args: -c:v h264_v4l2m2m
|
||||
input_args: preset-rtsp-restream
|
||||
|
||||
@@ -3,7 +3,7 @@ id: index
|
||||
title: Frigate Configuration
|
||||
---
|
||||
|
||||
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 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 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 Add-on configuration directory {#accessing-add-on-config-dir}
|
||||
## Accessing the Home Assistant App configuration directory {#accessing-app-config-dir}
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
| Add-on Variant | Configuration directory |
|
||||
| App 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 Add-on, the Frigate `/config` directory is m
|
||||
|
||||
**Whenever you see `/config` in the documentation, it refers to this directory.**
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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 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.
|
||||
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.
|
||||
|
||||
## Environment Variable Substitution
|
||||
|
||||
@@ -50,6 +50,7 @@ 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}"
|
||||
```
|
||||
@@ -60,7 +61,7 @@ mqtt:
|
||||
|
||||
```yaml
|
||||
onvif:
|
||||
host: 10.0.10.10
|
||||
host: "192.168.1.12"
|
||||
port: 8000
|
||||
user: "{FRIGATE_RTSP_USER}"
|
||||
password: "{FRIGATE_RTSP_PASSWORD}"
|
||||
@@ -82,10 +83,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 Add-on with USB Coral
|
||||
### Raspberry Pi Home Assistant App with USB Coral
|
||||
|
||||
- Single camera with 720p, 5fps stream for detect
|
||||
- MQTT connected to the Home Assistant Mosquitto Add-on
|
||||
- MQTT connected to the Home Assistant Mosquitto App
|
||||
- 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 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 and a CPU with AVX + AVX2 instructions 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
|
||||
```yaml {4,5}
|
||||
cameras:
|
||||
garage:
|
||||
...
|
||||
@@ -375,7 +375,6 @@ 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
|
||||
@@ -386,31 +385,28 @@ 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).
|
||||
|
||||
@@ -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
|
||||
```yaml {3,6,8,25-29}
|
||||
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 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:
|
||||
- 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:
|
||||
|
||||
```yaml title="config.yml"
|
||||
```yaml title="config.yml" {4-7}
|
||||
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 Add-on, 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 App, 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 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:
|
||||
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:
|
||||
|
||||
```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
|
||||
```yaml {4-6}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -222,34 +222,28 @@ 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)
|
||||
|
||||
@@ -270,21 +264,18 @@ 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).
|
||||
@@ -324,9 +315,7 @@ 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)
|
||||
|
||||
|
||||
@@ -49,6 +49,11 @@ 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.
|
||||
@@ -161,7 +166,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.
|
||||
|
||||
:::
|
||||
|
||||
@@ -572,7 +577,7 @@ $ docker run --device=/dev/kfd --device=/dev/dri \
|
||||
|
||||
When using Docker Compose:
|
||||
|
||||
```yaml
|
||||
```yaml {4-6}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -603,7 +608,7 @@ $ docker run -e HSA_OVERRIDE_GFX_VERSION=10.0.0 \
|
||||
|
||||
When using Docker Compose:
|
||||
|
||||
```yaml
|
||||
```yaml {4-5}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -1478,6 +1483,42 @@ 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.
|
||||
@@ -1571,12 +1612,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 libgl1 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/
|
||||
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/
|
||||
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.1 onnxscript
|
||||
RUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier==0.4.* 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
|
||||
|
||||
@@ -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
|
||||
```yaml {3-4}
|
||||
record:
|
||||
enabled: True
|
||||
export:
|
||||
@@ -162,6 +162,8 @@ 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.
|
||||
|
||||
@@ -16,6 +16,8 @@ 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
|
||||
@@ -616,13 +618,12 @@ record:
|
||||
# never stored, so setting the mode to "all" here won't bring them back.
|
||||
mode: motion
|
||||
|
||||
# Optional: Configuration for the jpg snapshots written to the clips directory for each tracked object
|
||||
# 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.
|
||||
# NOTE: Can be overridden at the camera level
|
||||
snapshots:
|
||||
# Optional: Enable writing jpg snapshot to /media/frigate/clips (default: shown below)
|
||||
# Optional: Enable writing snapshot images 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)
|
||||
@@ -640,8 +641,8 @@ snapshots:
|
||||
# Optional: Per object retention days
|
||||
objects:
|
||||
person: 15
|
||||
# Optional: quality of the encoded jpeg, 0-100 (default: shown below)
|
||||
quality: 70
|
||||
# Optional: quality of the encoded snapshot image, 0-100 (default: shown below)
|
||||
quality: 60
|
||||
|
||||
# Optional: Configuration for semantic search capability
|
||||
semantic_search:
|
||||
@@ -950,6 +951,8 @@ 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
|
||||
@@ -963,6 +966,10 @@ 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:
|
||||
|
||||
@@ -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
|
||||
```yaml {2-4}
|
||||
go2rtc:
|
||||
rtsp:
|
||||
username: "admin"
|
||||
@@ -147,6 +147,7 @@ For example:
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
# highlight-error-line
|
||||
my_camera: rtsp://username:$@foo%@192.168.1.100
|
||||
```
|
||||
|
||||
@@ -155,6 +156,7 @@ becomes
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
# highlight-next-line
|
||||
my_camera: rtsp://username:$%40foo%25@192.168.1.100
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
```yaml {3-5}
|
||||
cameras:
|
||||
gatecamera:
|
||||
review:
|
||||
|
||||
@@ -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 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 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.
|
||||
|
||||
For best performance, 16GB or more of RAM and a dedicated GPU are recommended.
|
||||
|
||||
|
||||
@@ -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>.jpg`. 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>-clean.webp`. 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,21 +13,19 @@ 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 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.
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
## Clean Copy
|
||||
## Rendering
|
||||
|
||||
Frigate can produce up to two snapshot files per event, each used in different places:
|
||||
Frigate stores a single clean snapshot on disk:
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
MQTT snapshots are configured separately under `cameras -> your_camera -> mqtt` and are unrelated to the stored event snapshot.
|
||||
|
||||
@@ -20,7 +20,7 @@ tls:
|
||||
|
||||
TLS certificates can be mounted at `/etc/letsencrypt/live/frigate` using a bind mount or docker volume.
|
||||
|
||||
```yaml
|
||||
```yaml {3-4}
|
||||
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
|
||||
```yaml {3-5}
|
||||
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
|
||||
```yaml {3-4}
|
||||
frigate:
|
||||
...
|
||||
ports:
|
||||
|
||||
@@ -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
|
||||
```yaml {6,8}
|
||||
cameras:
|
||||
name_of_your_camera:
|
||||
review:
|
||||
@@ -108,6 +108,7 @@ cameras:
|
||||
name_of_your_camera:
|
||||
zones:
|
||||
sidewalk:
|
||||
# highlight-next-line
|
||||
loitering_time: 4 # unit is in seconds
|
||||
objects:
|
||||
- person
|
||||
@@ -122,6 +123,7 @@ cameras:
|
||||
name_of_your_camera:
|
||||
zones:
|
||||
front_yard:
|
||||
# highlight-next-line
|
||||
inertia: 3
|
||||
objects:
|
||||
- person
|
||||
@@ -134,6 +136,7 @@ cameras:
|
||||
name_of_your_camera:
|
||||
zones:
|
||||
driveway_entrance:
|
||||
# highlight-next-line
|
||||
inertia: 1
|
||||
objects:
|
||||
- car
|
||||
@@ -196,5 +199,6 @@ 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)
|
||||
```
|
||||
|
||||
@@ -17,15 +17,15 @@ From here, follow the guides for:
|
||||
- [Web Interface](#web-interface)
|
||||
- [Documentation](#documentation)
|
||||
|
||||
### Frigate Home Assistant Add-on
|
||||
### Frigate Home Assistant App
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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 Add-on](#frigate-home-assistant-add-on).
|
||||
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).
|
||||
|
||||
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,6 +89,14 @@ 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.
|
||||
@@ -125,6 +133,28 @@ 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
|
||||
|
||||
@@ -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 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 (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.
|
||||
|
||||
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,6 +103,10 @@ 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
|
||||
@@ -196,13 +200,14 @@ 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 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 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 |
|
||||
|
||||
### Apple Silicon
|
||||
|
||||
@@ -288,6 +293,14 @@ 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.
|
||||
|
||||
@@ -3,11 +3,13 @@ id: installation
|
||||
title: Installation
|
||||
---
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
:::tip
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
:::
|
||||
|
||||
@@ -77,22 +79,9 @@ 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.
|
||||
|
||||
You can calculate the **minimum** shm size for each camera with the following formula using the resolution specified for detect:
|
||||
<ShmCalculator/>
|
||||
|
||||
```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.
|
||||
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.
|
||||
|
||||
## Extra Steps for Specific Hardware
|
||||
|
||||
@@ -439,6 +428,42 @@ 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.
|
||||
@@ -510,7 +535,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 Add-on
|
||||
## Home Assistant App
|
||||
|
||||
:::warning
|
||||
|
||||
@@ -521,7 +546,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 addons do not support the Nvidia runtime.
|
||||
- Nvidia GPUs are not supported because HA Apps do not support the Nvidia runtime.
|
||||
|
||||
:::
|
||||
|
||||
@@ -531,27 +556,27 @@ See [the network storage guide](/guides/ha_network_storage.md) for instructions
|
||||
|
||||
:::
|
||||
|
||||
Home Assistant OS users can install via the Add-on repository.
|
||||
Home Assistant OS users can install via the App repository.
|
||||
|
||||
1. In Home Assistant, navigate to _Settings_ > _Add-ons_ > _Add-on Store_ > _Repositories_
|
||||
1. In Home Assistant, navigate to _Settings_ > _Apps_ > _App Store_ > _Repositories_
|
||||
2. Add `https://github.com/blakeblackshear/frigate-hass-addons`
|
||||
3. Install the desired variant of the Frigate Add-on (see below)
|
||||
3. Install the desired variant of the Frigate App (see below)
|
||||
4. Setup your network configuration in the `Configuration` tab
|
||||
5. Start the Add-on
|
||||
5. Start the App
|
||||
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 Add-on available:
|
||||
There are several variants of the App available:
|
||||
|
||||
| Add-on Variant | Description |
|
||||
| App 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 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.
|
||||
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.
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
## Kubernetes
|
||||
|
||||
|
||||
@@ -34,11 +34,14 @@ 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.
|
||||
@@ -71,4 +74,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.
|
||||
|
||||
@@ -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 Addon, 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 App, etc.). Below are instructions for the most common setups.
|
||||
|
||||
## Before You Begin
|
||||
|
||||
@@ -67,30 +67,30 @@ If you’re running Frigate via Docker (recommended method), follow these steps:
|
||||
- If you’ve customized other settings (e.g., `shm-size`), ensure they’re 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 Addon
|
||||
## Updating the Home Assistant App (formerly Addon)
|
||||
|
||||
For users running Frigate as a Home Assistant Addon:
|
||||
For users running Frigate as a Home Assistant App:
|
||||
|
||||
1. **Check for Updates**:
|
||||
- Navigate to **Settings > Add-ons** in Home Assistant.
|
||||
- Find your installed Frigate addon (e.g., "Frigate NVR" or "Frigate NVR (Full Access)").
|
||||
- Navigate to **Settings > Apps** in Home Assistant.
|
||||
- Find your installed Frigate app (e.g., "Frigate NVR" or "Frigate NVR (Full Access)").
|
||||
- If an update is available, you’ll see an "Update" button.
|
||||
|
||||
2. **Update the Addon**:
|
||||
- Click the "Update" button next to the Frigate addon.
|
||||
2. **Update the App**:
|
||||
- Click the "Update" button next to the Frigate app.
|
||||
- Wait for the process to complete. Home Assistant will handle downloading and installing the new version.
|
||||
|
||||
3. **Restart the Addon**:
|
||||
- After updating, go to the addon’s page and click "Restart" to apply the changes.
|
||||
3. **Restart the App**:
|
||||
- After updating, go to the app’s page and click "Restart" to apply the changes.
|
||||
|
||||
4. **Verify the Update**:
|
||||
- Check the addon logs (under the "Log" tab) to ensure Frigate starts without errors.
|
||||
- Check the app 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 addon updates don’t modify your hardware settings.
|
||||
- If using custom hardware (e.g., Coral or GPU), verify that configurations still work, as app updates don’t 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: Reinstall the previous addon version manually via the repository if needed and restart the addon.
|
||||
- For Home Assistant: Restore from the app/addon backup you took before you updated.
|
||||
4. Verify the old version is running again.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -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\nstore)]
|
||||
SnapStore[(Snapshot\nstore)]
|
||||
RecStore[(Recording<br>store)]
|
||||
SnapStore[(Snapshot<br>store)]
|
||||
|
||||
subgraph Acquisition
|
||||
Cam["Camera"] -->|FFmpeg supported| Stream
|
||||
Cam -->|"Other streaming\nprotocols"| go2rtc
|
||||
Cam -->|"Other streaming<br>protocols"| go2rtc
|
||||
go2rtc("go2rtc") --> Stream
|
||||
Stream[Capture main and\nsub streams] --> |detect stream|Decode(Decode and\ndownscale)
|
||||
Stream[Capture main and<br>sub streams] --> |detect stream|Decode(Decode and<br>downscale)
|
||||
end
|
||||
subgraph Motion
|
||||
Decode --> MotionM(Apply\nmotion masks)
|
||||
MotionM --> MotionD(Motion\ndetection)
|
||||
Decode --> MotionM(Apply<br>motion masks)
|
||||
MotionM --> MotionD(Motion<br>detection)
|
||||
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\n(retain motion)"|RecStore
|
||||
MotionD --> |"video segments<br>(retain motion)"|RecStore
|
||||
ObjectZ --> |detection clip|RecStore
|
||||
Stream -->|"video segments\n(retain all)"| RecStore
|
||||
Stream -->|"video segments<br>(retain all)"| RecStore
|
||||
ObjectZ --> |detection snapshot|SnapStore
|
||||
```
|
||||
|
||||
@@ -33,19 +33,16 @@ 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.
|
||||
@@ -58,7 +55,6 @@ 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
|
||||
@@ -101,9 +97,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 Add-On (for
|
||||
To access the go2rtc stream externally when utilizing the Frigate App (for
|
||||
instance through VLC), you must first enable the RTSP Restream port.
|
||||
You can do this by visiting the Frigate Add-On configuration page within Home
|
||||
You can do this by visiting the Frigate App configuration page within Home
|
||||
Assistant and revealing the hidden options under the "Show disabled ports"
|
||||
section.
|
||||
|
||||
|
||||
@@ -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 Add-on, you can continue to [Configuring Frigate](#configuring-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.
|
||||
|
||||
:::
|
||||
|
||||
@@ -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 Add-on 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 App 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
|
||||
```yaml {4,5}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -168,17 +168,57 @@ 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. If you have a USB Coral, you will need to add a detectors section to your config.
|
||||
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>
|
||||
|
||||
`docker-compose.yml` (after modifying, you will need to run `docker compose up -d` to apply changes)
|
||||
|
||||
```yaml
|
||||
```yaml {4-6}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -188,7 +228,7 @@ services:
|
||||
...
|
||||
```
|
||||
|
||||
```yaml
|
||||
```yaml {3-6,11-12}
|
||||
mqtt: ...
|
||||
|
||||
detectors: # <---- add detectors
|
||||
@@ -204,6 +244,8 @@ 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).
|
||||
@@ -222,7 +264,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
|
||||
```yaml {16-18}
|
||||
mqtt:
|
||||
enabled: False
|
||||
|
||||
@@ -252,7 +294,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
|
||||
```yaml {16-17}
|
||||
mqtt: ...
|
||||
|
||||
detectors: ...
|
||||
|
||||
@@ -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 Add-ons.
|
||||
As of Home Assistant 2023.6, Network Mounted Storage is supported for Apps.
|
||||
|
||||
## Setting Up Remote Storage For Frigate
|
||||
|
||||
@@ -14,7 +14,7 @@ As of Home Assistant 2023.6, Network Mounted Storage is supported for Add-ons.
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. Stop the Frigate Add-on
|
||||
1. Stop the Frigate App
|
||||
|
||||
### 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 Add-on
|
||||
7. Start the Frigate App
|
||||
|
||||
@@ -99,11 +99,11 @@ services:
|
||||
...
|
||||
```
|
||||
|
||||
### Home Assistant Add-on
|
||||
### Home Assistant App
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
| Add-on Variant | URL |
|
||||
| App Variant | URL |
|
||||
| -------------------------- | -------------------------------------- |
|
||||
| Frigate | `http://ccab4aaf-frigate:5000` |
|
||||
| Frigate (Full Access) | `http://ccab4aaf-frigate-fa:5000` |
|
||||
|
||||
@@ -11,7 +11,8 @@ 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)
|
||||
"offline": published after Frigate has stopped
|
||||
"stopped": published when Frigate is stopped normally
|
||||
"offline": published automatically by the MQTT broker if Frigate disconnects unexpectedly (via MQTT Will Message)
|
||||
|
||||
### `frigate/restart`
|
||||
|
||||
@@ -283,17 +284,6 @@ 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`.
|
||||
|
||||
@@ -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 Addon users can set it under Settings > Add-ons > 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 App users can set it under Settings > Apps > 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 Add-on 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 App config.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -42,3 +42,7 @@ 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.
|
||||
|
||||
@@ -25,10 +25,9 @@ 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 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.
|
||||
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.
|
||||
|
||||
```yaml
|
||||
snapshots:
|
||||
enabled: true
|
||||
clean_copy: true
|
||||
```
|
||||
|
||||
@@ -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 Add-on 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 App with the _Protection mode_ switch disabled so that the coral can be accessed.
|
||||
|
||||
### Synology 716+II running DSM 7.2.1-69057 Update 5
|
||||
|
||||
|
||||
@@ -83,6 +83,17 @@ 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: [
|
||||
|
||||
Generated
+12
-12
@@ -6820,9 +6820,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"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==",
|
||||
"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.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -12313,9 +12313,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
|
||||
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
@@ -20609,9 +20609,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/serve-handler/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
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;
|
||||
@@ -0,0 +1,131 @@
|
||||
.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;
|
||||
}
|
||||
@@ -234,3 +234,11 @@
|
||||
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;
|
||||
}
|
||||
Vendored
+2316
-498
File diff suppressed because it is too large
Load Diff
+69
-36
@@ -5,6 +5,7 @@ import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import traceback
|
||||
import urllib
|
||||
from datetime import datetime, timedelta
|
||||
@@ -34,7 +35,6 @@ 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,41 +244,29 @@ 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",
|
||||
@@ -341,7 +329,7 @@ def config_raw_paths(request: Request):
|
||||
return JSONResponse(content=raw_paths)
|
||||
|
||||
|
||||
@router.get("/config/raw", dependencies=[Depends(allow_any_authenticated())])
|
||||
@router.get("/config/raw", dependencies=[Depends(require_role(["admin"]))])
|
||||
def config_raw():
|
||||
config_file = find_config_file()
|
||||
|
||||
@@ -625,6 +613,34 @@ 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)
|
||||
@@ -884,7 +900,10 @@ 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
|
||||
dry_run=body.dry_run,
|
||||
media_types=body.media_types,
|
||||
force=body.force,
|
||||
verbose=body.verbose,
|
||||
)
|
||||
|
||||
if job_id is None:
|
||||
@@ -1093,7 +1112,12 @@ 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):
|
||||
def timeline(
|
||||
camera: str = "all",
|
||||
limit: int = 100,
|
||||
source_id: Optional[str] = None,
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
clauses = []
|
||||
|
||||
selected_columns = [
|
||||
@@ -1115,6 +1139,9 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N
|
||||
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))
|
||||
|
||||
@@ -1130,7 +1157,10 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N
|
||||
|
||||
|
||||
@router.get("/timeline/hourly", dependencies=[Depends(allow_any_authenticated())])
|
||||
def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
|
||||
def hourly_timeline(
|
||||
params: AppTimelineHourlyQueryParameters = Depends(),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
"""Get hourly summary for timeline."""
|
||||
cameras = params.cameras
|
||||
labels = params.labels
|
||||
@@ -1148,6 +1178,9 @@ def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
|
||||
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))
|
||||
|
||||
+68
-2
@@ -73,7 +73,6 @@ def require_admin_by_default():
|
||||
"/stats",
|
||||
"/stats/history",
|
||||
"/config",
|
||||
"/config/raw",
|
||||
"/vainfo",
|
||||
"/nvinfo",
|
||||
"/labels",
|
||||
@@ -896,6 +895,7 @@ def create_user(
|
||||
User.notification_tokens: [],
|
||||
}
|
||||
).execute()
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"username": body.username})
|
||||
|
||||
|
||||
@@ -913,6 +913,7 @@ 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})
|
||||
|
||||
|
||||
@@ -1032,6 +1033,7 @@ 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})
|
||||
|
||||
|
||||
@@ -1045,7 +1047,16 @@ async def require_camera_access(
|
||||
|
||||
current_user = await get_current_user(request)
|
||||
if isinstance(current_user, JSONResponse):
|
||||
return current_user
|
||||
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())
|
||||
@@ -1063,6 +1074,61 @@ 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)
|
||||
|
||||
+92
-5
@@ -20,9 +20,10 @@ from zeep.transports import AsyncTransport
|
||||
|
||||
from frigate.api.auth import (
|
||||
allow_any_authenticated,
|
||||
require_camera_access,
|
||||
require_go2rtc_stream_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 (
|
||||
@@ -80,14 +81,27 @@ def go2rtc_streams():
|
||||
|
||||
|
||||
@router.get(
|
||||
"/go2rtc/streams/{camera_name}", dependencies=[Depends(require_camera_access)]
|
||||
"/go2rtc/streams/{stream_name}",
|
||||
dependencies=[Depends(require_go2rtc_stream_access)],
|
||||
)
|
||||
def go2rtc_camera_stream(request: Request, camera_name: str):
|
||||
def go2rtc_camera_stream(request: Request, stream_name: str):
|
||||
r = requests.get(
|
||||
f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=allµphone"
|
||||
"http://127.0.0.1:1984/api/streams",
|
||||
params={
|
||||
"src": stream_name,
|
||||
"video": "all",
|
||||
"audio": "all",
|
||||
"microphone": "",
|
||||
},
|
||||
)
|
||||
if not r.ok:
|
||||
camera_config = request.app.frigate_config.cameras.get(camera_name)
|
||||
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
|
||||
|
||||
if camera_config and camera_config.enabled:
|
||||
logger.error("Failed to fetch streams from go2rtc")
|
||||
@@ -1155,3 +1169,76 @@ 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})
|
||||
|
||||
+445
-46
@@ -26,6 +26,11 @@ 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__)
|
||||
|
||||
@@ -82,6 +87,16 @@ 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.
|
||||
@@ -95,9 +110,11 @@ def get_tool_definitions() -> List[Dict[str, Any]]:
|
||||
"function": {
|
||||
"name": "search_objects",
|
||||
"description": (
|
||||
"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. "
|
||||
"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. "
|
||||
"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."
|
||||
@@ -140,15 +157,70 @@ 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 detection information for a camera: objects being tracked, "
|
||||
"Get the current live image and detection information for a camera: objects being tracked, "
|
||||
"zones, timestamps. Use this to understand what is visible in the live view. "
|
||||
"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."
|
||||
"Call this when answering questions about what is happening right now on a specific camera."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
@@ -162,6 +234,65 @@ 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": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -255,6 +386,7 @@ 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:
|
||||
@@ -272,6 +404,12 @@ 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,
|
||||
@@ -321,12 +459,54 @@ async def _execute_get_live_context(
|
||||
"stationary": obj_dict.get("stationary", False),
|
||||
}
|
||||
|
||||
return {
|
||||
result: Dict[str, Any] = {
|
||||
"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 {
|
||||
@@ -342,8 +522,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 when include_live_image
|
||||
is set to attach the image to the first user message.
|
||||
Returns None if the frame cannot be retrieved. Used by get_live_context
|
||||
to attach the live image to the conversation.
|
||||
"""
|
||||
if (
|
||||
camera not in allowed_cameras
|
||||
@@ -358,12 +538,12 @@ async def _get_live_frame_image_url(
|
||||
if frame is None:
|
||||
return None
|
||||
height, width = frame.shape[:2]
|
||||
max_dimension = 1024
|
||||
if height > max_dimension or width > max_dimension:
|
||||
scale = max_dimension / max(height, width)
|
||||
target_height = 480
|
||||
if height > target_height:
|
||||
scale = target_height / height
|
||||
frame = cv2.resize(
|
||||
frame,
|
||||
(int(width * scale), int(height * scale)),
|
||||
(int(width * scale), target_height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
_, img_encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
@@ -374,6 +554,57 @@ 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],
|
||||
@@ -398,6 +629,8 @@ 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:
|
||||
@@ -408,26 +641,91 @@ 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. "
|
||||
"Arguments received: %s",
|
||||
"Tool call failed: unknown tool %r. Expected one of: search_objects, get_live_context, "
|
||||
"start_camera_watch, stop_camera_watch. 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]]]:
|
||||
) -> tuple[List[ToolCall], List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Execute a list of tool calls; return (ToolCall list for API response, tool result dicts for conversation).
|
||||
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)
|
||||
"""
|
||||
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 {}
|
||||
@@ -464,6 +762,27 @@ 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))
|
||||
@@ -499,7 +818,7 @@ async def _execute_pending_tools(
|
||||
"content": error_content,
|
||||
}
|
||||
)
|
||||
return (tool_calls_out, tool_results)
|
||||
return (tool_calls_out, tool_results, extra_messages)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -555,7 +874,13 @@ async def chat_completion(
|
||||
if camera_config.friendly_name
|
||||
else camera_id.replace("_", " ").title()
|
||||
)
|
||||
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
|
||||
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_section = ""
|
||||
if cameras_info:
|
||||
@@ -565,14 +890,6 @@ 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}
|
||||
@@ -582,7 +899,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}{live_image_note}"""
|
||||
Always be accurate with time calculations based on the current date provided.{cameras_section}"""
|
||||
|
||||
conversation.append(
|
||||
{
|
||||
@@ -591,7 +908,6 @@ 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,
|
||||
@@ -602,21 +918,6 @@ 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
|
||||
@@ -674,11 +975,16 @@ Always be accurate with time calculations based on the current date provided.{ca
|
||||
msg.get("content"), pending
|
||||
)
|
||||
)
|
||||
executed_calls, tool_results = await _execute_pending_tools(
|
||||
(
|
||||
executed_calls,
|
||||
tool_results,
|
||||
extra_msgs,
|
||||
) = 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(
|
||||
{
|
||||
@@ -785,11 +1091,12 @@ 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 = await _execute_pending_tools(
|
||||
executed_calls, tool_results, extra_msgs = 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..."
|
||||
@@ -819,3 +1126,95 @@ 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)
|
||||
|
||||
@@ -338,6 +338,82 @@ 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,
|
||||
@@ -787,6 +863,101 @@ 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] = 70
|
||||
quality: Optional[int] = None
|
||||
|
||||
|
||||
class MediaMjpegFeedQueryParams(BaseModel):
|
||||
|
||||
@@ -30,10 +30,8 @@ class AppPutRoleBody(BaseModel):
|
||||
role: str
|
||||
|
||||
|
||||
class ProfileSetBody(BaseModel):
|
||||
profile: Optional[str] = Field(
|
||||
default=None, description="Profile name to activate, or null to deactivate"
|
||||
)
|
||||
class CameraSetBody(BaseModel):
|
||||
value: str = Field(..., description="The value to set for the feature")
|
||||
|
||||
|
||||
class MediaSyncBody(BaseModel):
|
||||
@@ -47,3 +45,7 @@ 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",
|
||||
)
|
||||
|
||||
@@ -32,13 +32,6 @@ 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.",
|
||||
|
||||
+16
-31
@@ -1,5 +1,6 @@
|
||||
"""Event apis."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
@@ -12,7 +13,6 @@ 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
|
||||
from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image
|
||||
from frigate.util.time import get_dst_transitions, get_tz_modifiers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1081,30 +1081,8 @@ 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:
|
||||
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)
|
||||
image, is_clean_snapshot = load_event_snapshot_image(event, clean_only=True)
|
||||
except Exception:
|
||||
logger.error(f"Unable to load clean snapshot for event: {event.id}")
|
||||
return JSONResponse(
|
||||
@@ -1114,17 +1092,22 @@ async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = N
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if image is None or image.size == 0:
|
||||
logger.error(f"Unable to load clean snapshot for event: {event.id}")
|
||||
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}")
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": "Unable to load clean snapshot for event"}
|
||||
{
|
||||
"success": False,
|
||||
"message": "Unable to find clean snapshot for event",
|
||||
}
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
plus_id = request.app.frigate_config.plus_api.upload_image(image, event.camera)
|
||||
plus_id = await asyncio.to_thread(
|
||||
request.app.frigate_config.plus_api.upload_image, image, event.camera
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.exception(ex)
|
||||
return JSONResponse(
|
||||
@@ -1140,7 +1123,8 @@ async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = N
|
||||
box = event.data["box"]
|
||||
|
||||
try:
|
||||
request.app.frigate_config.plus_api.add_annotation(
|
||||
await asyncio.to_thread(
|
||||
request.app.frigate_config.plus_api.add_annotation,
|
||||
event.plus_id,
|
||||
box,
|
||||
event.label,
|
||||
@@ -1230,7 +1214,8 @@ async def false_positive(request: Request, event_id: str):
|
||||
)
|
||||
|
||||
try:
|
||||
request.app.frigate_config.plus_api.add_false_positive(
|
||||
await asyncio.to_thread(
|
||||
request.app.frigate_config.plus_api.add_false_positive,
|
||||
event.plus_id,
|
||||
region,
|
||||
box,
|
||||
|
||||
@@ -46,6 +46,7 @@ from frigate.record.export import (
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS,
|
||||
PlaybackSourceEnum,
|
||||
RecordingExporter,
|
||||
validate_ffmpeg_args,
|
||||
)
|
||||
from frigate.util.time import is_current_hour
|
||||
|
||||
@@ -547,6 +548,24 @@ 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 = ""
|
||||
|
||||
@@ -29,11 +29,13 @@ 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
|
||||
@@ -69,8 +71,8 @@ def create_fastapi_app(
|
||||
event_metadata_updater: EventMetadataPublisher,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
replay_manager: DebugReplayManager,
|
||||
dispatcher=None,
|
||||
profile_manager=None,
|
||||
dispatcher: Optional[Dispatcher] = None,
|
||||
profile_manager: Optional[ProfileManager] = None,
|
||||
enforce_default_admin: bool = True,
|
||||
):
|
||||
logger.info("Starting FastAPI app")
|
||||
|
||||
+207
-116
@@ -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,11 +45,18 @@ 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_thumbnail_bytes
|
||||
from frigate.util.image import get_image_from_recording
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
router = APIRouter(tags=[Tags.media])
|
||||
|
||||
|
||||
@@ -110,6 +117,24 @@ 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:
|
||||
@@ -147,14 +172,7 @@ async def latest_frame(
|
||||
"paths": params.paths,
|
||||
"regions": params.regions,
|
||||
}
|
||||
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]
|
||||
quality_params = get_image_quality_params(extension.value, params.quality)
|
||||
|
||||
if camera_name in request.app.frigate_config.cameras:
|
||||
frame = frame_processor.get_current_frame(camera_name, draw_options)
|
||||
@@ -592,6 +610,33 @@ 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(
|
||||
@@ -729,7 +774,7 @@ async def vod_clip(
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}/snapshot.jpg",
|
||||
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.",
|
||||
description="Returns a snapshot image for the specified object id.",
|
||||
)
|
||||
async def event_snapshot(
|
||||
request: Request,
|
||||
@@ -748,11 +793,22 @@ async def event_snapshot(
|
||||
content={"success": False, "message": "Snapshot not available"},
|
||||
status_code=404,
|
||||
)
|
||||
# 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()
|
||||
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,
|
||||
)
|
||||
except DoesNotExist:
|
||||
# see if the object is currently being tracked
|
||||
try:
|
||||
@@ -763,13 +819,16 @@ 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=params.timestamp,
|
||||
bounding_box=params.bbox,
|
||||
crop=params.crop,
|
||||
height=params.height,
|
||||
quality=params.quality,
|
||||
timestamp=snapshot_settings["timestamp"],
|
||||
bounding_box=snapshot_settings["bounding_box"],
|
||||
crop=snapshot_settings["crop"],
|
||||
height=snapshot_settings["height"],
|
||||
quality=snapshot_settings["quality"],
|
||||
)
|
||||
await require_camera_access(camera_state.name, request=request)
|
||||
except Exception:
|
||||
@@ -807,7 +866,6 @@ async def event_snapshot(
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}/thumbnail.{extension}",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
async def event_thumbnail(
|
||||
request: Request,
|
||||
@@ -851,11 +909,12 @@ 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_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
|
||||
img = cv2.imdecode(img_as_np, flags=1)
|
||||
thumbnail = cv2.copyMakeBorder(
|
||||
img = cv2.copyMakeBorder(
|
||||
img,
|
||||
0,
|
||||
0,
|
||||
@@ -865,14 +924,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]
|
||||
|
||||
_, img = cv2.imencode(f".{extension.value}", thumbnail, quality_params)
|
||||
thumbnail_bytes = img.tobytes()
|
||||
_, encoded = cv2.imencode(f".{extension.value}", img, quality_params)
|
||||
thumbnail_bytes = encoded.tobytes()
|
||||
|
||||
return Response(
|
||||
thumbnail_bytes,
|
||||
@@ -1025,18 +1084,20 @@ def clear_region_grid(request: Request, camera_name: str):
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}/snapshot-clean.webp",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
|
||||
async 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 and clean_copy must be enabled in the config",
|
||||
"message": "Snapshots must be enabled in the config",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
@@ -1068,54 +1129,10 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False
|
||||
)
|
||||
if webp_bytes is None:
|
||||
try:
|
||||
# webp
|
||||
clean_snapshot_path_webp = os.path.join(
|
||||
CLIPS_DIR, f"{event.camera}-{event.id}-clean.webp"
|
||||
image_path, is_clean_snapshot = get_event_snapshot_path(
|
||||
event, clean_only=True
|
||||
)
|
||||
# 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:
|
||||
if not is_clean_snapshot or image_path is None:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
@@ -1123,6 +1140,34 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False
|
||||
},
|
||||
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(
|
||||
@@ -1135,7 +1180,7 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False
|
||||
|
||||
headers = {
|
||||
"Content-Type": "image/webp",
|
||||
"Cache-Control": "private, max-age=31536000",
|
||||
"Cache-Control": "private, max-age=31536000" if event_complete else "no-cache",
|
||||
}
|
||||
|
||||
if download:
|
||||
@@ -1151,7 +1196,7 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}/clip.mp4", dependencies=[Depends(require_camera_access)]
|
||||
"/events/{event_id}/clip.mp4",
|
||||
)
|
||||
async def event_clip(
|
||||
request: Request,
|
||||
@@ -1165,6 +1210,8 @@ 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
|
||||
@@ -1181,9 +1228,36 @@ async def event_clip(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}/preview.gif", dependencies=[Depends(require_camera_access)]
|
||||
"/review/{review_id}/clip.mp4",
|
||||
)
|
||||
def event_preview(request: Request, event_id: str):
|
||||
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):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
except DoesNotExist:
|
||||
@@ -1191,6 +1265,8 @@ 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
|
||||
@@ -1213,25 +1289,25 @@ def preview_gif(
|
||||
):
|
||||
if datetime.fromtimestamp(start_ts) < datetime.now().replace(minute=0, second=0):
|
||||
# has preview mp4
|
||||
preview: Previews = (
|
||||
Previews.select(
|
||||
Previews.camera,
|
||||
Previews.path,
|
||||
Previews.duration,
|
||||
Previews.start_time,
|
||||
Previews.end_time,
|
||||
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()
|
||||
)
|
||||
.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:
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Preview not found"},
|
||||
status_code=404,
|
||||
@@ -1288,9 +1364,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)):
|
||||
@@ -1470,9 +1546,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)):
|
||||
@@ -1549,8 +1625,8 @@ def preview_mp4(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/review/{event_id}/preview", dependencies=[Depends(require_camera_access)])
|
||||
def review_preview(
|
||||
@router.get("/review/{event_id}/preview")
|
||||
async def review_preview(
|
||||
request: Request,
|
||||
event_id: str,
|
||||
format: str = Query(default="gif", enum=["gif", "mp4"]),
|
||||
@@ -1563,6 +1639,8 @@ def review_preview(
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
await require_camera_access(review.camera, request=request)
|
||||
|
||||
padding = 8
|
||||
start_ts = review.start_time - padding
|
||||
end_ts = (
|
||||
@@ -1576,12 +1654,14 @@ def review_preview(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/preview/{file_name}/thumbnail.jpg", dependencies=[Depends(require_camera_access)]
|
||||
"/preview/{file_name}/thumbnail.jpg",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
@router.get(
|
||||
"/preview/{file_name}/thumbnail.webp", dependencies=[Depends(require_camera_access)]
|
||||
"/preview/{file_name}/thumbnail.webp",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
def preview_thumbnail(file_name: str):
|
||||
async def preview_thumbnail(request: Request, file_name: str):
|
||||
"""Get a thumbnail from the cached preview frames."""
|
||||
if len(file_name) > 1000:
|
||||
return JSONResponse(
|
||||
@@ -1591,6 +1671,17 @@ def preview_thumbnail(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")
|
||||
|
||||
|
||||
@@ -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)):
|
||||
|
||||
+6
-28
@@ -1,5 +1,6 @@
|
||||
"""Recording APIs."""
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from functools import reduce
|
||||
@@ -97,45 +98,22 @@ def all_recordings_summary(
|
||||
days: dict[str, bool] = {}
|
||||
|
||||
for period_start, period_end, period_offset in dst_periods:
|
||||
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"
|
||||
day_expr = ((Recordings.start_time + period_offset) / 86400).cast("int")
|
||||
|
||||
period_query = (
|
||||
Recordings.select(
|
||||
fn.strftime(
|
||||
"%Y-%m-%d",
|
||||
fn.datetime(
|
||||
Recordings.start_time,
|
||||
"unixepoch",
|
||||
period_hour_modifier,
|
||||
period_minute_modifier,
|
||||
),
|
||||
).alias("day")
|
||||
)
|
||||
Recordings.select(day_expr.alias("day_idx"))
|
||||
.where(
|
||||
(Recordings.camera << camera_list)
|
||||
& (Recordings.end_time >= period_start)
|
||||
& (Recordings.start_time <= period_end)
|
||||
)
|
||||
.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())
|
||||
.distinct()
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
for g in period_query:
|
||||
days[g.day] = True
|
||||
day_str = (dt.date(1970, 1, 1) + dt.timedelta(days=g.day_idx)).isoformat()
|
||||
days[day_str] = True
|
||||
|
||||
return JSONResponse(content=dict(sorted(days.items())))
|
||||
|
||||
|
||||
+43
-1
@@ -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 Optional
|
||||
from typing import Callable, Optional
|
||||
|
||||
import psutil
|
||||
import uvicorn
|
||||
@@ -81,6 +81,7 @@ 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
|
||||
@@ -497,6 +498,47 @@ 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:
|
||||
|
||||
+23
-22
@@ -1,26 +1,27 @@
|
||||
import multiprocessing as mp
|
||||
from multiprocessing.managers import SyncManager
|
||||
import queue
|
||||
from multiprocessing.managers import SyncManager, ValueProxy
|
||||
from multiprocessing.sharedctypes import Synchronized
|
||||
from multiprocessing.synchronize import Event
|
||||
|
||||
|
||||
class CameraMetrics:
|
||||
camera_fps: Synchronized
|
||||
detection_fps: Synchronized
|
||||
detection_frame: Synchronized
|
||||
process_fps: Synchronized
|
||||
skipped_fps: Synchronized
|
||||
read_start: Synchronized
|
||||
audio_rms: Synchronized
|
||||
audio_dBFS: Synchronized
|
||||
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]
|
||||
|
||||
frame_queue: mp.Queue
|
||||
frame_queue: queue.Queue
|
||||
|
||||
process_pid: Synchronized
|
||||
capture_process_pid: Synchronized
|
||||
ffmpeg_pid: Synchronized
|
||||
reconnects_last_hour: Synchronized
|
||||
stalls_last_hour: Synchronized
|
||||
process_pid: ValueProxy[int]
|
||||
capture_process_pid: ValueProxy[int]
|
||||
ffmpeg_pid: ValueProxy[int]
|
||||
reconnects_last_hour: ValueProxy[int]
|
||||
stalls_last_hour: ValueProxy[int]
|
||||
|
||||
def __init__(self, manager: SyncManager):
|
||||
self.camera_fps = manager.Value("d", 0)
|
||||
@@ -56,14 +57,14 @@ class PTZMetrics:
|
||||
reset: Event
|
||||
|
||||
def __init__(self, *, autotracker_enabled: bool):
|
||||
self.autotracker_enabled = mp.Value("i", autotracker_enabled)
|
||||
self.autotracker_enabled = mp.Value("i", autotracker_enabled) # 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.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.tracking_active = mp.Event()
|
||||
self.motor_stopped = mp.Event()
|
||||
|
||||
@@ -37,6 +37,9 @@ 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()
|
||||
@@ -114,7 +117,7 @@ class CameraActivityManager:
|
||||
self.last_camera_activity = new_activity
|
||||
|
||||
def compare_camera_activity(
|
||||
self, camera: str, new_activity: dict[str, Any]
|
||||
self, camera: str, new_activity: list[dict[str, Any]]
|
||||
) -> None:
|
||||
all_objects = Counter(
|
||||
obj["label"].replace("-verified", "") for obj in new_activity
|
||||
@@ -175,6 +178,9 @@ 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:
|
||||
@@ -202,7 +208,7 @@ class AudioActivityManager:
|
||||
|
||||
def compare_audio_activity(
|
||||
self, camera: str, new_detections: list[tuple[str, float]], now: float
|
||||
) -> None:
|
||||
) -> bool:
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return False
|
||||
|
||||
@@ -102,7 +102,7 @@ class CameraMaintainer(threading.Thread):
|
||||
f"recommend increasing it to at least {shm_stats['min_shm']}MB."
|
||||
)
|
||||
|
||||
return shm_stats["shm_frame_count"]
|
||||
return int(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[config.name] = camera_process
|
||||
self.camera_processes[name] = camera_process
|
||||
camera_process.start()
|
||||
self.camera_metrics[config.name].process_pid.value = camera_process.pid
|
||||
logger.info(f"Camera processor started for {config.name}: {camera_process.pid}")
|
||||
self.camera_metrics[name].process_pid.value = camera_process.pid
|
||||
logger.info(f"Camera processor started for {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):
|
||||
def run(self) -> None:
|
||||
self.__init_historical_regions()
|
||||
|
||||
# start camera processes
|
||||
|
||||
+48
-70
@@ -31,26 +31,26 @@ logger = logging.getLogger(__name__)
|
||||
class CameraState:
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
name: str,
|
||||
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 = {}
|
||||
self.zone_objects = defaultdict(list)
|
||||
self.frame_cache: dict[float, dict[str, Any]] = {}
|
||||
self.zone_objects: defaultdict[str, list[Any]] = 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 = []
|
||||
self.regions = []
|
||||
self.previous_frame_id = None
|
||||
self.callbacks = defaultdict(list)
|
||||
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.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)
|
||||
frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420) # type: ignore[assignment]
|
||||
# draw on the frame
|
||||
if draw_options.get("mask"):
|
||||
mask_overlay = np.where(self.camera_config.motion.rasterized_mask == [0])
|
||||
mask_overlay = np.where(self.camera_config.motion.rasterized_mask == [0]) # type: ignore[attr-defined]
|
||||
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"]
|
||||
].obj_data["id"] # type: ignore[attr-defined]
|
||||
and obj["frame_time"] == frame_time
|
||||
):
|
||||
thickness = 5
|
||||
@@ -109,10 +109,12 @@ 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"]
|
||||
]["max_target_box"] # type: ignore[index]
|
||||
side_length = max_target_box * (
|
||||
max(
|
||||
self.camera_config.detect.width,
|
||||
@@ -221,14 +223,14 @@ class CameraState:
|
||||
)
|
||||
|
||||
if draw_options.get("timestamp"):
|
||||
color = self.camera_config.timestamp_style.color
|
||||
ts_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=(color.blue, color.green, color.red),
|
||||
font_color=(ts_color.blue, ts_color.green, ts_color.red),
|
||||
position=self.camera_config.timestamp_style.position,
|
||||
)
|
||||
|
||||
@@ -273,10 +275,10 @@ class CameraState:
|
||||
|
||||
return frame_copy
|
||||
|
||||
def finished(self, obj_id):
|
||||
def finished(self, obj_id: str) -> None:
|
||||
del self.tracked_objects[obj_id]
|
||||
|
||||
def on(self, event_type: str, callback: Callable):
|
||||
def on(self, event_type: str, callback: Callable[..., Any]) -> None:
|
||||
self.callbacks[event_type].append(callback)
|
||||
|
||||
def update(
|
||||
@@ -286,7 +288,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
|
||||
)
|
||||
@@ -313,7 +315,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),
|
||||
"frame": np.copy(current_frame), # type: ignore[arg-type]
|
||||
"object_id": id,
|
||||
}
|
||||
|
||||
@@ -356,7 +358,8 @@ class CameraState:
|
||||
if thumb_update and current_frame is not None:
|
||||
# ensure this frame is stored in the cache
|
||||
if (
|
||||
updated_obj.thumbnail_data["frame_time"] == frame_time
|
||||
updated_obj.thumbnail_data is not None
|
||||
and updated_obj.thumbnail_data["frame_time"] == frame_time
|
||||
and frame_time not in self.frame_cache
|
||||
):
|
||||
logger.debug(
|
||||
@@ -397,7 +400,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, list[Any]] = {
|
||||
camera_activity: dict[str, Any] = {
|
||||
"motion": len(motion_boxes) > 0,
|
||||
"objects": [],
|
||||
}
|
||||
@@ -411,10 +414,7 @@ class CameraState:
|
||||
sub_label = None
|
||||
|
||||
if obj.obj_data.get("sub_label"):
|
||||
if (
|
||||
obj.obj_data.get("sub_label")[0]
|
||||
in self.config.model.all_attributes
|
||||
):
|
||||
if obj.obj_data["sub_label"][0] in self.config.model.all_attributes:
|
||||
label = obj.obj_data["sub_label"][0]
|
||||
else:
|
||||
label = f"{object_type}-verified"
|
||||
@@ -449,14 +449,19 @@ 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 (
|
||||
is_better_thumbnail(
|
||||
current_best.thumbnail_data is not None
|
||||
and obj.thumbnail_data is not None
|
||||
and is_better_thumbnail(
|
||||
object_type,
|
||||
current_best.thumbnail_data,
|
||||
obj.thumbnail_data,
|
||||
self.camera_config.frame_shape,
|
||||
)
|
||||
or (now - current_best.thumbnail_data["frame_time"])
|
||||
> self.camera_config.best_image_timeout
|
||||
or (
|
||||
current_best.thumbnail_data is not None
|
||||
and (now - current_best.thumbnail_data["frame_time"])
|
||||
> self.camera_config.best_image_timeout
|
||||
)
|
||||
):
|
||||
self.send_mqtt_snapshot(obj, object_type)
|
||||
else:
|
||||
@@ -472,7 +477,9 @@ class CameraState:
|
||||
if obj.thumbnail_data is not None
|
||||
}
|
||||
current_best_frames = {
|
||||
obj.thumbnail_data["frame_time"] for obj in self.best_objects.values()
|
||||
obj.thumbnail_data["frame_time"]
|
||||
for obj in self.best_objects.values()
|
||||
if obj.thumbnail_data is not None
|
||||
}
|
||||
thumb_frames_to_delete = [
|
||||
t
|
||||
@@ -532,53 +539,24 @@ class CameraState:
|
||||
) -> None:
|
||||
img_frame = frame if frame is not None else self.get_current_frame()
|
||||
|
||||
# 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]
|
||||
)
|
||||
ret, webp = cv2.imencode(
|
||||
".webp", img_frame, [int(cv2.IMWRITE_WEBP_QUALITY), 80]
|
||||
)
|
||||
|
||||
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())
|
||||
if ret:
|
||||
with open(
|
||||
os.path.join(
|
||||
CLIPS_DIR,
|
||||
f"{self.name}-{event_id}-clean.webp",
|
||||
),
|
||||
"wb",
|
||||
) as p:
|
||||
p.write(webp.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.camera_config.name)
|
||||
thumb_path = os.path.join(THUMB_DIR, self.name)
|
||||
os.makedirs(thumb_path, exist_ok=True)
|
||||
cv2.imwrite(os.path.join(thumb_path, f"{event_id}.webp"), thumb)
|
||||
|
||||
|
||||
+1
-11
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Callable
|
||||
@@ -39,6 +38,7 @@ class MqttClient(Communicator):
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.publish("available", "stopped", retain=True)
|
||||
self.client.disconnect()
|
||||
|
||||
def _set_initial_topics(self) -> None:
|
||||
@@ -169,16 +169,6 @@ 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(
|
||||
|
||||
+92
-11
@@ -17,6 +17,7 @@ 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,
|
||||
@@ -58,6 +59,7 @@ 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
|
||||
@@ -78,13 +80,12 @@ class WebPushClient(Communicator):
|
||||
for sub in user["notification_tokens"]:
|
||||
self.web_pushers[user["username"]].append(WebPusher(sub))
|
||||
|
||||
# notification config updater
|
||||
self.global_config_subscriber = ConfigSubscriber(
|
||||
"config/notifications", exact=True
|
||||
)
|
||||
# notification and auth config updater
|
||||
self.global_config_subscriber = ConfigSubscriber("config/")
|
||||
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."""
|
||||
@@ -164,13 +165,19 @@ 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 notification config
|
||||
_, updated_notification_config = (
|
||||
self.global_config_subscriber.check_for_update()
|
||||
)
|
||||
|
||||
if updated_notification_config:
|
||||
self.config.notifications = updated_notification_config
|
||||
# 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()
|
||||
|
||||
updates = self.config_subscriber.check_for_updates()
|
||||
|
||||
@@ -210,6 +217,15 @@ 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()
|
||||
@@ -291,6 +307,31 @@ 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:
|
||||
@@ -418,6 +459,14 @@ 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,
|
||||
@@ -465,6 +514,14 @@ 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,
|
||||
@@ -477,6 +534,30 @@ 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()
|
||||
|
||||
@@ -141,7 +141,7 @@ class CameraConfig(FrigateBaseModel):
|
||||
snapshots: SnapshotsConfig = Field(
|
||||
default_factory=SnapshotsConfig,
|
||||
title="Snapshots",
|
||||
description="Settings for saved JPEG snapshots of tracked objects for this camera.",
|
||||
description="Settings for API-generated snapshots of tracked objects for this camera.",
|
||||
)
|
||||
timestamp_style: TimestampStyleConfig = Field(
|
||||
default_factory=TimestampStyleConfig,
|
||||
|
||||
@@ -49,8 +49,8 @@ class StationaryConfig(FrigateBaseModel):
|
||||
class DetectConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
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.",
|
||||
title="Enable object detection",
|
||||
description="Enable or disable object detection for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
height: Optional[int] = Field(
|
||||
default=None,
|
||||
@@ -71,6 +71,7 @@ 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,
|
||||
|
||||
@@ -92,7 +92,7 @@ class PtzAutotrackConfig(FrigateBaseModel):
|
||||
|
||||
|
||||
class OnvifConfig(FrigateBaseModel):
|
||||
host: str = Field(
|
||||
host: EnvString = Field(
|
||||
default="",
|
||||
title="ONVIF host",
|
||||
description="Host (and optional scheme) for the ONVIF service for this camera.",
|
||||
@@ -117,6 +117,11 @@ 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",
|
||||
|
||||
@@ -188,7 +188,7 @@ class ReviewConfig(FrigateBaseModel):
|
||||
detections: DetectionsConfig = Field(
|
||||
default_factory=DetectionsConfig,
|
||||
title="Detections config",
|
||||
description="Settings for creating detection events (non-alert) and how long to keep them.",
|
||||
description="Settings for which tracked objects generate detections (non-alert) and how detections are retained.",
|
||||
)
|
||||
genai: GenAIReviewConfig = Field(
|
||||
default_factory=GenAIReviewConfig,
|
||||
|
||||
@@ -29,28 +29,23 @@ class RetainConfig(FrigateBaseModel):
|
||||
class SnapshotsConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
title="Snapshots enabled",
|
||||
title="Enable snapshots",
|
||||
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 saved snapshots.",
|
||||
description="Overlay a timestamp on snapshots from API.",
|
||||
)
|
||||
bounding_box: bool = Field(
|
||||
default=True,
|
||||
title="Bounding box overlay",
|
||||
description="Draw bounding boxes for tracked objects on saved snapshots.",
|
||||
description="Draw bounding boxes for tracked objects on snapshots from API.",
|
||||
)
|
||||
crop: bool = Field(
|
||||
default=False,
|
||||
title="Crop snapshot",
|
||||
description="Crop saved snapshots to the detected object's bounding box.",
|
||||
description="Crop snapshots from API to the detected object's bounding box.",
|
||||
)
|
||||
required_zones: list[str] = Field(
|
||||
default_factory=list,
|
||||
@@ -60,17 +55,17 @@ class SnapshotsConfig(FrigateBaseModel):
|
||||
height: Optional[int] = Field(
|
||||
default=None,
|
||||
title="Snapshot height",
|
||||
description="Height (pixels) to resize saved snapshots to; leave empty to preserve original size.",
|
||||
description="Height (pixels) to resize snapshots from API to; leave empty to preserve original size.",
|
||||
)
|
||||
retain: RetainConfig = Field(
|
||||
default_factory=RetainConfig,
|
||||
title="Snapshot retention",
|
||||
description="Retention settings for saved snapshots including default days and per-object overrides.",
|
||||
description="Retention settings for snapshots including default days and per-object overrides.",
|
||||
)
|
||||
quality: int = Field(
|
||||
default=70,
|
||||
title="JPEG quality",
|
||||
description="JPEG encode quality for saved snapshots (0-100).",
|
||||
default=60,
|
||||
title="Snapshot quality",
|
||||
description="Encode quality for saved snapshots (0-100).",
|
||||
ge=0,
|
||||
le=100,
|
||||
)
|
||||
|
||||
@@ -18,10 +18,12 @@ 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"
|
||||
@@ -30,6 +32,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
face_recognition = "face_recognition"
|
||||
lpr = "lpr"
|
||||
snapshots = "snapshots"
|
||||
timestamp_style = "timestamp_style"
|
||||
zones = "zones"
|
||||
|
||||
|
||||
@@ -107,6 +110,8 @@ 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:
|
||||
@@ -127,6 +132,10 @@ 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
|
||||
|
||||
|
||||
+31
-10
@@ -25,6 +25,7 @@ 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,
|
||||
@@ -40,7 +41,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
|
||||
from .camera.audio import AudioConfig, AudioFilterConfig
|
||||
from .camera.birdseye import BirdseyeConfig
|
||||
from .camera.detect import DetectConfig
|
||||
from .camera.ffmpeg import FfmpegConfig
|
||||
@@ -444,7 +445,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
# GenAI config (named provider configs: name -> GenAIConfig)
|
||||
genai: Dict[str, GenAIConfig] = Field(
|
||||
default_factory=dict,
|
||||
title="Generative AI configuration (named providers).",
|
||||
title="Generative AI configuration",
|
||||
description="Settings for integrated generative AI providers used to generate object descriptions and review summaries.",
|
||||
)
|
||||
|
||||
@@ -473,7 +474,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
live: CameraLiveConfig = Field(
|
||||
default_factory=CameraLiveConfig,
|
||||
title="Live playback",
|
||||
description="Settings used by the Web UI to control live stream resolution and quality.",
|
||||
description="Settings to control the jsmpeg live stream resolution and quality. This does not affect restreamed cameras that use go2rtc for live view.",
|
||||
)
|
||||
motion: Optional[MotionConfig] = Field(
|
||||
default=None,
|
||||
@@ -498,7 +499,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
snapshots: SnapshotsConfig = Field(
|
||||
default_factory=SnapshotsConfig,
|
||||
title="Snapshots",
|
||||
description="Settings for saved JPEG snapshots of tracked objects for all cameras; can be overridden per-camera.",
|
||||
description="Settings for API-generated snapshots of tracked objects for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
timestamp_style: TimestampStyleConfig = Field(
|
||||
default_factory=TimestampStyleConfig,
|
||||
@@ -613,6 +614,21 @@ 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={
|
||||
@@ -748,7 +764,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
)
|
||||
|
||||
# Default min_initialized configuration
|
||||
min_initialized = int(camera_config.detect.fps / 2)
|
||||
min_initialized = max(int(camera_config.detect.fps / 2), 2)
|
||||
if camera_config.detect.min_initialized is None:
|
||||
camera_config.detect.min_initialized = min_initialized
|
||||
|
||||
@@ -791,6 +807,16 @@ 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:
|
||||
@@ -933,11 +959,6 @@ 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())
|
||||
|
||||
|
||||
@@ -24,8 +24,10 @@ 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, v in v.items():
|
||||
os.environ[k] = v
|
||||
for k, val in v.items():
|
||||
os.environ[k] = val
|
||||
if k.startswith("FRIGATE_"):
|
||||
FRIGATE_ENV_VARS[k] = val
|
||||
|
||||
return v
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class MqttConfig(FrigateBaseModel):
|
||||
title="Enable MQTT",
|
||||
description="Enable or disable MQTT integration for state, events, and snapshots.",
|
||||
)
|
||||
host: str = Field(
|
||||
host: EnvString = 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=self.model_files.keys(),
|
||||
file_names=list(self.model_files.keys()),
|
||||
download_func=self.__download_models,
|
||||
)
|
||||
self.downloader.ensure_model_files()
|
||||
|
||||
@@ -21,7 +21,7 @@ class FaceRecognizer(ABC):
|
||||
|
||||
def __init__(self, config: FrigateConfig) -> None:
|
||||
self.config = config
|
||||
self.landmark_detector: cv2.face.FacemarkLBF = None
|
||||
self.landmark_detector: cv2.face.Facemark | None = None
|
||||
self.init_landmark_detector()
|
||||
|
||||
@abstractmethod
|
||||
@@ -38,13 +38,14 @@ class FaceRecognizer(ABC):
|
||||
def classify(self, face_image: np.ndarray) -> tuple[str, float] | None:
|
||||
pass
|
||||
|
||||
@redirect_output_to_logger(logger, logging.DEBUG)
|
||||
@redirect_output_to_logger(logger, logging.DEBUG) # type: ignore[misc]
|
||||
def init_landmark_detector(self) -> None:
|
||||
landmark_model = os.path.join(MODEL_CACHE_DIR, "facedet/landmarkdet.yaml")
|
||||
|
||||
if os.path.exists(landmark_model):
|
||||
self.landmark_detector = cv2.face.createFacemarkLBF()
|
||||
self.landmark_detector.loadModel(landmark_model)
|
||||
landmark_detector = cv2.face.createFacemarkLBF()
|
||||
landmark_detector.loadModel(landmark_model)
|
||||
self.landmark_detector = landmark_detector
|
||||
|
||||
def align_face(
|
||||
self,
|
||||
@@ -52,8 +53,10 @@ class FaceRecognizer(ABC):
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
) -> np.ndarray:
|
||||
# landmark is run on grayscale images
|
||||
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:
|
||||
@@ -131,8 +134,11 @@ class FaceRecognizer(ABC):
|
||||
|
||||
|
||||
def similarity_to_confidence(
|
||||
cosine_similarity: float, median=0.3, range_width=0.6, slope_factor=12
|
||||
):
|
||||
cosine_similarity: float,
|
||||
median: float = 0.3,
|
||||
range_width: float = 0.6,
|
||||
slope_factor: float = 12,
|
||||
) -> float:
|
||||
"""
|
||||
Default sigmoid function to map cosine similarity to confidence.
|
||||
|
||||
@@ -151,14 +157,14 @@ def similarity_to_confidence(
|
||||
bias = median
|
||||
|
||||
# Calculate confidence
|
||||
confidence = 1 / (1 + np.exp(-slope * (cosine_similarity - bias)))
|
||||
confidence: float = 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[int, np.ndarray] = {}
|
||||
self.mean_embs: dict[str, np.ndarray] = {}
|
||||
self.face_embedder: FaceNetEmbedding = FaceNetEmbedding()
|
||||
self.model_builder_queue: queue.Queue | None = None
|
||||
|
||||
@@ -168,7 +174,7 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
def run_build_task(self) -> None:
|
||||
self.model_builder_queue = queue.Queue()
|
||||
|
||||
def build_model():
|
||||
def build_model() -> None:
|
||||
face_embeddings_map: dict[str, list[np.ndarray]] = {}
|
||||
idx = 0
|
||||
|
||||
@@ -187,7 +193,7 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
img = cv2.imread(os.path.join(face_folder, image))
|
||||
|
||||
if img is None:
|
||||
continue
|
||||
continue # type: ignore[unreachable]
|
||||
|
||||
img = self.align_face(img, img.shape[1], img.shape[0])
|
||||
emb = self.face_embedder([img])[0].squeeze()
|
||||
@@ -195,12 +201,13 @@ 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):
|
||||
def build(self) -> None:
|
||||
if not self.landmark_detector:
|
||||
self.init_landmark_detector()
|
||||
return None
|
||||
@@ -226,7 +233,7 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
|
||||
logger.debug("Finished building ArcFace model")
|
||||
|
||||
def classify(self, face_image):
|
||||
def classify(self, face_image: np.ndarray) -> tuple[str, float] | None:
|
||||
if not self.landmark_detector:
|
||||
return None
|
||||
|
||||
@@ -245,7 +252,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 = 0
|
||||
score: float = 0
|
||||
label = ""
|
||||
|
||||
for name, mean_emb in self.mean_embs.items():
|
||||
@@ -268,7 +275,7 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
class ArcFaceRecognizer(FaceRecognizer):
|
||||
def __init__(self, config: FrigateConfig):
|
||||
super().__init__(config)
|
||||
self.mean_embs: dict[int, np.ndarray] = {}
|
||||
self.mean_embs: dict[str, np.ndarray] = {}
|
||||
self.face_embedder: ArcfaceEmbedding = ArcfaceEmbedding(config.face_recognition)
|
||||
self.model_builder_queue: queue.Queue | None = None
|
||||
|
||||
@@ -278,7 +285,7 @@ class ArcFaceRecognizer(FaceRecognizer):
|
||||
def run_build_task(self) -> None:
|
||||
self.model_builder_queue = queue.Queue()
|
||||
|
||||
def build_model():
|
||||
def build_model() -> None:
|
||||
face_embeddings_map: dict[str, list[np.ndarray]] = {}
|
||||
idx = 0
|
||||
|
||||
@@ -297,20 +304,21 @@ class ArcFaceRecognizer(FaceRecognizer):
|
||||
img = cv2.imread(os.path.join(face_folder, image))
|
||||
|
||||
if img is None:
|
||||
continue
|
||||
continue # type: ignore[unreachable]
|
||||
|
||||
img = self.align_face(img, img.shape[1], img.shape[0])
|
||||
emb = self.face_embedder([img])[0].squeeze()
|
||||
emb = self.face_embedder([img])[0].squeeze() # type: ignore[arg-type]
|
||||
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):
|
||||
def build(self) -> None:
|
||||
if not self.landmark_detector:
|
||||
self.init_landmark_detector()
|
||||
return None
|
||||
@@ -336,7 +344,7 @@ class ArcFaceRecognizer(FaceRecognizer):
|
||||
|
||||
logger.debug("Finished building ArcFace model")
|
||||
|
||||
def classify(self, face_image):
|
||||
def classify(self, face_image: np.ndarray) -> tuple[str, float] | None:
|
||||
if not self.landmark_detector:
|
||||
return None
|
||||
|
||||
@@ -353,9 +361,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()
|
||||
embedding = self.face_embedder([img])[0].squeeze() # type: ignore[arg-type]
|
||||
|
||||
score = 0
|
||||
score: float = 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, Optional, Tuple
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -22,19 +22,35 @@ 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:
|
||||
def __init__(self, *args, **kwargs):
|
||||
# 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:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.plate_rec_speed = InferenceSpeed(self.metrics.alpr_speed)
|
||||
self.plates_rec_second = EventsPerSecond()
|
||||
@@ -97,7 +113,7 @@ class LicensePlateProcessingMixin:
|
||||
)
|
||||
|
||||
try:
|
||||
outputs = self.model_runner.detection_model([normalized_image])[0]
|
||||
outputs = self.model_runner.detection_model([normalized_image])[0] # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LPR box detection model: {e}")
|
||||
return []
|
||||
@@ -105,18 +121,18 @@ class LicensePlateProcessingMixin:
|
||||
outputs = outputs[0, :, :]
|
||||
|
||||
if False:
|
||||
current_time = int(datetime.datetime.now().timestamp())
|
||||
current_time = int(datetime.datetime.now().timestamp()) # type: ignore[unreachable]
|
||||
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))
|
||||
return self._filter_polygon(boxes, (h, w)) # type: ignore[return-value,arg-type]
|
||||
|
||||
def _classify(
|
||||
self, images: List[np.ndarray]
|
||||
) -> Tuple[List[np.ndarray], List[Tuple[str, float]]]:
|
||||
) -> Tuple[List[np.ndarray], List[Tuple[str, float]]] | None:
|
||||
"""
|
||||
Classify the orientation or category of each detected license plate.
|
||||
|
||||
@@ -138,15 +154,15 @@ class LicensePlateProcessingMixin:
|
||||
norm_images.append(norm_img)
|
||||
|
||||
try:
|
||||
outputs = self.model_runner.classification_model(norm_images)
|
||||
outputs = self.model_runner.classification_model(norm_images) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LPR classification model: {e}")
|
||||
return
|
||||
return None
|
||||
|
||||
return self._process_classification_output(images, outputs)
|
||||
|
||||
def _recognize(
|
||||
self, camera: string, images: List[np.ndarray]
|
||||
self, camera: str, images: List[np.ndarray]
|
||||
) -> Tuple[List[str], List[List[float]]]:
|
||||
"""
|
||||
Recognize the characters on the detected license plates using the recognition model.
|
||||
@@ -179,7 +195,7 @@ class LicensePlateProcessingMixin:
|
||||
norm_images.append(norm_image)
|
||||
|
||||
try:
|
||||
outputs = self.model_runner.recognition_model(norm_images)
|
||||
outputs = self.model_runner.recognition_model(norm_images) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LPR recognition model: {e}")
|
||||
return [], []
|
||||
@@ -410,7 +426,8 @@ class LicensePlateProcessingMixin:
|
||||
)
|
||||
|
||||
if sorted_data:
|
||||
return map(list, zip(*sorted_data))
|
||||
plates, confs, areas_list = zip(*sorted_data)
|
||||
return list(plates), list(confs), list(areas_list)
|
||||
|
||||
return [], [], []
|
||||
|
||||
@@ -532,7 +549,7 @@ class LicensePlateProcessingMixin:
|
||||
# Add the last box
|
||||
merged_boxes.append(current_box)
|
||||
|
||||
return np.array(merged_boxes, dtype=np.int32)
|
||||
return np.array(merged_boxes, dtype=np.int32) # type: ignore[return-value]
|
||||
|
||||
def _boxes_from_bitmap(
|
||||
self, output: np.ndarray, mask: np.ndarray, dest_width: int, dest_height: int
|
||||
@@ -560,38 +577,42 @@ class LicensePlateProcessingMixin:
|
||||
boxes = []
|
||||
scores = []
|
||||
|
||||
for index in range(len(contours)):
|
||||
contour = contours[index]
|
||||
for index in range(len(contours)): # type: ignore[arg-type]
|
||||
contour = contours[index] # type: ignore[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)
|
||||
points = np.array(points, dtype=np.float32) # type: ignore[assignment]
|
||||
|
||||
score = self._box_score(output, contour)
|
||||
if self.box_thresh > score:
|
||||
continue
|
||||
|
||||
points = self._expand_box(points)
|
||||
points = self._expand_box(points) # type: ignore[assignment]
|
||||
|
||||
# Get the minimum area rectangle again after expansion
|
||||
points, sside = self._get_min_boxes(points.reshape(-1, 1, 2))
|
||||
points, sside = self._get_min_boxes(points.reshape(-1, 1, 2)) # type: ignore[attr-defined]
|
||||
if sside < self.min_size + 2:
|
||||
continue
|
||||
|
||||
points = np.array(points, dtype=np.float32)
|
||||
points = np.array(points, dtype=np.float32) # type: ignore[assignment]
|
||||
|
||||
# normalize and clip box coordinates to fit within the destination image size.
|
||||
points[:, 0] = np.clip(
|
||||
np.round(points[:, 0] / width * dest_width), 0, dest_width
|
||||
points[:, 0] = np.clip( # type: ignore[call-overload]
|
||||
np.round(points[:, 0] / width * dest_width), # type: ignore[call-overload]
|
||||
0,
|
||||
dest_width,
|
||||
)
|
||||
points[:, 1] = np.clip(
|
||||
np.round(points[:, 1] / height * dest_height), 0, dest_height
|
||||
points[:, 1] = np.clip( # type: ignore[call-overload]
|
||||
np.round(points[:, 1] / height * dest_height), # type: ignore[call-overload]
|
||||
0,
|
||||
dest_height,
|
||||
)
|
||||
|
||||
boxes.append(points.astype("int32"))
|
||||
boxes.append(points.astype("int32")) # type: ignore[attr-defined]
|
||||
scores.append(score)
|
||||
|
||||
return np.array(boxes, dtype="int32"), scores
|
||||
@@ -632,7 +653,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)
|
||||
cv2.fillPoly(mask, [contour - [x1, y1]], 1) # type: ignore[call-overload]
|
||||
return cv2.mean(bitmap[y1 : y2 + 1, x1 : x2 + 1], mask)[0]
|
||||
|
||||
@staticmethod
|
||||
@@ -690,7 +711,7 @@ class LicensePlateProcessingMixin:
|
||||
Returns:
|
||||
bool: Whether the polygon is valid or not.
|
||||
"""
|
||||
return (
|
||||
return bool(
|
||||
point[:, 0].min() >= 0
|
||||
and point[:, 0].max() < width
|
||||
and point[:, 1].min() >= 0
|
||||
@@ -735,7 +756,7 @@ class LicensePlateProcessingMixin:
|
||||
return np.array([tl, tr, br, bl])
|
||||
|
||||
@staticmethod
|
||||
def _sort_boxes(boxes):
|
||||
def _sort_boxes(boxes: list[np.ndarray]) -> list[np.ndarray]:
|
||||
"""
|
||||
Sort polygons based on their position in the image. If boxes are close in vertical
|
||||
position (within 5 pixels), sort them by horizontal position.
|
||||
@@ -837,16 +858,16 @@ class LicensePlateProcessingMixin:
|
||||
results = [["", 0.0]] * len(images)
|
||||
indices = np.argsort(np.array([x.shape[1] / x.shape[0] for x in images]))
|
||||
|
||||
outputs = np.stack(outputs)
|
||||
stacked_outputs = np.stack(outputs)
|
||||
|
||||
outputs = [
|
||||
(labels[idx], outputs[i, idx])
|
||||
for i, idx in enumerate(outputs.argmax(axis=1))
|
||||
stacked_outputs = [
|
||||
(labels[idx], stacked_outputs[i, idx])
|
||||
for i, idx in enumerate(stacked_outputs.argmax(axis=1))
|
||||
]
|
||||
|
||||
for i in range(0, len(images), self.batch_size):
|
||||
for j in range(len(outputs)):
|
||||
label, score = outputs[j]
|
||||
for j in range(len(stacked_outputs)):
|
||||
label, score = stacked_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:
|
||||
@@ -854,10 +875,10 @@ class LicensePlateProcessingMixin:
|
||||
images[indices[i + j]], cv2.ROTATE_180
|
||||
)
|
||||
|
||||
return images, results
|
||||
return images, results # type: ignore[return-value]
|
||||
|
||||
def _preprocess_recognition_image(
|
||||
self, camera: string, image: np.ndarray, max_wh_ratio: float
|
||||
self, camera: str, image: np.ndarray, max_wh_ratio: float
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Preprocess an image for recognition by dynamically adjusting its width.
|
||||
@@ -925,7 +946,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()
|
||||
model_input_w = self.model_runner.recognition_model.runner.get_input_width() # type: ignore[union-attr]
|
||||
if isinstance(model_input_w, int) and model_input_w > 0:
|
||||
input_w = model_input_w
|
||||
|
||||
@@ -945,7 +966,7 @@ class LicensePlateProcessingMixin:
|
||||
padded_image[:, :, :resized_w] = resized_image
|
||||
|
||||
if False:
|
||||
current_time = int(datetime.datetime.now().timestamp() * 1000)
|
||||
current_time = int(datetime.datetime.now().timestamp() * 1000) # type: ignore[unreachable]
|
||||
cv2.imwrite(
|
||||
f"debug/frames/preprocessed_recognition_{current_time}.jpg",
|
||||
image,
|
||||
@@ -983,8 +1004,9 @@ class LicensePlateProcessingMixin:
|
||||
np.linalg.norm(points[1] - points[2]),
|
||||
)
|
||||
)
|
||||
pts_std = np.float32(
|
||||
[[0, 0], [crop_width, 0], [crop_width, crop_height], [0, crop_height]]
|
||||
pts_std = np.array(
|
||||
[[0, 0], [crop_width, 0], [crop_width, crop_height], [0, crop_height]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
matrix = cv2.getPerspectiveTransform(points, pts_std)
|
||||
image = cv2.warpPerspective(
|
||||
@@ -1000,15 +1022,15 @@ class LicensePlateProcessingMixin:
|
||||
return image
|
||||
|
||||
def _detect_license_plate(
|
||||
self, camera: string, input: np.ndarray
|
||||
) -> tuple[int, int, int, int]:
|
||||
self, camera: str, input: np.ndarray
|
||||
) -> tuple[int, int, int, int] | None:
|
||||
"""
|
||||
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)
|
||||
predictions = self.model_runner.yolov9_detection_model(input) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running YOLOv9 license plate detection model: {e}")
|
||||
return None
|
||||
@@ -1073,7 +1095,7 @@ class LicensePlateProcessingMixin:
|
||||
logger.debug(
|
||||
f"{camera}: Found license plate. Bounding box: {expanded_box.astype(int)}"
|
||||
)
|
||||
return tuple(expanded_box.astype(int))
|
||||
return tuple(expanded_box.astype(int)) # type: ignore[return-value]
|
||||
else:
|
||||
return None # No detection above the threshold
|
||||
|
||||
@@ -1097,7 +1119,7 @@ class LicensePlateProcessingMixin:
|
||||
f" Variant {i + 1}: '{p['plate']}' (conf: {p['conf']:.3f}, area: {p['area']})"
|
||||
)
|
||||
|
||||
clusters = []
|
||||
clusters: list[list[dict[str, Any]]] = []
|
||||
for i, plate in enumerate(plates):
|
||||
merged = False
|
||||
for j, cluster in enumerate(clusters):
|
||||
@@ -1132,7 +1154,7 @@ class LicensePlateProcessingMixin:
|
||||
)
|
||||
|
||||
# Best cluster: largest size, tiebroken by max conf
|
||||
def cluster_score(c):
|
||||
def cluster_score(c: list[dict[str, Any]]) -> tuple[int, float]:
|
||||
return (len(c), max(v["conf"] for v in c))
|
||||
|
||||
best_cluster_idx = max(
|
||||
@@ -1178,7 +1200,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()
|
||||
@@ -1195,7 +1217,7 @@ class LicensePlateProcessingMixin:
|
||||
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
|
||||
|
||||
# apply motion mask
|
||||
rgb[self.config.cameras[obj_data].motion.rasterized_mask == 0] = [0, 0, 0]
|
||||
rgb[self.config.cameras[camera].motion.rasterized_mask == 0] = [0, 0, 0] # type: ignore[attr-defined]
|
||||
|
||||
if WRITE_DEBUG_IMAGES:
|
||||
cv2.imwrite(
|
||||
@@ -1261,7 +1283,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)"
|
||||
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]
|
||||
)
|
||||
return
|
||||
|
||||
@@ -1288,7 +1310,7 @@ class LicensePlateProcessingMixin:
|
||||
if time_since_stationary > self.stationary_scan_duration:
|
||||
return
|
||||
|
||||
license_plate: Optional[dict[str, Any]] = None
|
||||
license_plate = None
|
||||
|
||||
if "license_plate" not in self.config.cameras[camera].objects.track:
|
||||
logger.debug(f"{camera}: Running manual license_plate detection.")
|
||||
@@ -1301,7 +1323,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]
|
||||
rgb[self.config.cameras[camera].motion.rasterized_mask == 0] = [0, 0, 0] # type: ignore[attr-defined]
|
||||
|
||||
left, top, right, bottom = car_box
|
||||
car = rgb[top:bottom, left:right]
|
||||
@@ -1378,10 +1400,10 @@ class LicensePlateProcessingMixin:
|
||||
if attr.get("label") != "license_plate":
|
||||
continue
|
||||
|
||||
if license_plate is None or attr.get(
|
||||
if license_plate is None or attr.get( # type: ignore[unreachable]
|
||||
"score", 0.0
|
||||
) > license_plate.get("score", 0.0):
|
||||
license_plate = attr
|
||||
license_plate = attr # type: ignore[assignment]
|
||||
|
||||
# no license plates detected in this frame
|
||||
if not license_plate:
|
||||
@@ -1389,9 +1411,9 @@ class LicensePlateProcessingMixin:
|
||||
|
||||
# we are using dedicated lpr with frigate+
|
||||
if obj_data.get("label") == "license_plate":
|
||||
license_plate = obj_data
|
||||
license_plate = obj_data # type: ignore[assignment]
|
||||
|
||||
license_plate_box = license_plate.get("box")
|
||||
license_plate_box = license_plate.get("box") # type: ignore[attr-defined]
|
||||
|
||||
# check that license plate is valid
|
||||
if (
|
||||
@@ -1420,7 +1442,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)
|
||||
plate_box = tuple(int(x) for x in expanded_box) # type: ignore[assignment]
|
||||
|
||||
# Crop using the expanded box
|
||||
license_plate_frame = license_plate_frame[
|
||||
@@ -1596,7 +1618,7 @@ class LicensePlateProcessingMixin:
|
||||
sub_label = next(
|
||||
(
|
||||
label
|
||||
for label, plates_list in self.lpr_config.known_plates.items()
|
||||
for label, plates_list in self.lpr_config.known_plates.items() # type: ignore[union-attr]
|
||||
if any(
|
||||
re.match(f"^{plate}$", rep_plate)
|
||||
or Levenshtein.distance(plate, rep_plate)
|
||||
@@ -1649,14 +1671,16 @@ 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).decode("ASCII"), id, camera),
|
||||
(base64.b64encode(encoded_img.tobytes()).decode("ASCII"), id, camera),
|
||||
EventMetadataTypeEnum.save_lpr_snapshot.value,
|
||||
)
|
||||
|
||||
def handle_request(self, topic, request_data) -> dict[str, Any] | None:
|
||||
return
|
||||
def handle_request(
|
||||
self, topic: str, request_data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
def lpr_expire(self, object_id: str, camera: str):
|
||||
def lpr_expire(self, object_id: str, camera: str) -> None:
|
||||
if object_id in self.detected_license_plates:
|
||||
self.detected_license_plates.pop(object_id)
|
||||
|
||||
@@ -1673,7 +1697,7 @@ class CTCDecoder:
|
||||
for each decoded character sequence.
|
||||
"""
|
||||
|
||||
def __init__(self, character_dict_path=None):
|
||||
def __init__(self, character_dict_path: str | None = None) -> None:
|
||||
"""
|
||||
Initializes the CTCDecoder.
|
||||
:param character_dict_path: Path to the character dictionary file.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.embeddings.onnx.lpr_embedding import (
|
||||
LicensePlateDetector,
|
||||
PaddleOCRClassification,
|
||||
@@ -9,7 +10,12 @@ from ...types import DataProcessorModelRunner
|
||||
|
||||
|
||||
class LicensePlateModelRunner(DataProcessorModelRunner):
|
||||
def __init__(self, requestor, device: str = "CPU", model_size: str = "small"):
|
||||
def __init__(
|
||||
self,
|
||||
requestor: InterProcessRequestor,
|
||||
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
|
||||
|
||||
@@ -17,7 +17,7 @@ class PostProcessorApi(ABC):
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
metrics: DataProcessorMetrics,
|
||||
model_runner: DataProcessorModelRunner,
|
||||
model_runner: DataProcessorModelRunner | None,
|
||||
) -> 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] | None:
|
||||
) -> dict[str, Any] | str | 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 Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from peewee import DoesNotExist
|
||||
|
||||
@@ -17,6 +17,7 @@ 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
|
||||
|
||||
@@ -31,7 +32,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
requestor: InterProcessRequestor,
|
||||
embeddings,
|
||||
embeddings: Embeddings,
|
||||
metrics: DataProcessorMetrics,
|
||||
):
|
||||
super().__init__(config, metrics, None)
|
||||
@@ -40,7 +41,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
self.embeddings = embeddings
|
||||
self.recognizer = None
|
||||
self.transcription_lock = threading.Lock()
|
||||
self.transcription_thread = None
|
||||
self.transcription_thread: threading.Thread | None = None
|
||||
self.transcription_running = False
|
||||
|
||||
# faster-whisper handles model downloading automatically
|
||||
@@ -69,7 +70,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.
|
||||
|
||||
@@ -141,13 +142,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[tuple[str, float]]:
|
||||
def __transcribe_audio(self, audio_data: bytes) -> Optional[str]:
|
||||
"""Transcribe WAV audio data using faster-whisper."""
|
||||
if not self.recognizer:
|
||||
logger.debug("Recognizer not initialized")
|
||||
return None
|
||||
|
||||
try:
|
||||
try: # type: ignore[unreachable]
|
||||
# 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:
|
||||
@@ -176,7 +177,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(
|
||||
@@ -194,7 +195,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):
|
||||
class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): # type: ignore[misc]
|
||||
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"]
|
||||
event_id = data["event_id"] # type: ignore[unreachable]
|
||||
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, request_data) -> dict[str, Any] | None:
|
||||
def handle_request(self, topic: str, request_data: dict) -> dict[str, Any] | None:
|
||||
if topic == EmbeddingsRequestEnum.reprocess_plate.value:
|
||||
event = request_data["event"]
|
||||
|
||||
@@ -242,3 +242,5 @@ 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
|
||||
from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image
|
||||
from frigate.util.image import create_thumbnail, ensure_jpeg_bytes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.embeddings import Embeddings
|
||||
from frigate.embeddings.embeddings import Embeddings
|
||||
|
||||
from ..post.api import PostProcessorApi
|
||||
from ..types import DataProcessorMetrics
|
||||
@@ -103,16 +103,19 @@ 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,
|
||||
[
|
||||
data["thumbnail"]
|
||||
for data in self.tracked_events[data["id"]]
|
||||
],
|
||||
thumbnails_copy,
|
||||
),
|
||||
).start()
|
||||
|
||||
@@ -136,7 +139,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
):
|
||||
self._process_genai_description(event, camera_config, thumbnail)
|
||||
else:
|
||||
self.cleanup_event(event.id)
|
||||
self.cleanup_event(str(event.id))
|
||||
|
||||
def __regenerate_description(self, event_id: str, source: str, force: bool) -> None:
|
||||
"""Regenerate the description for an event."""
|
||||
@@ -146,17 +149,17 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
logger.error(f"Event {event_id} not found for description regeneration")
|
||||
return
|
||||
|
||||
if self.genai_client is None:
|
||||
logger.error("GenAI not enabled")
|
||||
return
|
||||
|
||||
camera_config = self.config.cameras[event.camera]
|
||||
camera_config = self.config.cameras[str(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)
|
||||
|
||||
@@ -172,14 +175,21 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
embed_image = (
|
||||
[snapshot_image]
|
||||
if event.has_snapshot and source == "snapshot"
|
||||
# Copy thumbnails to avoid holding references
|
||||
else (
|
||||
[data["thumbnail"] for data in self.tracked_events[event_id]]
|
||||
[
|
||||
data["thumbnail"][:] if data.get("thumbnail") else None
|
||||
for data in self.tracked_events[event_id]
|
||||
if data.get("thumbnail")
|
||||
]
|
||||
if len(self.tracked_events.get(event_id, [])) > 0
|
||||
else [thumbnail]
|
||||
)
|
||||
)
|
||||
|
||||
self._genai_embed_description(event, embed_image)
|
||||
self._genai_embed_description(
|
||||
event, [img for img in embed_image if img is not None]
|
||||
)
|
||||
|
||||
def process_data(self, frame_data: dict, data_type: PostProcessDataEnum) -> None:
|
||||
"""Process a frame update."""
|
||||
@@ -224,51 +234,42 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
def _read_and_crop_snapshot(self, event: Event) -> bytes | None:
|
||||
"""Read, decode, and crop the snapshot image."""
|
||||
|
||||
snapshot_file = os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg")
|
||||
|
||||
if not os.path.isfile(snapshot_file):
|
||||
logger.error(
|
||||
f"Cannot load snapshot for {event.id}, file not found: {snapshot_file}"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(snapshot_file, "rb") as image_file:
|
||||
snapshot_image = image_file.read()
|
||||
img, _ = load_event_snapshot_image(event)
|
||||
if img is None:
|
||||
logger.error(f"Cannot load snapshot for {event.id}, file not found")
|
||||
return None
|
||||
|
||||
img = cv2.imdecode(
|
||||
np.frombuffer(snapshot_image, dtype=np.int8),
|
||||
cv2.IMREAD_COLOR,
|
||||
)
|
||||
# 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]
|
||||
)
|
||||
x1, y1 = int(x1_rel * width), int(y1_rel * height)
|
||||
|
||||
# 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),
|
||||
]
|
||||
|
||||
cropped_image = img[
|
||||
y1 : y1 + int(height_rel * height),
|
||||
x1 : x1 + int(width_rel * width),
|
||||
]
|
||||
_, buffer = cv2.imencode(".jpg", cropped_image)
|
||||
|
||||
_, buffer = cv2.imencode(".jpg", cropped_image)
|
||||
|
||||
return buffer.tobytes()
|
||||
return buffer.tobytes()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _process_genai_description(
|
||||
self, event: Event, camera_config: CameraConfig, thumbnail
|
||||
self, event: Event, camera_config: CameraConfig, thumbnail: bytes
|
||||
) -> 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)
|
||||
@@ -276,30 +277,35 @@ 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"] for data in self.tracked_events[event.id]]
|
||||
[
|
||||
data["thumbnail"][:] if data.get("thumbnail") else None
|
||||
for data in self.tracked_events[event_id]
|
||||
if data.get("thumbnail")
|
||||
]
|
||||
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:
|
||||
@@ -308,7 +314,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,
|
||||
@@ -317,12 +323,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[event.camera]
|
||||
camera_config = self.config.cameras[str(event.camera)]
|
||||
description = self.genai_client.generate_object_description(
|
||||
camera_config, thumbnails, event
|
||||
)
|
||||
@@ -344,7 +350,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
|
||||
# Embed the description
|
||||
if self.config.semantic_search.enabled:
|
||||
self.embeddings.embed_description(event.id, description)
|
||||
self.embeddings.embed_description(str(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_descs_dps = EventsPerSecond()
|
||||
self.review_descs_dps.start()
|
||||
self.review_desc_dps = EventsPerSecond()
|
||||
self.review_desc_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 (ultrawide aspect ratios) use more tokens.
|
||||
Token usage varies by resolution: larger images (ultra-wide aspect ratios) use more tokens.
|
||||
Estimates ~1 token per 1250 pixels. Targets 98% context utilization with safety margin.
|
||||
Capped at 20 frames.
|
||||
"""
|
||||
@@ -68,7 +68,11 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
|
||||
detect_width = camera_config.detect.width
|
||||
detect_height = camera_config.detect.height
|
||||
aspect_ratio = detect_width / detect_height
|
||||
|
||||
if not detect_width or not detect_height:
|
||||
aspect_ratio = 16 / 9
|
||||
else:
|
||||
aspect_ratio = detect_width / detect_height
|
||||
|
||||
if image_source == ImageSourceEnum.recordings:
|
||||
if aspect_ratio >= 1:
|
||||
@@ -99,8 +103,10 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
|
||||
return min(max(max_frames, 3), 20)
|
||||
|
||||
def process_data(self, data, data_type):
|
||||
self.metrics.review_desc_dps.value = self.review_descs_dps.eps()
|
||||
def process_data(
|
||||
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
||||
) -> None:
|
||||
self.metrics.review_desc_dps.value = self.review_desc_dps.eps()
|
||||
|
||||
if data_type != PostProcessDataEnum.review:
|
||||
return
|
||||
@@ -186,7 +192,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
)
|
||||
|
||||
# kickoff analysis
|
||||
self.review_descs_dps.update()
|
||||
self.review_desc_dps.update()
|
||||
threading.Thread(
|
||||
target=run_analysis,
|
||||
args=(
|
||||
@@ -202,7 +208,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
),
|
||||
).start()
|
||||
|
||||
def handle_request(self, topic, request_data):
|
||||
def handle_request(self, topic: str, request_data: dict[str, Any]) -> str | None:
|
||||
if topic == EmbeddingsRequestEnum.summarize_review.value:
|
||||
start_ts = request_data["start_ts"]
|
||||
end_ts = request_data["end_ts"]
|
||||
@@ -324,10 +330,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 = []
|
||||
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] = []
|
||||
|
||||
for file in sorted(os.listdir(preview_dir)):
|
||||
if not file.startswith(file_start):
|
||||
@@ -463,6 +469,13 @@ 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]
|
||||
)
|
||||
@@ -481,13 +494,12 @@ 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, str],
|
||||
final_data: dict[str, Any],
|
||||
thumbs: list[bytes],
|
||||
genai_config: GenAIReviewConfig,
|
||||
labelmap_objects: list[str],
|
||||
@@ -521,7 +533,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,6 +19,7 @@ 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
|
||||
@@ -40,8 +41,8 @@ class SemanticTriggerProcessor(PostProcessorApi):
|
||||
requestor: InterProcessRequestor,
|
||||
sub_label_publisher: EventMetadataPublisher,
|
||||
metrics: DataProcessorMetrics,
|
||||
embeddings,
|
||||
):
|
||||
embeddings: Embeddings,
|
||||
) -> None:
|
||||
super().__init__(config, metrics, None)
|
||||
self.db = db
|
||||
self.embeddings = embeddings
|
||||
@@ -236,11 +237,14 @@ class SemanticTriggerProcessor(PostProcessorApi):
|
||||
return
|
||||
|
||||
# Skip the event if not an object
|
||||
if event.data.get("type") != "object":
|
||||
if event.data.get("type") != "object": # type: ignore[attr-defined]
|
||||
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)
|
||||
|
||||
@@ -262,8 +266,10 @@ class SemanticTriggerProcessor(PostProcessorApi):
|
||||
thumbnail,
|
||||
)
|
||||
|
||||
def handle_request(self, topic, request_data):
|
||||
def handle_request(
|
||||
self, topic: str, request_data: dict[str, Any]
|
||||
) -> dict[str, Any] | str | None:
|
||||
return None
|
||||
|
||||
def expire_object(self, object_id, camera):
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
pass
|
||||
|
||||
@@ -4,20 +4,23 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
class ReviewMetadata(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", protected_namespaces=())
|
||||
|
||||
title: str = Field(description="A concise title for the activity.")
|
||||
title: str = Field(
|
||||
description="A short title characterizing what took place and where, under 10 words."
|
||||
)
|
||||
scene: str = Field(
|
||||
description="A comprehensive description of the setting and entities, including relevant context and plausible inferences if supported by visual evidence."
|
||||
description="A chronological narrative of what happens from start to finish."
|
||||
)
|
||||
shortSummary: str = Field(
|
||||
description="A brief 2-sentence summary of the scene, suitable for notifications. Should capture the key activity and context without full detail."
|
||||
description="A brief 2-sentence summary of the scene, suitable for notifications."
|
||||
)
|
||||
confidence: float = Field(
|
||||
description="A float between 0 and 1 representing your overall confidence in this analysis."
|
||||
ge=0.0,
|
||||
description="Confidence in the analysis, from 0 to 1.",
|
||||
)
|
||||
potential_threat_level: int = Field(
|
||||
ge=0,
|
||||
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.",
|
||||
le=2,
|
||||
description="Threat level: 0 = normal, 1 = suspicious, 2 = critical threat.",
|
||||
)
|
||||
other_concerns: list[str] | None = Field(
|
||||
default=None,
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from typing import Optional
|
||||
from typing import Any, 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 = None
|
||||
self.whisper_model = None
|
||||
self.stream: Any = None
|
||||
self.whisper_model: FasterWhisperASR | None = None
|
||||
self.model_runner = model_runner
|
||||
self.transcription_segments = []
|
||||
self.audio_queue = queue.Queue()
|
||||
self.transcription_segments: list[str] = []
|
||||
self.audio_queue: queue.Queue[tuple[dict[str, Any], np.ndarray]] = 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) -> None:
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
pass
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user