mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-29 07:09:03 +03:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1769dac5e | ||
|
|
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 |
@@ -1,6 +1,7 @@
|
||||
_Please read the [contributing guidelines](https://github.com/blakeblackshear/frigate/blob/dev/CONTRIBUTING.md) before submitting a PR._
|
||||
|
||||
## Proposed change
|
||||
|
||||
<!--
|
||||
Thank you!
|
||||
|
||||
@@ -25,6 +26,7 @@ _Please read the [contributing guidelines](https://github.com/blakeblackshear/fr
|
||||
|
||||
- This PR fixes or closes issue: fixes #
|
||||
- This PR is related to issue:
|
||||
- Link to discussion with maintainers (**required** for large/pinned features):
|
||||
|
||||
## For new features
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -102,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -951,7 +951,7 @@ 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 user, and password can be specified with environment variables or docker secrets
|
||||
# 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).
|
||||
@@ -966,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:
|
||||
|
||||
@@ -205,7 +205,7 @@ Inference is done with the `onnx` detector type. Speeds will vary greatly depend
|
||||
| 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: ~ 6 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 |
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ id: installation
|
||||
title: Installation
|
||||
---
|
||||
|
||||
import ShmCalculator from '@site/src/components/ShmCalculator'
|
||||
|
||||
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App.
|
||||
|
||||
:::tip
|
||||
@@ -77,20 +79,7 @@ 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:
|
||||
|
||||
```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
|
||||
```
|
||||
<ShmCalculator/>
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Generated
+6
-6
@@ -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": {
|
||||
@@ -15952,9 +15952,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
|
||||
"integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.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;
|
||||
}
|
||||
Vendored
+5
@@ -6891,6 +6891,11 @@ components:
|
||||
title: Force
|
||||
description: "If True, bypass safety threshold checks"
|
||||
default: false
|
||||
verbose:
|
||||
type: boolean
|
||||
title: Verbose
|
||||
description: "If True, write full orphan file list to /config/media_sync/<job_id>.txt"
|
||||
default: false
|
||||
type: object
|
||||
title: MediaSyncBody
|
||||
MotionSearchMetricsResponse:
|
||||
|
||||
+52
-13
@@ -5,6 +5,7 @@ import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import traceback
|
||||
import urllib
|
||||
from datetime import datetime, timedelta
|
||||
@@ -246,19 +247,26 @@ def get_active_profile(request: Request):
|
||||
@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",
|
||||
@@ -605,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)
|
||||
@@ -864,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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -45,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",
|
||||
)
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
+33
-6
@@ -1227,6 +1227,33 @@ async def event_clip(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/review/{review_id}/clip.mp4",
|
||||
)
|
||||
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",
|
||||
)
|
||||
@@ -1337,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)):
|
||||
@@ -1519,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)):
|
||||
|
||||
@@ -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)):
|
||||
|
||||
+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
|
||||
|
||||
+37
-30
@@ -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
|
||||
@@ -540,7 +547,7 @@ class CameraState:
|
||||
with open(
|
||||
os.path.join(
|
||||
CLIPS_DIR,
|
||||
f"{self.camera_config.name}-{event_id}-clean.webp",
|
||||
f"{self.name}-{event_id}-clean.webp",
|
||||
),
|
||||
"wb",
|
||||
) as p:
|
||||
@@ -549,7 +556,7 @@ class CameraState:
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -542,9 +542,9 @@ class WebPushClient(Communicator):
|
||||
|
||||
self.check_registrations()
|
||||
|
||||
reasoning: str = payload.get("reasoning", "")
|
||||
text: str = payload.get("message") or payload.get("reasoning", "")
|
||||
title = f"{camera_name}: Monitoring Alert"
|
||||
message = (reasoning[:197] + "...") if len(reasoning) > 200 else reasoning
|
||||
message = (text[:197] + "...") if len(text) > 200 else text
|
||||
|
||||
logger.debug(f"Sending camera monitoring push notification for {camera_name}")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,6 +23,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
notifications = "notifications"
|
||||
objects = "objects"
|
||||
object_genai = "object_genai"
|
||||
onvif = "onvif"
|
||||
record = "record"
|
||||
remove = "remove" # for removing a camera
|
||||
review = "review"
|
||||
@@ -31,6 +32,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
face_recognition = "face_recognition"
|
||||
lpr = "lpr"
|
||||
snapshots = "snapshots"
|
||||
timestamp_style = "timestamp_style"
|
||||
zones = "zones"
|
||||
|
||||
|
||||
@@ -130,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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,7 +24,7 @@ from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_ima
|
||||
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
|
||||
@@ -139,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."""
|
||||
@@ -149,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)
|
||||
|
||||
@@ -187,7 +187,9 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
)
|
||||
)
|
||||
|
||||
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."""
|
||||
@@ -241,7 +243,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
# 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(
|
||||
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)
|
||||
@@ -258,14 +260,16 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
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)
|
||||
@@ -277,7 +281,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
else (
|
||||
[
|
||||
data["thumbnail"][:] if data.get("thumbnail") else None
|
||||
for data in self.tracked_events[event.id]
|
||||
for data in self.tracked_events[event_id]
|
||||
if data.get("thumbnail")
|
||||
]
|
||||
if num_thumbnails > 0
|
||||
@@ -286,22 +290,22 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -310,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,
|
||||
@@ -319,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
|
||||
)
|
||||
@@ -346,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):
|
||||
@@ -465,7 +471,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
thumb_data = cv2.imread(thumb_path)
|
||||
|
||||
if thumb_data is None:
|
||||
logger.warning(
|
||||
logger.warning( # type: ignore[unreachable]
|
||||
"Could not read preview frame at %s, skipping", thumb_path
|
||||
)
|
||||
continue
|
||||
@@ -488,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],
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -14,7 +14,7 @@ from frigate.comms.event_metadata_updater import (
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.log import suppress_stderr_during
|
||||
from frigate.util.object import calculate_region
|
||||
from frigate.util.image import calculate_region
|
||||
|
||||
from ..types import DataProcessorMetrics
|
||||
from .api import RealTimeProcessorApi
|
||||
@@ -35,10 +35,10 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
||||
metrics: DataProcessorMetrics,
|
||||
):
|
||||
super().__init__(config, metrics)
|
||||
self.interpreter: Interpreter = None
|
||||
self.interpreter: Interpreter | None = None
|
||||
self.sub_label_publisher = sub_label_publisher
|
||||
self.tensor_input_details: dict[str, Any] = None
|
||||
self.tensor_output_details: dict[str, Any] = None
|
||||
self.tensor_input_details: list[dict[str, Any]] | None = None
|
||||
self.tensor_output_details: list[dict[str, Any]] | None = None
|
||||
self.detected_birds: dict[str, float] = {}
|
||||
self.labelmap: dict[int, str] = {}
|
||||
|
||||
@@ -61,7 +61,7 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.downloader = ModelDownloader(
|
||||
model_name="bird",
|
||||
download_path=download_path,
|
||||
file_names=self.model_files.keys(),
|
||||
file_names=list(self.model_files.keys()),
|
||||
download_func=self.__download_models,
|
||||
complete_func=self.__build_detector,
|
||||
)
|
||||
@@ -102,8 +102,12 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
||||
i += 1
|
||||
line = f.readline()
|
||||
|
||||
def process_frame(self, obj_data, frame):
|
||||
if not self.interpreter:
|
||||
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None:
|
||||
if (
|
||||
not self.interpreter
|
||||
or not self.tensor_input_details
|
||||
or not self.tensor_output_details
|
||||
):
|
||||
return
|
||||
|
||||
if obj_data["label"] != "bird":
|
||||
@@ -145,7 +149,7 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.tensor_output_details[0]["index"]
|
||||
)[0]
|
||||
probs = res / res.sum(axis=0)
|
||||
best_id = np.argmax(probs)
|
||||
best_id = int(np.argmax(probs))
|
||||
|
||||
if best_id == 964:
|
||||
logger.debug("No bird classification was detected.")
|
||||
@@ -179,9 +183,11 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.config.classification = payload
|
||||
logger.debug("Bird classification config updated dynamically")
|
||||
|
||||
def handle_request(self, topic, request_data):
|
||||
def handle_request(
|
||||
self, topic: str, request_data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
def expire_object(self, object_id, camera):
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
if object_id in self.detected_birds:
|
||||
self.detected_birds.pop(object_id)
|
||||
|
||||
@@ -24,7 +24,8 @@ from frigate.const import CLIPS_DIR, MODEL_CACHE_DIR
|
||||
from frigate.log import suppress_stderr_during
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, load_labels
|
||||
from frigate.util.object import box_overlaps, calculate_region
|
||||
from frigate.util.image import calculate_region
|
||||
from frigate.util.object import box_overlaps
|
||||
|
||||
from ..types import DataProcessorMetrics
|
||||
from .api import RealTimeProcessorApi
|
||||
@@ -49,12 +50,16 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
):
|
||||
super().__init__(config, metrics)
|
||||
self.model_config = model_config
|
||||
|
||||
if not self.model_config.name:
|
||||
raise ValueError("Custom classification model name must be set.")
|
||||
|
||||
self.requestor = requestor
|
||||
self.model_dir = os.path.join(MODEL_CACHE_DIR, self.model_config.name)
|
||||
self.train_dir = os.path.join(CLIPS_DIR, self.model_config.name, "train")
|
||||
self.interpreter: Interpreter = None
|
||||
self.tensor_input_details: dict[str, Any] | None = None
|
||||
self.tensor_output_details: dict[str, Any] | None = None
|
||||
self.interpreter: Interpreter | None = None
|
||||
self.tensor_input_details: list[dict[str, Any]] | None = None
|
||||
self.tensor_output_details: list[dict[str, Any]] | None = None
|
||||
self.labelmap: dict[int, str] = {}
|
||||
self.classifications_per_second = EventsPerSecond()
|
||||
self.state_history: dict[str, dict[str, Any]] = {}
|
||||
@@ -63,7 +68,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
self.metrics
|
||||
and self.model_config.name in self.metrics.classification_speeds
|
||||
):
|
||||
self.inference_speed = InferenceSpeed(
|
||||
self.inference_speed: InferenceSpeed | None = InferenceSpeed(
|
||||
self.metrics.classification_speeds[self.model_config.name]
|
||||
)
|
||||
else:
|
||||
@@ -172,12 +177,20 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
|
||||
return None
|
||||
|
||||
def process_frame(self, frame_data: dict[str, Any], frame: np.ndarray):
|
||||
def process_frame(self, frame_data: dict[str, Any], frame: np.ndarray) -> None:
|
||||
if (
|
||||
not self.model_config.name
|
||||
or not self.model_config.state_config
|
||||
or not self.tensor_input_details
|
||||
or not self.tensor_output_details
|
||||
):
|
||||
return
|
||||
|
||||
if self.metrics and self.model_config.name in self.metrics.classification_cps:
|
||||
self.metrics.classification_cps[
|
||||
self.model_config.name
|
||||
].value = self.classifications_per_second.eps()
|
||||
camera = frame_data.get("camera")
|
||||
camera = str(frame_data.get("camera"))
|
||||
|
||||
if camera not in self.model_config.state_config.cameras:
|
||||
return
|
||||
@@ -283,7 +296,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
logger.debug(
|
||||
f"{self.model_config.name} Ran state classification with probabilities: {probs}"
|
||||
)
|
||||
best_id = np.argmax(probs)
|
||||
best_id = int(np.argmax(probs))
|
||||
score = round(probs[best_id], 2)
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||
|
||||
@@ -319,7 +332,9 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
verified_state,
|
||||
)
|
||||
|
||||
def handle_request(self, topic, request_data):
|
||||
def handle_request(
|
||||
self, topic: str, request_data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
if topic == EmbeddingsRequestEnum.reload_classification_model.value:
|
||||
if request_data.get("model_name") == self.model_config.name:
|
||||
self.__build_detector()
|
||||
@@ -335,7 +350,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
else:
|
||||
return None
|
||||
|
||||
def expire_object(self, object_id, camera):
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@@ -350,13 +365,17 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
):
|
||||
super().__init__(config, metrics)
|
||||
self.model_config = model_config
|
||||
|
||||
if not self.model_config.name:
|
||||
raise ValueError("Custom classification model name must be set.")
|
||||
|
||||
self.model_dir = os.path.join(MODEL_CACHE_DIR, self.model_config.name)
|
||||
self.train_dir = os.path.join(CLIPS_DIR, self.model_config.name, "train")
|
||||
self.interpreter: Interpreter = None
|
||||
self.interpreter: Interpreter | None = None
|
||||
self.sub_label_publisher = sub_label_publisher
|
||||
self.requestor = requestor
|
||||
self.tensor_input_details: dict[str, Any] | None = None
|
||||
self.tensor_output_details: dict[str, Any] | None = None
|
||||
self.tensor_input_details: list[dict[str, Any]] | None = None
|
||||
self.tensor_output_details: list[dict[str, Any]] | None = None
|
||||
self.classification_history: dict[str, list[tuple[str, float, float]]] = {}
|
||||
self.labelmap: dict[int, str] = {}
|
||||
self.classifications_per_second = EventsPerSecond()
|
||||
@@ -365,7 +384,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
self.metrics
|
||||
and self.model_config.name in self.metrics.classification_speeds
|
||||
):
|
||||
self.inference_speed = InferenceSpeed(
|
||||
self.inference_speed: InferenceSpeed | None = InferenceSpeed(
|
||||
self.metrics.classification_speeds[self.model_config.name]
|
||||
)
|
||||
else:
|
||||
@@ -431,8 +450,8 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
return None, 0.0
|
||||
|
||||
label_counts = {}
|
||||
label_scores = {}
|
||||
label_counts: dict[str, int] = {}
|
||||
label_scores: dict[str, list[float]] = {}
|
||||
total_attempts = len(history)
|
||||
|
||||
for label, score, timestamp in history:
|
||||
@@ -443,7 +462,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
label_counts[label] += 1
|
||||
label_scores[label].append(score)
|
||||
|
||||
best_label = max(label_counts, key=label_counts.get)
|
||||
best_label = max(label_counts, key=lambda k: label_counts[k])
|
||||
best_count = label_counts[best_label]
|
||||
|
||||
consensus_threshold = total_attempts * 0.6
|
||||
@@ -470,7 +489,15 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
return best_label, avg_score
|
||||
|
||||
def process_frame(self, obj_data, frame):
|
||||
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None:
|
||||
if (
|
||||
not self.model_config.name
|
||||
or not self.model_config.object_config
|
||||
or not self.tensor_input_details
|
||||
or not self.tensor_output_details
|
||||
):
|
||||
return
|
||||
|
||||
if self.metrics and self.model_config.name in self.metrics.classification_cps:
|
||||
self.metrics.classification_cps[
|
||||
self.model_config.name
|
||||
@@ -555,7 +582,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
logger.debug(
|
||||
f"{self.model_config.name} Ran object classification with probabilities: {probs}"
|
||||
)
|
||||
best_id = np.argmax(probs)
|
||||
best_id = int(np.argmax(probs))
|
||||
score = round(probs[best_id], 2)
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||
|
||||
@@ -650,7 +677,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
),
|
||||
)
|
||||
|
||||
def handle_request(self, topic, request_data):
|
||||
def handle_request(self, topic: str, request_data: dict) -> dict | None:
|
||||
if topic == EmbeddingsRequestEnum.reload_classification_model.value:
|
||||
if request_data.get("model_name") == self.model_config.name:
|
||||
self.__build_detector()
|
||||
@@ -666,12 +693,11 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
else:
|
||||
return None
|
||||
|
||||
def expire_object(self, object_id, camera):
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
if object_id in self.classification_history:
|
||||
self.classification_history.pop(object_id)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def write_classification_attempt(
|
||||
folder: str,
|
||||
frame: np.ndarray,
|
||||
|
||||
@@ -52,11 +52,11 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.face_config = config.face_recognition
|
||||
self.requestor = requestor
|
||||
self.sub_label_publisher = sub_label_publisher
|
||||
self.face_detector: cv2.FaceDetectorYN = None
|
||||
self.face_detector: cv2.FaceDetectorYN | None = None
|
||||
self.requires_face_detection = "face" not in self.config.objects.all_objects
|
||||
self.person_face_history: dict[str, list[tuple[str, float, int]]] = {}
|
||||
self.camera_current_people: dict[str, list[str]] = {}
|
||||
self.recognizer: FaceRecognizer | None = None
|
||||
self.recognizer: FaceRecognizer
|
||||
self.faces_per_second = EventsPerSecond()
|
||||
self.inference_speed = InferenceSpeed(self.metrics.face_rec_speed)
|
||||
|
||||
@@ -78,7 +78,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.downloader = ModelDownloader(
|
||||
model_name="facedet",
|
||||
download_path=download_path,
|
||||
file_names=self.model_files.keys(),
|
||||
file_names=list(self.model_files.keys()),
|
||||
download_func=self.__download_models,
|
||||
complete_func=self.__build_detector,
|
||||
)
|
||||
@@ -134,7 +134,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
def __detect_face(
|
||||
self, input: np.ndarray, threshold: float
|
||||
) -> tuple[int, int, int, int]:
|
||||
) -> tuple[int, int, int, int] | None:
|
||||
"""Detect faces in input image."""
|
||||
if not self.face_detector:
|
||||
return None
|
||||
@@ -153,7 +153,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
faces = self.face_detector.detect(input)
|
||||
|
||||
if faces is None or faces[1] is None:
|
||||
return None
|
||||
return None # type: ignore[unreachable]
|
||||
|
||||
face = None
|
||||
|
||||
@@ -168,7 +168,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
h: int = int(raw_bbox[3] / scale_factor)
|
||||
bbox = (x, y, x + w, y + h)
|
||||
|
||||
if face is None or area(bbox) > area(face):
|
||||
if face is None or area(bbox) > area(face): # type: ignore[unreachable]
|
||||
face = bbox
|
||||
|
||||
return face
|
||||
@@ -177,7 +177,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.faces_per_second.update()
|
||||
self.inference_speed.update(duration)
|
||||
|
||||
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray):
|
||||
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None:
|
||||
"""Look for faces in image."""
|
||||
self.metrics.face_rec_fps.value = self.faces_per_second.eps()
|
||||
camera = obj_data["camera"]
|
||||
@@ -349,7 +349,9 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - start)
|
||||
|
||||
def handle_request(self, topic, request_data) -> dict[str, Any] | None:
|
||||
def handle_request(
|
||||
self, topic: str, request_data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
if topic == EmbeddingsRequestEnum.clear_face_classifier.value:
|
||||
self.recognizer.clear()
|
||||
return {"success": True, "message": "Face classifier cleared."}
|
||||
@@ -432,7 +434,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
img = cv2.imread(current_file)
|
||||
|
||||
if img is None:
|
||||
return {
|
||||
return { # type: ignore[unreachable]
|
||||
"message": "Invalid image file.",
|
||||
"success": False,
|
||||
}
|
||||
@@ -469,7 +471,9 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
"score": score,
|
||||
}
|
||||
|
||||
def expire_object(self, object_id: str, camera: str):
|
||||
return None
|
||||
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
if object_id in self.person_face_history:
|
||||
self.person_face_history.pop(object_id)
|
||||
|
||||
@@ -478,7 +482,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
def weighted_average(
|
||||
self, results_list: list[tuple[str, float, int]], max_weight: int = 4000
|
||||
):
|
||||
) -> tuple[str | None, float]:
|
||||
"""
|
||||
Calculates a robust weighted average, capping the area weight and giving more weight to higher scores.
|
||||
|
||||
@@ -493,8 +497,8 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
return None, 0.0
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
weighted_scores: dict[str, int] = {}
|
||||
total_weights: dict[str, int] = {}
|
||||
weighted_scores: dict[str, float] = {}
|
||||
total_weights: dict[str, float] = {}
|
||||
|
||||
for name, score, face_area in results_list:
|
||||
if name == "unknown":
|
||||
@@ -509,7 +513,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
counts[name] += 1
|
||||
|
||||
# Capped weight based on face area
|
||||
weight = min(face_area, max_weight)
|
||||
weight: float = min(face_area, max_weight)
|
||||
|
||||
# Score-based weighting (higher scores get more weight)
|
||||
weight *= (score - self.face_config.unknown_score) * 10
|
||||
@@ -519,7 +523,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
if not weighted_scores:
|
||||
return None, 0.0
|
||||
|
||||
best_name = max(weighted_scores, key=weighted_scores.get)
|
||||
best_name = max(weighted_scores, key=lambda k: weighted_scores[k])
|
||||
|
||||
# If the number of faces for this person < min_faces, we are not confident it is a correct result
|
||||
if counts[best_name] < self.face_config.min_faces:
|
||||
|
||||
@@ -61,14 +61,16 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess
|
||||
self,
|
||||
obj_data: dict[str, Any],
|
||||
frame: np.ndarray,
|
||||
dedicated_lpr: bool | None = False,
|
||||
):
|
||||
dedicated_lpr: bool = False,
|
||||
) -> None:
|
||||
"""Look for license plates in image."""
|
||||
self.lpr_process(obj_data, frame, dedicated_lpr)
|
||||
|
||||
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 expire_object(self, object_id: str, camera: str):
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
"""Expire lpr objects."""
|
||||
self.lpr_expire(object_id, camera)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Embeddings types."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from multiprocessing.managers import SyncManager
|
||||
from multiprocessing.sharedctypes import Synchronized
|
||||
from multiprocessing.managers import DictProxy, SyncManager, ValueProxy
|
||||
from typing import Any
|
||||
|
||||
import sherpa_onnx
|
||||
|
||||
@@ -10,22 +12,22 @@ from frigate.data_processing.real_time.whisper_online import FasterWhisperASR
|
||||
|
||||
|
||||
class DataProcessorMetrics:
|
||||
image_embeddings_speed: Synchronized
|
||||
image_embeddings_eps: Synchronized
|
||||
text_embeddings_speed: Synchronized
|
||||
text_embeddings_eps: Synchronized
|
||||
face_rec_speed: Synchronized
|
||||
face_rec_fps: Synchronized
|
||||
alpr_speed: Synchronized
|
||||
alpr_pps: Synchronized
|
||||
yolov9_lpr_speed: Synchronized
|
||||
yolov9_lpr_pps: Synchronized
|
||||
review_desc_speed: Synchronized
|
||||
review_desc_dps: Synchronized
|
||||
object_desc_speed: Synchronized
|
||||
object_desc_dps: Synchronized
|
||||
classification_speeds: dict[str, Synchronized]
|
||||
classification_cps: dict[str, Synchronized]
|
||||
image_embeddings_speed: ValueProxy[float]
|
||||
image_embeddings_eps: ValueProxy[float]
|
||||
text_embeddings_speed: ValueProxy[float]
|
||||
text_embeddings_eps: ValueProxy[float]
|
||||
face_rec_speed: ValueProxy[float]
|
||||
face_rec_fps: ValueProxy[float]
|
||||
alpr_speed: ValueProxy[float]
|
||||
alpr_pps: ValueProxy[float]
|
||||
yolov9_lpr_speed: ValueProxy[float]
|
||||
yolov9_lpr_pps: ValueProxy[float]
|
||||
review_desc_speed: ValueProxy[float]
|
||||
review_desc_dps: ValueProxy[float]
|
||||
object_desc_speed: ValueProxy[float]
|
||||
object_desc_dps: ValueProxy[float]
|
||||
classification_speeds: DictProxy[str, ValueProxy[float]]
|
||||
classification_cps: DictProxy[str, ValueProxy[float]]
|
||||
|
||||
def __init__(self, manager: SyncManager, custom_classification_models: list[str]):
|
||||
self.image_embeddings_speed = manager.Value("d", 0.0)
|
||||
@@ -52,7 +54,7 @@ class DataProcessorMetrics:
|
||||
|
||||
|
||||
class DataProcessorModelRunner:
|
||||
def __init__(self, requestor, device: str = "CPU", model_size: str = "large"):
|
||||
def __init__(self, requestor: Any, device: str = "CPU", model_size: str = "large"):
|
||||
self.requestor = requestor
|
||||
self.device = device
|
||||
self.model_size = model_size
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
|
||||
class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
def __init__(self, *args, load_vec_extension: bool = False, **kwargs) -> None:
|
||||
def __init__(
|
||||
self, *args: Any, load_vec_extension: bool = False, **kwargs: Any
|
||||
) -> None:
|
||||
self.load_vec_extension: bool = load_vec_extension
|
||||
# no extension necessary, sqlite will load correctly for each platform
|
||||
self.sqlite_vec_path = "/usr/local/lib/vec0"
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _connect(self, *args, **kwargs) -> sqlite3.Connection:
|
||||
conn: sqlite3.Connection = super()._connect(*args, **kwargs)
|
||||
def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection:
|
||||
conn: sqlite3.Connection = super()._connect(*args, **kwargs) # type: ignore[misc]
|
||||
if self.load_vec_extension:
|
||||
self._load_vec_extension(conn)
|
||||
|
||||
@@ -27,7 +30,7 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
conn.enable_load_extension(False)
|
||||
|
||||
def _register_regexp(self, conn: sqlite3.Connection) -> None:
|
||||
def regexp(expr: str, item: str) -> bool:
|
||||
def regexp(expr: str, item: str | None) -> bool:
|
||||
if item is None:
|
||||
return False
|
||||
try:
|
||||
|
||||
@@ -47,7 +47,7 @@ class ModelTypeEnum(str, Enum):
|
||||
class ModelConfig(BaseModel):
|
||||
path: Optional[str] = Field(
|
||||
None,
|
||||
title="Custom Object detection model path",
|
||||
title="Custom object detector model path",
|
||||
description="Path to a custom detection model file (or plus://<model_id> for Frigate+ models).",
|
||||
)
|
||||
labelmap_path: Optional[str] = Field(
|
||||
|
||||
@@ -317,7 +317,7 @@ class MemryXDetector(DetectionApi):
|
||||
f"Failed to remove downloaded zip {zip_path}: {e}"
|
||||
)
|
||||
|
||||
def send_input(self, connection_id, tensor_input: np.ndarray):
|
||||
def send_input(self, connection_id, tensor_input: np.ndarray) -> None:
|
||||
"""Pre-process (if needed) and send frame to MemryX input queue"""
|
||||
if tensor_input is None:
|
||||
raise ValueError("[send_input] No image data provided for inference")
|
||||
|
||||
+36
-21
@@ -2,17 +2,19 @@
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from multiprocessing.managers import DictProxy
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Tuple
|
||||
from typing import Any, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import CameraConfig, CameraInput, FfmpegConfig, FrigateConfig
|
||||
from frigate.config import CameraConfig, CameraInput, FrigateConfig
|
||||
from frigate.config.camera.ffmpeg import CameraFfmpegConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
@@ -35,10 +37,9 @@ from frigate.data_processing.real_time.audio_transcription import (
|
||||
)
|
||||
from frigate.ffmpeg_presets import parse_preset_input
|
||||
from frigate.log import LogPipe, suppress_stderr_during
|
||||
from frigate.object_detection.base import load_labels
|
||||
from frigate.util.builtin import get_ffmpeg_arg_list
|
||||
from frigate.util.builtin import get_ffmpeg_arg_list, load_labels
|
||||
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
|
||||
from frigate.util.process import FrigateProcess
|
||||
from frigate.video import start_or_restart_ffmpeg, stop_ffmpeg
|
||||
|
||||
try:
|
||||
from tflite_runtime.interpreter import Interpreter
|
||||
@@ -49,7 +50,7 @@ except ModuleNotFoundError:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ffmpeg_command(ffmpeg: FfmpegConfig) -> list[str]:
|
||||
def get_ffmpeg_command(ffmpeg: CameraFfmpegConfig) -> list[str]:
|
||||
ffmpeg_input: CameraInput = [i for i in ffmpeg.inputs if "audio" in i.roles][0]
|
||||
input_args = get_ffmpeg_arg_list(ffmpeg.global_args) + (
|
||||
parse_preset_input(ffmpeg_input.input_args, 1)
|
||||
@@ -102,9 +103,11 @@ class AudioProcessor(FrigateProcess):
|
||||
threading.current_thread().name = "process:audio_manager"
|
||||
|
||||
if self.config.audio_transcription.enabled:
|
||||
self.transcription_model_runner = AudioTranscriptionModelRunner(
|
||||
self.config.audio_transcription.device,
|
||||
self.config.audio_transcription.model_size,
|
||||
self.transcription_model_runner: AudioTranscriptionModelRunner | None = (
|
||||
AudioTranscriptionModelRunner(
|
||||
self.config.audio_transcription.device or "AUTO",
|
||||
self.config.audio_transcription.model_size,
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.transcription_model_runner = None
|
||||
@@ -118,7 +121,7 @@ class AudioProcessor(FrigateProcess):
|
||||
self.config,
|
||||
self.camera_metrics,
|
||||
self.transcription_model_runner,
|
||||
self.stop_event,
|
||||
self.stop_event, # type: ignore[arg-type]
|
||||
)
|
||||
audio_threads.append(audio_thread)
|
||||
audio_thread.start()
|
||||
@@ -162,7 +165,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.logger = logging.getLogger(f"audio.{self.camera_config.name}")
|
||||
self.ffmpeg_cmd = get_ffmpeg_command(self.camera_config.ffmpeg)
|
||||
self.logpipe = LogPipe(f"ffmpeg.{self.camera_config.name}.audio")
|
||||
self.audio_listener = None
|
||||
self.audio_listener: subprocess.Popen[Any] | None = None
|
||||
self.audio_transcription_model_runner = audio_transcription_model_runner
|
||||
self.transcription_processor = None
|
||||
self.transcription_thread = None
|
||||
@@ -171,7 +174,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.requestor = InterProcessRequestor()
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
None,
|
||||
{self.camera_config.name: self.camera_config},
|
||||
{str(self.camera_config.name): self.camera_config},
|
||||
[
|
||||
CameraConfigUpdateEnum.audio,
|
||||
CameraConfigUpdateEnum.enabled,
|
||||
@@ -180,7 +183,10 @@ class AudioEventMaintainer(threading.Thread):
|
||||
)
|
||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio.value)
|
||||
|
||||
if self.config.audio_transcription.enabled:
|
||||
if (
|
||||
self.config.audio_transcription.enabled
|
||||
and self.audio_transcription_model_runner is not None
|
||||
):
|
||||
# init the transcription processor for this camera
|
||||
self.transcription_processor = AudioTranscriptionRealTimeProcessor(
|
||||
config=self.config,
|
||||
@@ -200,11 +206,11 @@ class AudioEventMaintainer(threading.Thread):
|
||||
|
||||
self.was_enabled = camera.enabled
|
||||
|
||||
def detect_audio(self, audio) -> None:
|
||||
def detect_audio(self, audio: np.ndarray) -> None:
|
||||
if not self.camera_config.audio.enabled or self.stop_event.is_set():
|
||||
return
|
||||
|
||||
audio_as_float = audio.astype(np.float32)
|
||||
audio_as_float: np.ndarray = audio.astype(np.float32)
|
||||
rms, dBFS = self.calculate_audio_levels(audio_as_float)
|
||||
|
||||
self.camera_metrics[self.camera_config.name].audio_rms.value = rms
|
||||
@@ -261,7 +267,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
else:
|
||||
self.transcription_processor.check_unload_model()
|
||||
|
||||
def calculate_audio_levels(self, audio_as_float: np.float32) -> Tuple[float, float]:
|
||||
def calculate_audio_levels(self, audio_as_float: np.ndarray) -> Tuple[float, float]:
|
||||
# Calculate RMS (Root-Mean-Square) which represents the average signal amplitude
|
||||
# Note: np.float32 isn't serializable, we must use np.float64 to publish the message
|
||||
rms = np.sqrt(np.mean(np.absolute(np.square(audio_as_float))))
|
||||
@@ -296,6 +302,10 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.logpipe.dump()
|
||||
self.start_or_restart_ffmpeg()
|
||||
|
||||
if self.audio_listener is None or self.audio_listener.stdout is None:
|
||||
log_and_restart()
|
||||
return
|
||||
|
||||
try:
|
||||
chunk = self.audio_listener.stdout.read(self.chunk_size)
|
||||
|
||||
@@ -341,7 +351,10 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.requestor.send_data(
|
||||
EXPIRE_AUDIO_ACTIVITY, self.camera_config.name
|
||||
)
|
||||
stop_ffmpeg(self.audio_listener, self.logger)
|
||||
|
||||
if self.audio_listener:
|
||||
stop_ffmpeg(self.audio_listener, self.logger)
|
||||
|
||||
self.audio_listener = None
|
||||
self.was_enabled = enabled
|
||||
continue
|
||||
@@ -367,7 +380,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
|
||||
|
||||
class AudioTfl:
|
||||
def __init__(self, stop_event: threading.Event, num_threads=2):
|
||||
def __init__(self, stop_event: threading.Event, num_threads: int = 2) -> None:
|
||||
self.stop_event = stop_event
|
||||
self.num_threads = num_threads
|
||||
self.labels = load_labels("/audio-labelmap.txt", prefill=521)
|
||||
@@ -382,7 +395,7 @@ class AudioTfl:
|
||||
self.tensor_input_details = self.interpreter.get_input_details()
|
||||
self.tensor_output_details = self.interpreter.get_output_details()
|
||||
|
||||
def _detect_raw(self, tensor_input):
|
||||
def _detect_raw(self, tensor_input: np.ndarray) -> np.ndarray:
|
||||
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
|
||||
self.interpreter.invoke()
|
||||
detections = np.zeros((20, 6), np.float32)
|
||||
@@ -410,8 +423,10 @@ class AudioTfl:
|
||||
|
||||
return detections
|
||||
|
||||
def detect(self, tensor_input, threshold=AUDIO_MIN_CONFIDENCE):
|
||||
detections = []
|
||||
def detect(
|
||||
self, tensor_input: np.ndarray, threshold: float = AUDIO_MIN_CONFIDENCE
|
||||
) -> list[tuple[str, float, tuple[float, float, float, float]]]:
|
||||
detections: list[tuple[str, float, tuple[float, float, float, float]]] = []
|
||||
|
||||
if self.stop_event.is_set():
|
||||
return detections
|
||||
|
||||
+15
-13
@@ -29,7 +29,7 @@ class EventCleanup(threading.Thread):
|
||||
self.stop_event = stop_event
|
||||
self.db = db
|
||||
self.camera_keys = list(self.config.cameras.keys())
|
||||
self.removed_camera_labels: list[str] = None
|
||||
self.removed_camera_labels: list[Event] | None = None
|
||||
self.camera_labels: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def get_removed_camera_labels(self) -> list[Event]:
|
||||
@@ -37,7 +37,7 @@ class EventCleanup(threading.Thread):
|
||||
if self.removed_camera_labels is None:
|
||||
self.removed_camera_labels = list(
|
||||
Event.select(Event.label)
|
||||
.where(Event.camera.not_in(self.camera_keys))
|
||||
.where(Event.camera.not_in(self.camera_keys)) # type: ignore[arg-type,call-arg,misc]
|
||||
.distinct()
|
||||
.execute()
|
||||
)
|
||||
@@ -61,7 +61,7 @@ class EventCleanup(threading.Thread):
|
||||
),
|
||||
}
|
||||
|
||||
return self.camera_labels[camera]["labels"]
|
||||
return self.camera_labels[camera]["labels"] # type: ignore[no-any-return]
|
||||
|
||||
def expire_snapshots(self) -> list[str]:
|
||||
## Expire events from unlisted cameras based on the global config
|
||||
@@ -74,7 +74,9 @@ class EventCleanup(threading.Thread):
|
||||
# loop over object types in db
|
||||
for event in distinct_labels:
|
||||
# get expiration time for this label
|
||||
expire_days = retain_config.objects.get(event.label, retain_config.default)
|
||||
expire_days = retain_config.objects.get(
|
||||
str(event.label), retain_config.default
|
||||
)
|
||||
|
||||
expire_after = (
|
||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||
@@ -87,7 +89,7 @@ class EventCleanup(threading.Thread):
|
||||
Event.thumbnail,
|
||||
)
|
||||
.where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
@@ -109,16 +111,16 @@ class EventCleanup(threading.Thread):
|
||||
|
||||
# update the clips attribute for the db entry
|
||||
query = Event.select(Event.id).where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
)
|
||||
|
||||
events_to_update = []
|
||||
events_to_update: list[str] = []
|
||||
|
||||
for event in query.iterator():
|
||||
events_to_update.append(event.id)
|
||||
events_to_update.append(str(event.id))
|
||||
if len(events_to_update) >= CHUNK_SIZE:
|
||||
logger.debug(
|
||||
f"Updating {update_params} for {len(events_to_update)} events"
|
||||
@@ -150,7 +152,7 @@ class EventCleanup(threading.Thread):
|
||||
for event in distinct_labels:
|
||||
# get expiration time for this label
|
||||
expire_days = retain_config.objects.get(
|
||||
event.label, retain_config.default
|
||||
str(event.label), retain_config.default
|
||||
)
|
||||
|
||||
expire_after = (
|
||||
@@ -177,7 +179,7 @@ class EventCleanup(threading.Thread):
|
||||
# only snapshots are stored in /clips
|
||||
# so no need to delete mp4 files
|
||||
for event in expired_events:
|
||||
events_to_update.append(event.id)
|
||||
events_to_update.append(str(event.id))
|
||||
deleted = delete_event_snapshot(event)
|
||||
|
||||
if not deleted:
|
||||
@@ -214,7 +216,7 @@ class EventCleanup(threading.Thread):
|
||||
Event.camera,
|
||||
)
|
||||
.where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.start_time < expire_after,
|
||||
Event.retain_indefinitely == False,
|
||||
)
|
||||
@@ -245,7 +247,7 @@ class EventCleanup(threading.Thread):
|
||||
|
||||
# update the clips attribute for the db entry
|
||||
query = Event.select(Event.id).where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.start_time < expire_after,
|
||||
Event.retain_indefinitely == False,
|
||||
)
|
||||
@@ -358,7 +360,7 @@ class EventCleanup(threading.Thread):
|
||||
|
||||
logger.debug(f"Found {len(events_to_delete)} events that can be expired")
|
||||
if len(events_to_delete) > 0:
|
||||
ids_to_delete = [e.id for e in events_to_delete]
|
||||
ids_to_delete = [str(e.id) for e in events_to_delete]
|
||||
for i in range(0, len(ids_to_delete), CHUNK_SIZE):
|
||||
chunk = ids_to_delete[i : i + CHUNK_SIZE]
|
||||
logger.debug(f"Deleting {len(chunk)} events from the database")
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
import threading
|
||||
from multiprocessing import Queue
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Dict
|
||||
from typing import Any, Dict
|
||||
|
||||
from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber
|
||||
from frigate.config import FrigateConfig
|
||||
@@ -15,7 +15,7 @@ from frigate.util.builtin import to_relative_box
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def should_update_db(prev_event: Event, current_event: Event) -> bool:
|
||||
def should_update_db(prev_event: dict[str, Any], current_event: dict[str, Any]) -> bool:
|
||||
"""If current_event has updated fields and (clip or snapshot)."""
|
||||
# If event is ending and was previously saved, always update to set end_time
|
||||
# This ensures events are properly ended even when alerts/detections are disabled
|
||||
@@ -47,7 +47,9 @@ def should_update_db(prev_event: Event, current_event: Event) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def should_update_state(prev_event: Event, current_event: Event) -> bool:
|
||||
def should_update_state(
|
||||
prev_event: dict[str, Any], current_event: dict[str, Any]
|
||||
) -> bool:
|
||||
"""If current event should update state, but not necessarily update the db."""
|
||||
if prev_event["stationary"] != current_event["stationary"]:
|
||||
return True
|
||||
@@ -74,7 +76,7 @@ class EventProcessor(threading.Thread):
|
||||
super().__init__(name="event_processor")
|
||||
self.config = config
|
||||
self.timeline_queue = timeline_queue
|
||||
self.events_in_process: Dict[str, Event] = {}
|
||||
self.events_in_process: Dict[str, dict[str, Any]] = {}
|
||||
self.stop_event = stop_event
|
||||
|
||||
self.event_receiver = EventUpdateSubscriber()
|
||||
@@ -92,7 +94,7 @@ class EventProcessor(threading.Thread):
|
||||
if update == None:
|
||||
continue
|
||||
|
||||
source_type, event_type, camera, _, event_data = update
|
||||
source_type, event_type, camera, _, event_data = update # type: ignore[misc]
|
||||
|
||||
logger.debug(
|
||||
f"Event received: {source_type} {event_type} {camera} {event_data['id']}"
|
||||
@@ -140,7 +142,7 @@ class EventProcessor(threading.Thread):
|
||||
self,
|
||||
event_type: str,
|
||||
camera: str,
|
||||
event_data: Event,
|
||||
event_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""handle tracked object event updates."""
|
||||
updated_db = False
|
||||
@@ -150,8 +152,13 @@ class EventProcessor(threading.Thread):
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return
|
||||
|
||||
width = camera_config.detect.width
|
||||
height = camera_config.detect.height
|
||||
|
||||
if width is None or height is None:
|
||||
return
|
||||
|
||||
first_detector = list(self.config.detectors.values())[0]
|
||||
|
||||
start_time = event_data["start_time"]
|
||||
@@ -222,8 +229,12 @@ class EventProcessor(threading.Thread):
|
||||
Event.thumbnail: event_data.get("thumbnail"),
|
||||
Event.has_clip: event_data["has_clip"],
|
||||
Event.has_snapshot: event_data["has_snapshot"],
|
||||
Event.model_hash: first_detector.model.model_hash,
|
||||
Event.model_type: first_detector.model.model_type,
|
||||
Event.model_hash: first_detector.model.model_hash
|
||||
if first_detector.model
|
||||
else None,
|
||||
Event.model_type: first_detector.model.model_type
|
||||
if first_detector.model
|
||||
else None,
|
||||
Event.detector_type: first_detector.type,
|
||||
Event.data: {
|
||||
"box": box,
|
||||
@@ -287,10 +298,10 @@ class EventProcessor(threading.Thread):
|
||||
|
||||
if event_type == EventStateEnum.end:
|
||||
del self.events_in_process[event_data["id"]]
|
||||
self.event_end_publisher.publish((event_data["id"], camera, updated_db))
|
||||
self.event_end_publisher.publish((event_data["id"], camera, updated_db)) # type: ignore[arg-type]
|
||||
|
||||
def handle_external_detection(
|
||||
self, event_type: EventStateEnum, event_data: Event
|
||||
self, event_type: EventStateEnum, event_data: dict[str, Any]
|
||||
) -> None:
|
||||
# Skip replay cameras
|
||||
if event_data.get("camera", "").startswith(REPLAY_CAMERA_PREFIX):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
from frigate.const import (
|
||||
FFMPEG_HVC1_ARGS,
|
||||
@@ -215,7 +215,7 @@ def parse_preset_hardware_acceleration_decode(
|
||||
width: int,
|
||||
height: int,
|
||||
gpu: int,
|
||||
) -> list[str]:
|
||||
) -> Optional[list[str]]:
|
||||
"""Return the correct preset if in preset format otherwise return None."""
|
||||
if not isinstance(arg, str):
|
||||
return None
|
||||
@@ -242,9 +242,9 @@ def parse_preset_hardware_acceleration_scale(
|
||||
else:
|
||||
scale = PRESETS_HW_ACCEL_SCALE.get(arg, PRESETS_HW_ACCEL_SCALE["default"])
|
||||
|
||||
scale = scale.format(fps, width, height).split(" ")
|
||||
scale.extend(detect_args)
|
||||
return scale
|
||||
scale_args = scale.format(fps, width, height).split(" ")
|
||||
scale_args.extend(detect_args)
|
||||
return scale_args
|
||||
|
||||
|
||||
class EncodeTypeEnum(str, Enum):
|
||||
@@ -420,7 +420,7 @@ PRESETS_INPUT = {
|
||||
}
|
||||
|
||||
|
||||
def parse_preset_input(arg: Any, detect_fps: int) -> list[str]:
|
||||
def parse_preset_input(arg: Any, detect_fps: int) -> Optional[list[str]]:
|
||||
"""Return the correct preset if in preset format otherwise return None."""
|
||||
if not isinstance(arg, str):
|
||||
return None
|
||||
@@ -465,6 +465,16 @@ PRESETS_RECORD_OUTPUT = {
|
||||
"-c:a",
|
||||
"aac",
|
||||
],
|
||||
# NOTE: This preset originally used "-c:a copy" to pass through audio
|
||||
# without re-encoding. FFmpeg 7.x introduced a threaded pipeline where
|
||||
# demuxing, encoding, and muxing run in parallel via a Scheduler. This
|
||||
# broke audio streamcopy from RTSP sources: packets are demuxed correctly
|
||||
# but silently dropped before reaching the muxer (0 bytes written). The
|
||||
# issue is specific to RTSP + streamcopy; file inputs and transcoding both
|
||||
# work. Transcoding AAC audio is very lightweight (~30KiB per 10s segment)
|
||||
# and adds negligible CPU overhead, so this is an acceptable workaround.
|
||||
# The benefits of FFmpeg 7.x — particularly the removal of gamma correction
|
||||
# hacks required by earlier versions — outweigh this trade-off.
|
||||
"preset-record-generic-audio-copy": [
|
||||
"-f",
|
||||
"segment",
|
||||
@@ -476,8 +486,10 @@ PRESETS_RECORD_OUTPUT = {
|
||||
"1",
|
||||
"-strftime",
|
||||
"1",
|
||||
"-c",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
],
|
||||
"preset-record-mjpeg": [
|
||||
"-f",
|
||||
@@ -530,7 +542,9 @@ PRESETS_RECORD_OUTPUT = {
|
||||
}
|
||||
|
||||
|
||||
def parse_preset_output_record(arg: Any, force_record_hvc1: bool) -> list[str]:
|
||||
def parse_preset_output_record(
|
||||
arg: Any, force_record_hvc1: bool
|
||||
) -> Optional[list[str]]:
|
||||
"""Return the correct preset if in preset format otherwise return None."""
|
||||
if not isinstance(arg, str):
|
||||
return None
|
||||
|
||||
@@ -5,7 +5,7 @@ import importlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
@@ -31,10 +31,10 @@ __all__ = [
|
||||
PROVIDERS = {}
|
||||
|
||||
|
||||
def register_genai_provider(key: GenAIProviderEnum):
|
||||
def register_genai_provider(key: GenAIProviderEnum) -> Callable:
|
||||
"""Register a GenAI provider."""
|
||||
|
||||
def decorator(cls):
|
||||
def decorator(cls: type) -> type:
|
||||
PROVIDERS[key] = cls
|
||||
return cls
|
||||
|
||||
@@ -297,7 +297,7 @@ Guidelines:
|
||||
"""Generate a description for the frame."""
|
||||
try:
|
||||
prompt = camera_config.objects.genai.object_prompts.get(
|
||||
event.label,
|
||||
str(event.label),
|
||||
camera_config.objects.genai.prompt,
|
||||
).format(**model_to_dict(event))
|
||||
except KeyError as e:
|
||||
@@ -307,7 +307,7 @@ Guidelines:
|
||||
logger.debug(f"Sending images to genai provider with prompt: {prompt}")
|
||||
return self._send(prompt, thumbnails)
|
||||
|
||||
def _init_provider(self):
|
||||
def _init_provider(self) -> Any:
|
||||
"""Initialize the client."""
|
||||
return None
|
||||
|
||||
@@ -402,7 +402,7 @@ Guidelines:
|
||||
}
|
||||
|
||||
|
||||
def load_providers():
|
||||
def load_providers() -> None:
|
||||
package_dir = os.path.dirname(__file__)
|
||||
for filename in os.listdir(package_dir):
|
||||
if filename.endswith(".py") and filename != "__init__.py":
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from openai import AzureOpenAI
|
||||
@@ -20,10 +20,10 @@ class OpenAIClient(GenAIClient):
|
||||
|
||||
provider: AzureOpenAI
|
||||
|
||||
def _init_provider(self):
|
||||
def _init_provider(self) -> AzureOpenAI | None:
|
||||
"""Initialize the client."""
|
||||
try:
|
||||
parsed_url = urlparse(self.genai_config.base_url)
|
||||
parsed_url = urlparse(self.genai_config.base_url or "")
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
api_version = query_params.get("api-version", [None])[0]
|
||||
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
|
||||
@@ -79,7 +79,7 @@ class OpenAIClient(GenAIClient):
|
||||
logger.warning("Azure OpenAI returned an error: %s", str(e))
|
||||
return None
|
||||
if len(result.choices) > 0:
|
||||
return result.choices[0].message.content.strip()
|
||||
return str(result.choices[0].message.content.strip())
|
||||
return None
|
||||
|
||||
def get_context_size(self) -> int:
|
||||
@@ -113,7 +113,7 @@ class OpenAIClient(GenAIClient):
|
||||
if openai_tool_choice is not None:
|
||||
request_params["tool_choice"] = openai_tool_choice
|
||||
|
||||
result = self.provider.chat.completions.create(**request_params)
|
||||
result = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
|
||||
|
||||
if (
|
||||
result is None
|
||||
@@ -181,7 +181,7 @@ class OpenAIClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
):
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""
|
||||
Stream chat with tools; yields content deltas then final message.
|
||||
|
||||
@@ -214,7 +214,7 @@ class OpenAIClient(GenAIClient):
|
||||
tool_calls_by_index: dict[int, dict[str, Any]] = {}
|
||||
finish_reason = "stop"
|
||||
|
||||
stream = self.provider.chat.completions.create(**request_params)
|
||||
stream = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
|
||||
|
||||
for chunk in stream:
|
||||
if not chunk or not chunk.choices:
|
||||
|
||||
+47
-24
@@ -2,10 +2,11 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
|
||||
from google import genai
|
||||
from google.genai import errors, types
|
||||
from google.genai.types import FunctionCallingConfigMode
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
@@ -19,10 +20,10 @@ class GeminiClient(GenAIClient):
|
||||
|
||||
provider: genai.Client
|
||||
|
||||
def _init_provider(self):
|
||||
def _init_provider(self) -> genai.Client:
|
||||
"""Initialize the client."""
|
||||
# Merge provider_options into HttpOptions
|
||||
http_options_dict = {
|
||||
http_options_dict: dict[str, Any] = {
|
||||
"timeout": int(self.timeout * 1000), # requires milliseconds
|
||||
"retry_options": types.HttpRetryOptions(
|
||||
attempts=3,
|
||||
@@ -49,12 +50,12 @@ class GeminiClient(GenAIClient):
|
||||
response_format: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to Gemini."""
|
||||
contents = [
|
||||
contents = [prompt] + [
|
||||
types.Part.from_bytes(data=img, mime_type="image/jpeg") for img in images
|
||||
] + [prompt]
|
||||
]
|
||||
try:
|
||||
# Merge runtime_options into generation_config if provided
|
||||
generation_config_dict = {"candidate_count": 1}
|
||||
generation_config_dict: dict[str, Any] = {"candidate_count": 1}
|
||||
generation_config_dict.update(self.genai_config.runtime_options)
|
||||
|
||||
if response_format and response_format.get("type") == "json_schema":
|
||||
@@ -65,7 +66,7 @@ class GeminiClient(GenAIClient):
|
||||
|
||||
response = self.provider.models.generate_content(
|
||||
model=self.genai_config.model,
|
||||
contents=contents,
|
||||
contents=contents, # type: ignore[arg-type]
|
||||
config=types.GenerateContentConfig(
|
||||
**generation_config_dict,
|
||||
),
|
||||
@@ -78,6 +79,8 @@ class GeminiClient(GenAIClient):
|
||||
return None
|
||||
|
||||
try:
|
||||
if response.text is None:
|
||||
return None
|
||||
description = response.text.strip()
|
||||
except (ValueError, AttributeError):
|
||||
# No description was generated
|
||||
@@ -102,7 +105,7 @@ class GeminiClient(GenAIClient):
|
||||
"""
|
||||
try:
|
||||
# Convert messages to Gemini format
|
||||
gemini_messages = []
|
||||
gemini_messages: list[types.Content] = []
|
||||
for msg in messages:
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
@@ -110,7 +113,11 @@ class GeminiClient(GenAIClient):
|
||||
# Map roles to Gemini format
|
||||
if role == "system":
|
||||
# Gemini doesn't have system role, prepend to first user message
|
||||
if gemini_messages and gemini_messages[0].role == "user":
|
||||
if (
|
||||
gemini_messages
|
||||
and gemini_messages[0].role == "user"
|
||||
and gemini_messages[0].parts
|
||||
):
|
||||
gemini_messages[0].parts[
|
||||
0
|
||||
].text = f"{content}\n\n{gemini_messages[0].parts[0].text}"
|
||||
@@ -136,7 +143,7 @@ class GeminiClient(GenAIClient):
|
||||
types.Content(
|
||||
role="function",
|
||||
parts=[
|
||||
types.Part.from_function_response(function_response)
|
||||
types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type]
|
||||
],
|
||||
)
|
||||
)
|
||||
@@ -171,19 +178,25 @@ class GeminiClient(GenAIClient):
|
||||
if tool_choice:
|
||||
if tool_choice == "none":
|
||||
tool_config = types.ToolConfig(
|
||||
function_calling_config=types.FunctionCallingConfig(mode="NONE")
|
||||
function_calling_config=types.FunctionCallingConfig(
|
||||
mode=FunctionCallingConfigMode.NONE
|
||||
)
|
||||
)
|
||||
elif tool_choice == "auto":
|
||||
tool_config = types.ToolConfig(
|
||||
function_calling_config=types.FunctionCallingConfig(mode="AUTO")
|
||||
function_calling_config=types.FunctionCallingConfig(
|
||||
mode=FunctionCallingConfigMode.AUTO
|
||||
)
|
||||
)
|
||||
elif tool_choice == "required":
|
||||
tool_config = types.ToolConfig(
|
||||
function_calling_config=types.FunctionCallingConfig(mode="ANY")
|
||||
function_calling_config=types.FunctionCallingConfig(
|
||||
mode=FunctionCallingConfigMode.ANY
|
||||
)
|
||||
)
|
||||
|
||||
# Build request config
|
||||
config_params = {"candidate_count": 1}
|
||||
config_params: dict[str, Any] = {"candidate_count": 1}
|
||||
|
||||
if gemini_tools:
|
||||
config_params["tools"] = gemini_tools
|
||||
@@ -197,7 +210,7 @@ class GeminiClient(GenAIClient):
|
||||
|
||||
response = self.provider.models.generate_content(
|
||||
model=self.genai_config.model,
|
||||
contents=gemini_messages,
|
||||
contents=gemini_messages, # type: ignore[arg-type]
|
||||
config=types.GenerateContentConfig(**config_params),
|
||||
)
|
||||
|
||||
@@ -291,7 +304,7 @@ class GeminiClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
):
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""
|
||||
Stream chat with tools; yields content deltas then final message.
|
||||
|
||||
@@ -299,7 +312,7 @@ class GeminiClient(GenAIClient):
|
||||
"""
|
||||
try:
|
||||
# Convert messages to Gemini format
|
||||
gemini_messages = []
|
||||
gemini_messages: list[types.Content] = []
|
||||
for msg in messages:
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
@@ -307,7 +320,11 @@ class GeminiClient(GenAIClient):
|
||||
# Map roles to Gemini format
|
||||
if role == "system":
|
||||
# Gemini doesn't have system role, prepend to first user message
|
||||
if gemini_messages and gemini_messages[0].role == "user":
|
||||
if (
|
||||
gemini_messages
|
||||
and gemini_messages[0].role == "user"
|
||||
and gemini_messages[0].parts
|
||||
):
|
||||
gemini_messages[0].parts[
|
||||
0
|
||||
].text = f"{content}\n\n{gemini_messages[0].parts[0].text}"
|
||||
@@ -333,7 +350,7 @@ class GeminiClient(GenAIClient):
|
||||
types.Content(
|
||||
role="function",
|
||||
parts=[
|
||||
types.Part.from_function_response(function_response)
|
||||
types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type]
|
||||
],
|
||||
)
|
||||
)
|
||||
@@ -368,19 +385,25 @@ class GeminiClient(GenAIClient):
|
||||
if tool_choice:
|
||||
if tool_choice == "none":
|
||||
tool_config = types.ToolConfig(
|
||||
function_calling_config=types.FunctionCallingConfig(mode="NONE")
|
||||
function_calling_config=types.FunctionCallingConfig(
|
||||
mode=FunctionCallingConfigMode.NONE
|
||||
)
|
||||
)
|
||||
elif tool_choice == "auto":
|
||||
tool_config = types.ToolConfig(
|
||||
function_calling_config=types.FunctionCallingConfig(mode="AUTO")
|
||||
function_calling_config=types.FunctionCallingConfig(
|
||||
mode=FunctionCallingConfigMode.AUTO
|
||||
)
|
||||
)
|
||||
elif tool_choice == "required":
|
||||
tool_config = types.ToolConfig(
|
||||
function_calling_config=types.FunctionCallingConfig(mode="ANY")
|
||||
function_calling_config=types.FunctionCallingConfig(
|
||||
mode=FunctionCallingConfigMode.ANY
|
||||
)
|
||||
)
|
||||
|
||||
# Build request config
|
||||
config_params = {"candidate_count": 1}
|
||||
config_params: dict[str, Any] = {"candidate_count": 1}
|
||||
|
||||
if gemini_tools:
|
||||
config_params["tools"] = gemini_tools
|
||||
@@ -399,7 +422,7 @@ class GeminiClient(GenAIClient):
|
||||
|
||||
stream = await self.provider.aio.models.generate_content_stream(
|
||||
model=self.genai_config.model,
|
||||
contents=gemini_messages,
|
||||
contents=gemini_messages, # type: ignore[arg-type]
|
||||
config=types.GenerateContentConfig(**config_params),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
@@ -23,7 +23,7 @@ def _to_jpeg(img_bytes: bytes) -> bytes | None:
|
||||
try:
|
||||
img = Image.open(io.BytesIO(img_bytes))
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
img = img.convert("RGB") # type: ignore[assignment]
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=85)
|
||||
return buf.getvalue()
|
||||
@@ -36,10 +36,10 @@ def _to_jpeg(img_bytes: bytes) -> bytes | None:
|
||||
class LlamaCppClient(GenAIClient):
|
||||
"""Generative AI client for Frigate using llama.cpp server."""
|
||||
|
||||
provider: str # base_url
|
||||
provider: str | None # base_url
|
||||
provider_options: dict[str, Any]
|
||||
|
||||
def _init_provider(self):
|
||||
def _init_provider(self) -> str | None:
|
||||
"""Initialize the client."""
|
||||
self.provider_options = {
|
||||
**self.genai_config.provider_options,
|
||||
@@ -75,7 +75,7 @@ class LlamaCppClient(GenAIClient):
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"image_url": { # type: ignore[dict-item]
|
||||
"url": f"data:image/jpeg;base64,{encoded_image}",
|
||||
},
|
||||
}
|
||||
@@ -111,7 +111,7 @@ class LlamaCppClient(GenAIClient):
|
||||
):
|
||||
choice = result["choices"][0]
|
||||
if "message" in choice and "content" in choice["message"]:
|
||||
return choice["message"]["content"].strip()
|
||||
return str(choice["message"]["content"].strip())
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("llama.cpp returned an error: %s", str(e))
|
||||
@@ -229,7 +229,7 @@ class LlamaCppClient(GenAIClient):
|
||||
content.append(
|
||||
{
|
||||
"prompt_string": "<__media__>\n",
|
||||
"multimodal_data": [encoded],
|
||||
"multimodal_data": [encoded], # type: ignore[dict-item]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -367,7 +367,7 @@ class LlamaCppClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
):
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""Stream chat with tools via OpenAI-compatible streaming API."""
|
||||
if self.provider is None:
|
||||
logger.warning(
|
||||
|
||||
+15
-9
@@ -2,7 +2,7 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
|
||||
from httpx import RemoteProtocolError, TimeoutException
|
||||
from ollama import AsyncClient as OllamaAsyncClient
|
||||
@@ -28,10 +28,10 @@ class OllamaClient(GenAIClient):
|
||||
},
|
||||
}
|
||||
|
||||
provider: ApiClient
|
||||
provider: ApiClient | None
|
||||
provider_options: dict[str, Any]
|
||||
|
||||
def _init_provider(self):
|
||||
def _init_provider(self) -> ApiClient | None:
|
||||
"""Initialize the client."""
|
||||
self.provider_options = {
|
||||
**self.LOCAL_OPTIMIZED_OPTIONS,
|
||||
@@ -54,12 +54,16 @@ class OllamaClient(GenAIClient):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _clean_schema_for_ollama(schema: dict) -> dict:
|
||||
def _clean_schema_for_ollama(schema: dict, *, _is_properties: bool = False) -> dict:
|
||||
"""Strip Pydantic metadata from a JSON schema for Ollama compatibility.
|
||||
|
||||
Ollama's grammar-based constrained generation works best with minimal
|
||||
schemas. Pydantic adds title/description/constraint fields that can
|
||||
cause the grammar generator to silently skip required fields.
|
||||
|
||||
Keys inside a ``properties`` dict are actual field names and must never
|
||||
be stripped, even if they collide with a metadata key name (e.g. a
|
||||
model field called ``title``).
|
||||
"""
|
||||
STRIP_KEYS = {
|
||||
"title",
|
||||
@@ -69,12 +73,14 @@ class OllamaClient(GenAIClient):
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum",
|
||||
}
|
||||
result = {}
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in schema.items():
|
||||
if key in STRIP_KEYS:
|
||||
if not _is_properties and key in STRIP_KEYS:
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
result[key] = OllamaClient._clean_schema_for_ollama(value)
|
||||
result[key] = OllamaClient._clean_schema_for_ollama(
|
||||
value, _is_properties=(key == "properties")
|
||||
)
|
||||
elif isinstance(value, list):
|
||||
result[key] = [
|
||||
OllamaClient._clean_schema_for_ollama(item)
|
||||
@@ -116,7 +122,7 @@ class OllamaClient(GenAIClient):
|
||||
logger.debug(
|
||||
f"Ollama tokens used: eval_count={result.get('eval_count')}, prompt_eval_count={result.get('prompt_eval_count')}"
|
||||
)
|
||||
return result["response"].strip()
|
||||
return str(result["response"]).strip()
|
||||
except (
|
||||
TimeoutException,
|
||||
ResponseError,
|
||||
@@ -257,7 +263,7 @@ class OllamaClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
):
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""Stream chat with tools; yields content deltas then final message.
|
||||
|
||||
When tools are provided, Ollama streaming does not include tool_calls
|
||||
|
||||
+12
-13
@@ -3,7 +3,7 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
|
||||
from httpx import TimeoutException
|
||||
from openai import OpenAI
|
||||
@@ -21,7 +21,7 @@ class OpenAIClient(GenAIClient):
|
||||
provider: OpenAI
|
||||
context_size: Optional[int] = None
|
||||
|
||||
def _init_provider(self):
|
||||
def _init_provider(self) -> OpenAI:
|
||||
"""Initialize the client."""
|
||||
# Extract context_size from provider_options as it's not a valid OpenAI client parameter
|
||||
# It will be used in get_context_size() instead
|
||||
@@ -44,7 +44,12 @@ class OpenAIClient(GenAIClient):
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to OpenAI."""
|
||||
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
|
||||
messages_content = []
|
||||
messages_content: list[dict] = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": prompt,
|
||||
}
|
||||
]
|
||||
for image in encoded_images:
|
||||
messages_content.append(
|
||||
{
|
||||
@@ -55,12 +60,6 @@ class OpenAIClient(GenAIClient):
|
||||
},
|
||||
}
|
||||
)
|
||||
messages_content.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": prompt,
|
||||
}
|
||||
)
|
||||
try:
|
||||
request_params = {
|
||||
"model": self.genai_config.model,
|
||||
@@ -81,7 +80,7 @@ class OpenAIClient(GenAIClient):
|
||||
and hasattr(result, "choices")
|
||||
and len(result.choices) > 0
|
||||
):
|
||||
return result.choices[0].message.content.strip()
|
||||
return str(result.choices[0].message.content.strip())
|
||||
return None
|
||||
except (TimeoutException, Exception) as e:
|
||||
logger.warning("OpenAI returned an error: %s", str(e))
|
||||
@@ -171,7 +170,7 @@ class OpenAIClient(GenAIClient):
|
||||
}
|
||||
request_params.update(provider_opts)
|
||||
|
||||
result = self.provider.chat.completions.create(**request_params)
|
||||
result = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
|
||||
|
||||
if (
|
||||
result is None
|
||||
@@ -245,7 +244,7 @@ class OpenAIClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
):
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""
|
||||
Stream chat with tools; yields content deltas then final message.
|
||||
|
||||
@@ -287,7 +286,7 @@ class OpenAIClient(GenAIClient):
|
||||
tool_calls_by_index: dict[int, dict[str, Any]] = {}
|
||||
finish_reason = "stop"
|
||||
|
||||
stream = self.provider.chat.completions.create(**request_params)
|
||||
stream = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
|
||||
|
||||
for chunk in stream:
|
||||
if not chunk or not chunk.choices:
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Media sync job management with background execution."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Optional, cast
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.const import UPDATE_JOB_STATE
|
||||
from frigate.const import CONFIG_DIR, UPDATE_JOB_STATE
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.jobs.manager import (
|
||||
get_current_job,
|
||||
@@ -16,7 +17,7 @@ from frigate.jobs.manager import (
|
||||
set_current_job,
|
||||
)
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.media import sync_all_media
|
||||
from frigate.util.media import sync_all_media, write_orphan_report
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,6 +30,7 @@ class MediaSyncJob(Job):
|
||||
dry_run: bool = False
|
||||
media_types: list[str] = field(default_factory=lambda: ["all"])
|
||||
force: bool = False
|
||||
verbose: bool = False
|
||||
|
||||
|
||||
class MediaSyncRunner(threading.Thread):
|
||||
@@ -61,6 +63,21 @@ class MediaSyncRunner(threading.Thread):
|
||||
force=self.job.force,
|
||||
)
|
||||
|
||||
# Write verbose report if requested
|
||||
if self.job.verbose:
|
||||
report_dir = os.path.join(CONFIG_DIR, "media_sync")
|
||||
os.makedirs(report_dir, exist_ok=True)
|
||||
report_path = os.path.join(report_dir, f"{self.job.id}.txt")
|
||||
write_orphan_report(
|
||||
results,
|
||||
report_path,
|
||||
job_id=self.job.id,
|
||||
dry_run=self.job.dry_run,
|
||||
)
|
||||
logger.info(
|
||||
"Media sync verbose orphan report written to %s", report_path
|
||||
)
|
||||
|
||||
# Store results and mark as complete
|
||||
self.job.results = results.to_dict()
|
||||
self.job.status = JobStatusTypesEnum.success
|
||||
@@ -95,6 +112,7 @@ def start_media_sync_job(
|
||||
dry_run: bool = False,
|
||||
media_types: Optional[list[str]] = None,
|
||||
force: bool = False,
|
||||
verbose: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Start a new media sync job if none is currently running.
|
||||
|
||||
@@ -104,7 +122,7 @@ def start_media_sync_job(
|
||||
if job_is_running("media_sync"):
|
||||
current = get_current_job("media_sync")
|
||||
logger.warning(
|
||||
f"Media sync job {current.id} is already running. Rejecting new request."
|
||||
f"Media sync job {current.id if current else 'unknown'} is already running. Rejecting new request."
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -113,6 +131,7 @@ def start_media_sync_job(
|
||||
dry_run=dry_run,
|
||||
media_types=media_types or ["all"],
|
||||
force=force,
|
||||
verbose=verbose,
|
||||
)
|
||||
|
||||
logger.debug(f"Creating new media sync job: {job.id}")
|
||||
@@ -127,9 +146,9 @@ def start_media_sync_job(
|
||||
|
||||
def get_current_media_sync_job() -> Optional[MediaSyncJob]:
|
||||
"""Get the current running/queued media sync job, if any."""
|
||||
return get_current_job("media_sync")
|
||||
return cast(Optional[MediaSyncJob], get_current_job("media_sync"))
|
||||
|
||||
|
||||
def get_media_sync_job_by_id(job_id: str) -> Optional[MediaSyncJob]:
|
||||
"""Get media sync job by ID. Currently only tracks the current job."""
|
||||
return get_job_by_id("media_sync", job_id)
|
||||
return cast(Optional[MediaSyncJob], get_job_by_id("media_sync", job_id))
|
||||
|
||||
@@ -6,7 +6,7 @@ import threading
|
||||
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -96,7 +96,7 @@ def create_polygon_mask(
|
||||
dtype=np.int32,
|
||||
)
|
||||
mask = np.zeros((frame_height, frame_width), dtype=np.uint8)
|
||||
cv2.fillPoly(mask, [motion_points], 255)
|
||||
cv2.fillPoly(mask, [motion_points], (255,))
|
||||
return mask
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ def compute_roi_bbox_normalized(
|
||||
|
||||
|
||||
def heatmap_overlaps_roi(
|
||||
heatmap: dict[str, int], roi_bbox: tuple[float, float, float, float]
|
||||
heatmap: object, roi_bbox: tuple[float, float, float, float]
|
||||
) -> bool:
|
||||
"""Check if a sparse motion heatmap has any overlap with the ROI bounding box.
|
||||
|
||||
@@ -155,9 +155,9 @@ def segment_passes_activity_gate(recording: Recordings) -> bool:
|
||||
Returns True if any of motion, objects, or regions is non-zero/non-null.
|
||||
Returns True if all are null (old segments without data).
|
||||
"""
|
||||
motion = recording.motion
|
||||
objects = recording.objects
|
||||
regions = recording.regions
|
||||
motion: Any = recording.motion
|
||||
objects: Any = recording.objects
|
||||
regions: Any = recording.regions
|
||||
|
||||
# Old segments without metadata - pass through (conservative)
|
||||
if motion is None and objects is None and regions is None:
|
||||
@@ -278,6 +278,9 @@ class MotionSearchRunner(threading.Thread):
|
||||
frame_width = camera_config.detect.width
|
||||
frame_height = camera_config.detect.height
|
||||
|
||||
if frame_width is None or frame_height is None:
|
||||
raise ValueError(f"Camera {camera_name} detect dimensions not configured")
|
||||
|
||||
# Create polygon mask
|
||||
polygon_mask = create_polygon_mask(
|
||||
self.job.polygon_points, frame_width, frame_height
|
||||
@@ -415,11 +418,13 @@ class MotionSearchRunner(threading.Thread):
|
||||
if self._should_stop():
|
||||
break
|
||||
|
||||
rec_start: float = recording.start_time # type: ignore[assignment]
|
||||
rec_end: float = recording.end_time # type: ignore[assignment]
|
||||
future = executor.submit(
|
||||
self._process_recording_for_motion,
|
||||
recording.path,
|
||||
recording.start_time,
|
||||
recording.end_time,
|
||||
str(recording.path),
|
||||
rec_start,
|
||||
rec_end,
|
||||
self.job.start_time_range,
|
||||
self.job.end_time_range,
|
||||
polygon_mask,
|
||||
@@ -524,10 +529,12 @@ class MotionSearchRunner(threading.Thread):
|
||||
break
|
||||
|
||||
try:
|
||||
rec_start: float = recording.start_time # type: ignore[assignment]
|
||||
rec_end: float = recording.end_time # type: ignore[assignment]
|
||||
results, frames = self._process_recording_for_motion(
|
||||
recording.path,
|
||||
recording.start_time,
|
||||
recording.end_time,
|
||||
str(recording.path),
|
||||
rec_start,
|
||||
rec_end,
|
||||
self.job.start_time_range,
|
||||
self.job.end_time_range,
|
||||
polygon_mask,
|
||||
@@ -672,7 +679,9 @@ class MotionSearchRunner(threading.Thread):
|
||||
# Handle frame dimension changes
|
||||
if gray.shape != polygon_mask.shape:
|
||||
resized_mask = cv2.resize(
|
||||
polygon_mask, (gray.shape[1], gray.shape[0]), cv2.INTER_NEAREST
|
||||
polygon_mask,
|
||||
(gray.shape[1], gray.shape[0]),
|
||||
interpolation=cv2.INTER_NEAREST,
|
||||
)
|
||||
current_bbox = cv2.boundingRect(resized_mask)
|
||||
else:
|
||||
@@ -698,7 +707,7 @@ class MotionSearchRunner(threading.Thread):
|
||||
)
|
||||
|
||||
if prev_frame_gray is not None:
|
||||
diff = cv2.absdiff(prev_frame_gray, masked_gray)
|
||||
diff = cv2.absdiff(prev_frame_gray, masked_gray) # type: ignore[unreachable]
|
||||
diff_blurred = cv2.GaussianBlur(diff, (3, 3), 0)
|
||||
_, thresh = cv2.threshold(
|
||||
diff_blurred, threshold, 255, cv2.THRESH_BINARY
|
||||
@@ -825,7 +834,7 @@ def get_motion_search_job(job_id: str) -> Optional[MotionSearchJob]:
|
||||
if job_entry:
|
||||
return job_entry[0]
|
||||
# Check completed jobs via manager
|
||||
return get_job_by_id("motion_search", job_id)
|
||||
return cast(Optional[MotionSearchJob], get_job_by_id("motion_search", job_id))
|
||||
|
||||
|
||||
def cancel_motion_search_job(job_id: str) -> bool:
|
||||
|
||||
+59
-19
@@ -25,6 +25,9 @@ logger = logging.getLogger(__name__)
|
||||
_MIN_INTERVAL = 1
|
||||
_MAX_INTERVAL = 300
|
||||
|
||||
# Minimum seconds between VLM iterations when woken by detections (no zone filter)
|
||||
_DETECTION_COOLDOWN_WITHOUT_ZONE = 10
|
||||
|
||||
# Max user/assistant turn pairs to keep in conversation history
|
||||
_MAX_HISTORY = 10
|
||||
|
||||
@@ -40,6 +43,7 @@ class VLMWatchJob(Job):
|
||||
labels: list = field(default_factory=list)
|
||||
zones: list = field(default_factory=list)
|
||||
last_reasoning: str = ""
|
||||
notification_message: str = ""
|
||||
iteration_count: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -54,9 +58,9 @@ class VLMWatchRunner(threading.Thread):
|
||||
job: VLMWatchJob,
|
||||
config: FrigateConfig,
|
||||
cancel_event: threading.Event,
|
||||
frame_processor,
|
||||
genai_manager,
|
||||
dispatcher,
|
||||
frame_processor: Any,
|
||||
genai_manager: Any,
|
||||
dispatcher: Any,
|
||||
) -> None:
|
||||
super().__init__(daemon=True, name=f"vlm_watch_{job.id}")
|
||||
self.job = job
|
||||
@@ -196,6 +200,7 @@ class VLMWatchRunner(threading.Thread):
|
||||
min(_MAX_INTERVAL, int(parsed.get("next_run_in", 30))),
|
||||
)
|
||||
reasoning = str(parsed.get("reasoning", ""))
|
||||
notification_message = str(parsed.get("notification_message", ""))
|
||||
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
||||
logger.warning(
|
||||
"VLM watch job %s: failed to parse VLM response: %s", self.job.id, e
|
||||
@@ -203,6 +208,7 @@ class VLMWatchRunner(threading.Thread):
|
||||
return 30
|
||||
|
||||
self.job.last_reasoning = reasoning
|
||||
self.job.notification_message = notification_message
|
||||
self.job.iteration_count += 1
|
||||
self._broadcast_status()
|
||||
|
||||
@@ -213,22 +219,41 @@ class VLMWatchRunner(threading.Thread):
|
||||
self.job.camera,
|
||||
reasoning,
|
||||
)
|
||||
self._send_notification(reasoning)
|
||||
self._send_notification(notification_message or reasoning)
|
||||
self.job.status = JobStatusTypesEnum.success
|
||||
return 0
|
||||
|
||||
return next_run_in
|
||||
|
||||
def _wait_for_trigger(self, max_wait: float) -> None:
|
||||
"""Wait up to max_wait seconds, returning early if a relevant detection fires on the target camera."""
|
||||
deadline = time.time() + max_wait
|
||||
"""Wait up to max_wait seconds, returning early if a relevant detection fires on the target camera.
|
||||
|
||||
With zones configured, a matching detection wakes immediately (events
|
||||
are already filtered). Without zones, detections are frequent so a
|
||||
cooldown is enforced: messages are continuously drained to prevent
|
||||
queue backup, but the loop only exits once a match has been seen
|
||||
*and* the cooldown period has elapsed.
|
||||
"""
|
||||
now = time.time()
|
||||
deadline = now + max_wait
|
||||
use_cooldown = not self.job.zones
|
||||
earliest_wake = now + _DETECTION_COOLDOWN_WITHOUT_ZONE if use_cooldown else 0
|
||||
triggered = False
|
||||
|
||||
while not self.cancel_event.is_set():
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
topic, payload = self.detection_subscriber.check_for_update(
|
||||
|
||||
if triggered and time.time() >= earliest_wake:
|
||||
break
|
||||
|
||||
result = self.detection_subscriber.check_for_update(
|
||||
timeout=min(1.0, remaining)
|
||||
)
|
||||
if result is None:
|
||||
continue
|
||||
topic, payload = result
|
||||
if topic is None or payload is None:
|
||||
continue
|
||||
# payload = (camera, frame_name, frame_time, tracked_objects, motion_boxes, regions)
|
||||
@@ -247,12 +272,22 @@ class VLMWatchRunner(threading.Thread):
|
||||
if cam != self.job.camera or not tracked_objects:
|
||||
continue
|
||||
if self._detection_matches_filters(tracked_objects):
|
||||
logger.debug(
|
||||
"VLM watch job %s: woken early by detection event on %s",
|
||||
self.job.id,
|
||||
self.job.camera,
|
||||
)
|
||||
break
|
||||
if not use_cooldown:
|
||||
logger.debug(
|
||||
"VLM watch job %s: woken early by detection event on %s",
|
||||
self.job.id,
|
||||
self.job.camera,
|
||||
)
|
||||
break
|
||||
|
||||
if not triggered:
|
||||
logger.debug(
|
||||
"VLM watch job %s: detection match on %s, draining for %.0fs",
|
||||
self.job.id,
|
||||
self.job.camera,
|
||||
max(0, earliest_wake - time.time()),
|
||||
)
|
||||
triggered = True
|
||||
|
||||
def _detection_matches_filters(self, tracked_objects: list) -> bool:
|
||||
"""Return True if any tracked object passes the label and zone filters."""
|
||||
@@ -281,7 +316,11 @@ class VLMWatchRunner(threading.Thread):
|
||||
f"You will receive a sequence of frames over time. Use the conversation history to understand "
|
||||
f"what is stationary vs. actively changing.\n\n"
|
||||
f"For each frame respond with JSON only:\n"
|
||||
f'{{"condition_met": <true/false>, "next_run_in": <integer seconds 1-300>, "reasoning": "<brief explanation>"}}\n\n'
|
||||
f'{{"condition_met": <true/false>, "next_run_in": <integer seconds 1-300>, "reasoning": "<brief explanation>", "notification_message": "<natural language notification>"}}\n\n'
|
||||
f"Guidelines for notification_message:\n"
|
||||
f"- Only required when condition_met is true.\n"
|
||||
f"- Write a short, natural notification a user would want to receive on their phone.\n"
|
||||
f'- Example: "Your package has been delivered to the front porch."\n\n'
|
||||
f"Guidelines for next_run_in:\n"
|
||||
f"- Scene is empty / nothing of interest visible: 60-300.\n"
|
||||
f"- Relevant object(s) visible anywhere in frame (even outside the target zone): 3-10. "
|
||||
@@ -291,12 +330,13 @@ class VLMWatchRunner(threading.Thread):
|
||||
f"- Keep reasoning to 1-2 sentences."
|
||||
)
|
||||
|
||||
def _send_notification(self, reasoning: str) -> None:
|
||||
def _send_notification(self, message: str) -> None:
|
||||
"""Publish a camera_monitoring event so downstream handlers (web push, MQTT) can notify users."""
|
||||
payload = {
|
||||
"camera": self.job.camera,
|
||||
"condition": self.job.condition,
|
||||
"reasoning": reasoning,
|
||||
"message": message,
|
||||
"reasoning": self.job.last_reasoning,
|
||||
"job_id": self.job.id,
|
||||
}
|
||||
|
||||
@@ -328,9 +368,9 @@ def start_vlm_watch_job(
|
||||
condition: str,
|
||||
max_duration_minutes: int,
|
||||
config: FrigateConfig,
|
||||
frame_processor,
|
||||
genai_manager,
|
||||
dispatcher,
|
||||
frame_processor: Any,
|
||||
genai_manager: Any,
|
||||
dispatcher: Any,
|
||||
labels: list[str] | None = None,
|
||||
zones: list[str] | None = None,
|
||||
) -> str:
|
||||
|
||||
@@ -13,10 +13,10 @@ class MotionDetector(ABC):
|
||||
frame_shape: Tuple[int, int, int],
|
||||
config: MotionConfig,
|
||||
fps: int,
|
||||
improve_contrast,
|
||||
threshold,
|
||||
contour_area,
|
||||
):
|
||||
improve_contrast: bool,
|
||||
threshold: int,
|
||||
contour_area: int | None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -25,7 +25,7 @@ class MotionDetector(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_calibrating(self):
|
||||
def is_calibrating(self) -> bool:
|
||||
"""Return if motion is recalibrating."""
|
||||
pass
|
||||
|
||||
@@ -35,6 +35,6 @@ class MotionDetector(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
"""Stop any ongoing work and processes."""
|
||||
pass
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from frigate.config import MotionConfig
|
||||
from frigate.config.config import RuntimeMotionConfig
|
||||
from frigate.motion import MotionDetector
|
||||
from frigate.util.image import grab_cv2_contours
|
||||
|
||||
@@ -9,19 +11,20 @@ from frigate.util.image import grab_cv2_contours
|
||||
class FrigateMotionDetector(MotionDetector):
|
||||
def __init__(
|
||||
self,
|
||||
frame_shape,
|
||||
config: MotionConfig,
|
||||
frame_shape: tuple[int, ...],
|
||||
config: RuntimeMotionConfig,
|
||||
fps: int,
|
||||
improve_contrast,
|
||||
threshold,
|
||||
contour_area,
|
||||
):
|
||||
improve_contrast: Any,
|
||||
threshold: Any,
|
||||
contour_area: Any,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.frame_shape = frame_shape
|
||||
self.resize_factor = frame_shape[0] / config.frame_height
|
||||
frame_height = config.frame_height or frame_shape[0]
|
||||
self.resize_factor = frame_shape[0] / frame_height
|
||||
self.motion_frame_size = (
|
||||
config.frame_height,
|
||||
config.frame_height * frame_shape[1] // frame_shape[0],
|
||||
frame_height,
|
||||
frame_height * frame_shape[1] // frame_shape[0],
|
||||
)
|
||||
self.avg_frame = np.zeros(self.motion_frame_size, np.float32)
|
||||
self.avg_delta = np.zeros(self.motion_frame_size, np.float32)
|
||||
@@ -38,10 +41,10 @@ class FrigateMotionDetector(MotionDetector):
|
||||
self.threshold = threshold
|
||||
self.contour_area = contour_area
|
||||
|
||||
def is_calibrating(self):
|
||||
def is_calibrating(self) -> bool:
|
||||
return False
|
||||
|
||||
def detect(self, frame):
|
||||
def detect(self, frame: np.ndarray) -> list:
|
||||
motion_boxes = []
|
||||
|
||||
gray = frame[0 : self.frame_shape[0], 0 : self.frame_shape[1]]
|
||||
@@ -99,7 +102,7 @@ class FrigateMotionDetector(MotionDetector):
|
||||
|
||||
# dilate the thresholded image to fill in holes, then find contours
|
||||
# on thresholded image
|
||||
thresh_dilated = cv2.dilate(thresh, None, iterations=2)
|
||||
thresh_dilated = cv2.dilate(thresh, None, iterations=2) # type: ignore[call-overload]
|
||||
contours = cv2.findContours(
|
||||
thresh_dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
||||
)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from scipy.ndimage import gaussian_filter
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.config import MotionConfig
|
||||
from frigate.config.config import RuntimeMotionConfig
|
||||
from frigate.motion import MotionDetector
|
||||
from frigate.util.image import grab_cv2_contours
|
||||
|
||||
@@ -15,22 +16,23 @@ logger = logging.getLogger(__name__)
|
||||
class ImprovedMotionDetector(MotionDetector):
|
||||
def __init__(
|
||||
self,
|
||||
frame_shape,
|
||||
config: MotionConfig,
|
||||
frame_shape: tuple[int, ...],
|
||||
config: RuntimeMotionConfig,
|
||||
fps: int,
|
||||
ptz_metrics: PTZMetrics = None,
|
||||
name="improved",
|
||||
blur_radius=1,
|
||||
interpolation=cv2.INTER_NEAREST,
|
||||
contrast_frame_history=50,
|
||||
):
|
||||
ptz_metrics: Optional[PTZMetrics] = None,
|
||||
name: str = "improved",
|
||||
blur_radius: int = 1,
|
||||
interpolation: int = cv2.INTER_NEAREST,
|
||||
contrast_frame_history: int = 50,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.config = config
|
||||
self.frame_shape = frame_shape
|
||||
self.resize_factor = frame_shape[0] / config.frame_height
|
||||
frame_height = config.frame_height or frame_shape[0]
|
||||
self.resize_factor = frame_shape[0] / frame_height
|
||||
self.motion_frame_size = (
|
||||
config.frame_height,
|
||||
config.frame_height * frame_shape[1] // frame_shape[0],
|
||||
frame_height,
|
||||
frame_height * frame_shape[1] // frame_shape[0],
|
||||
)
|
||||
self.avg_frame = np.zeros(self.motion_frame_size, np.float32)
|
||||
self.motion_frame_count = 0
|
||||
@@ -44,20 +46,20 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
self.contrast_values[:, 1:2] = 255
|
||||
self.contrast_values_index = 0
|
||||
self.ptz_metrics = ptz_metrics
|
||||
self.last_stop_time = None
|
||||
self.last_stop_time: float | None = None
|
||||
|
||||
def is_calibrating(self):
|
||||
def is_calibrating(self) -> bool:
|
||||
return self.calibrating
|
||||
|
||||
def detect(self, frame):
|
||||
motion_boxes = []
|
||||
def detect(self, frame: np.ndarray) -> list[tuple[int, int, int, int]]:
|
||||
motion_boxes: list[tuple[int, int, int, int]] = []
|
||||
|
||||
if not self.config.enabled:
|
||||
return motion_boxes
|
||||
|
||||
# if ptz motor is moving from autotracking, quickly return
|
||||
# a single box that is 80% of the frame
|
||||
if (
|
||||
if self.ptz_metrics is not None and (
|
||||
self.ptz_metrics.autotracker_enabled.value
|
||||
and not self.ptz_metrics.motor_stopped.is_set()
|
||||
):
|
||||
@@ -130,19 +132,19 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
|
||||
# dilate the thresholded image to fill in holes, then find contours
|
||||
# on thresholded image
|
||||
thresh_dilated = cv2.dilate(thresh, None, iterations=1)
|
||||
thresh_dilated = cv2.dilate(thresh, None, iterations=1) # type: ignore[call-overload]
|
||||
contours = cv2.findContours(
|
||||
thresh_dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
||||
)
|
||||
contours = grab_cv2_contours(contours)
|
||||
|
||||
# loop over the contours
|
||||
total_contour_area = 0
|
||||
total_contour_area: float = 0
|
||||
for c in contours:
|
||||
# if the contour is big enough, count it as motion
|
||||
contour_area = cv2.contourArea(c)
|
||||
total_contour_area += contour_area
|
||||
if contour_area > self.config.contour_area:
|
||||
if contour_area > (self.config.contour_area or 0):
|
||||
x, y, w, h = cv2.boundingRect(c)
|
||||
motion_boxes.append(
|
||||
(
|
||||
@@ -159,7 +161,7 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
|
||||
# check if the motor has just stopped from autotracking
|
||||
# if so, reassign the average to the current frame so we begin with a new baseline
|
||||
if (
|
||||
if self.ptz_metrics is not None and (
|
||||
# ensure we only do this for cameras with autotracking enabled
|
||||
self.ptz_metrics.autotracker_enabled.value
|
||||
and self.ptz_metrics.motor_stopped.is_set()
|
||||
|
||||
+27
-34
@@ -22,50 +22,43 @@ warn_unreachable = true
|
||||
no_implicit_reexport = true
|
||||
|
||||
[mypy-frigate.*]
|
||||
ignore_errors = false
|
||||
|
||||
# Third-party code imported from https://github.com/ufal/whisper_streaming
|
||||
[mypy-frigate.data_processing.real_time.whisper_online]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.__main__]
|
||||
ignore_errors = false
|
||||
disallow_untyped_calls = false
|
||||
# TODO: Remove ignores for these modules as they are updated with type annotations.
|
||||
|
||||
[mypy-frigate.app]
|
||||
ignore_errors = false
|
||||
disallow_untyped_calls = false
|
||||
[mypy-frigate.api.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.const]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.config.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.comms.*]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.debug_replay]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.events]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.detectors.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.log]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.embeddings.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.models]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.http]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.plus]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.ptz.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.stats]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.stats.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.track.*]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.test.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.types]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.util.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.version]
|
||||
ignore_errors = false
|
||||
|
||||
[mypy-frigate.watchdog]
|
||||
ignore_errors = false
|
||||
disallow_untyped_calls = false
|
||||
|
||||
|
||||
[mypy-frigate.service_manager.*]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.video.*]
|
||||
ignore_errors = true
|
||||
|
||||
@@ -7,6 +7,7 @@ from abc import ABC, abstractmethod
|
||||
from collections import deque
|
||||
from multiprocessing import Queue, Value
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
import zmq
|
||||
@@ -34,26 +35,25 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class ObjectDetector(ABC):
|
||||
@abstractmethod
|
||||
def detect(self, tensor_input, threshold: float = 0.4):
|
||||
def detect(self, tensor_input: np.ndarray, threshold: float = 0.4) -> list:
|
||||
pass
|
||||
|
||||
|
||||
class BaseLocalDetector(ObjectDetector):
|
||||
def __init__(
|
||||
self,
|
||||
detector_config: BaseDetectorConfig = None,
|
||||
labels: str = None,
|
||||
stop_event: MpEvent = None,
|
||||
):
|
||||
detector_config: Optional[BaseDetectorConfig] = None,
|
||||
labels: Optional[str] = None,
|
||||
stop_event: Optional[MpEvent] = None,
|
||||
) -> None:
|
||||
self.fps = EventsPerSecond()
|
||||
if labels is None:
|
||||
self.labels = {}
|
||||
self.labels: dict[int, str] = {}
|
||||
else:
|
||||
self.labels = load_labels(labels)
|
||||
|
||||
if detector_config:
|
||||
if detector_config and detector_config.model:
|
||||
self.input_transform = tensor_transform(detector_config.model.input_tensor)
|
||||
|
||||
self.dtype = detector_config.model.input_dtype
|
||||
else:
|
||||
self.input_transform = None
|
||||
@@ -77,10 +77,10 @@ class BaseLocalDetector(ObjectDetector):
|
||||
|
||||
return tensor_input
|
||||
|
||||
def detect(self, tensor_input: np.ndarray, threshold=0.4):
|
||||
def detect(self, tensor_input: np.ndarray, threshold: float = 0.4) -> list:
|
||||
detections = []
|
||||
|
||||
raw_detections = self.detect_raw(tensor_input)
|
||||
raw_detections = self.detect_raw(tensor_input) # type: ignore[attr-defined]
|
||||
|
||||
for d in raw_detections:
|
||||
if int(d[0]) < 0 or int(d[0]) >= len(self.labels):
|
||||
@@ -96,28 +96,28 @@ class BaseLocalDetector(ObjectDetector):
|
||||
|
||||
|
||||
class LocalObjectDetector(BaseLocalDetector):
|
||||
def detect_raw(self, tensor_input: np.ndarray):
|
||||
def detect_raw(self, tensor_input: np.ndarray) -> np.ndarray:
|
||||
tensor_input = self._transform_input(tensor_input)
|
||||
return self.detect_api.detect_raw(tensor_input=tensor_input)
|
||||
return self.detect_api.detect_raw(tensor_input=tensor_input) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
class AsyncLocalObjectDetector(BaseLocalDetector):
|
||||
def async_send_input(self, tensor_input: np.ndarray, connection_id: str):
|
||||
def async_send_input(self, tensor_input: np.ndarray, connection_id: str) -> None:
|
||||
tensor_input = self._transform_input(tensor_input)
|
||||
return self.detect_api.send_input(connection_id, tensor_input)
|
||||
self.detect_api.send_input(connection_id, tensor_input)
|
||||
|
||||
def async_receive_output(self):
|
||||
def async_receive_output(self) -> Any:
|
||||
return self.detect_api.receive_output()
|
||||
|
||||
|
||||
class DetectorRunner(FrigateProcess):
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
name: str,
|
||||
detection_queue: Queue,
|
||||
cameras: list[str],
|
||||
avg_speed: Value,
|
||||
start_time: Value,
|
||||
avg_speed: Any,
|
||||
start_time: Any,
|
||||
config: FrigateConfig,
|
||||
detector_config: BaseDetectorConfig,
|
||||
stop_event: MpEvent,
|
||||
@@ -129,11 +129,11 @@ class DetectorRunner(FrigateProcess):
|
||||
self.start_time = start_time
|
||||
self.config = config
|
||||
self.detector_config = detector_config
|
||||
self.outputs: dict = {}
|
||||
self.outputs: dict[str, Any] = {}
|
||||
|
||||
def create_output_shm(self, name: str):
|
||||
def create_output_shm(self, name: str) -> None:
|
||||
out_shm = UntrackedSharedMemory(name=f"out-{name}", create=False)
|
||||
out_np = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf)
|
||||
out_np: np.ndarray = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf)
|
||||
self.outputs[name] = {"shm": out_shm, "np": out_np}
|
||||
|
||||
def run(self) -> None:
|
||||
@@ -155,8 +155,8 @@ class DetectorRunner(FrigateProcess):
|
||||
connection_id,
|
||||
(
|
||||
1,
|
||||
self.detector_config.model.height,
|
||||
self.detector_config.model.width,
|
||||
self.detector_config.model.height, # type: ignore[union-attr]
|
||||
self.detector_config.model.width, # type: ignore[union-attr]
|
||||
3,
|
||||
),
|
||||
)
|
||||
@@ -187,11 +187,11 @@ class DetectorRunner(FrigateProcess):
|
||||
class AsyncDetectorRunner(FrigateProcess):
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
name: str,
|
||||
detection_queue: Queue,
|
||||
cameras: list[str],
|
||||
avg_speed: Value,
|
||||
start_time: Value,
|
||||
avg_speed: Any,
|
||||
start_time: Any,
|
||||
config: FrigateConfig,
|
||||
detector_config: BaseDetectorConfig,
|
||||
stop_event: MpEvent,
|
||||
@@ -203,15 +203,15 @@ class AsyncDetectorRunner(FrigateProcess):
|
||||
self.start_time = start_time
|
||||
self.config = config
|
||||
self.detector_config = detector_config
|
||||
self.outputs: dict = {}
|
||||
self.outputs: dict[str, Any] = {}
|
||||
self._frame_manager: SharedMemoryFrameManager | None = None
|
||||
self._publisher: ObjectDetectorPublisher | None = None
|
||||
self._detector: AsyncLocalObjectDetector | None = None
|
||||
self.send_times = deque()
|
||||
self.send_times: deque[float] = deque()
|
||||
|
||||
def create_output_shm(self, name: str):
|
||||
def create_output_shm(self, name: str) -> None:
|
||||
out_shm = UntrackedSharedMemory(name=f"out-{name}", create=False)
|
||||
out_np = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf)
|
||||
out_np: np.ndarray = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf)
|
||||
self.outputs[name] = {"shm": out_shm, "np": out_np}
|
||||
|
||||
def _detect_worker(self) -> None:
|
||||
@@ -222,12 +222,13 @@ class AsyncDetectorRunner(FrigateProcess):
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
assert self._frame_manager is not None
|
||||
input_frame = self._frame_manager.get(
|
||||
connection_id,
|
||||
(
|
||||
1,
|
||||
self.detector_config.model.height,
|
||||
self.detector_config.model.width,
|
||||
self.detector_config.model.height, # type: ignore[union-attr]
|
||||
self.detector_config.model.width, # type: ignore[union-attr]
|
||||
3,
|
||||
),
|
||||
)
|
||||
@@ -238,11 +239,13 @@ class AsyncDetectorRunner(FrigateProcess):
|
||||
|
||||
# mark start time and send to accelerator
|
||||
self.send_times.append(time.perf_counter())
|
||||
assert self._detector is not None
|
||||
self._detector.async_send_input(input_frame, connection_id)
|
||||
|
||||
def _result_worker(self) -> None:
|
||||
logger.info("Starting Result Worker Thread")
|
||||
while not self.stop_event.is_set():
|
||||
assert self._detector is not None
|
||||
connection_id, detections = self._detector.async_receive_output()
|
||||
|
||||
# Handle timeout case (queue.Empty) - just continue
|
||||
@@ -256,6 +259,7 @@ class AsyncDetectorRunner(FrigateProcess):
|
||||
duration = time.perf_counter() - ts
|
||||
|
||||
# release input buffer
|
||||
assert self._frame_manager is not None
|
||||
self._frame_manager.close(connection_id)
|
||||
|
||||
if connection_id not in self.outputs:
|
||||
@@ -264,6 +268,7 @@ class AsyncDetectorRunner(FrigateProcess):
|
||||
# write results and publish
|
||||
if detections is not None:
|
||||
self.outputs[connection_id]["np"][:] = detections[:]
|
||||
assert self._publisher is not None
|
||||
self._publisher.publish(connection_id)
|
||||
|
||||
# update timers
|
||||
@@ -330,11 +335,14 @@ class ObjectDetectProcess:
|
||||
self.stop_event = stop_event
|
||||
self.start_or_restart()
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
# if the process has already exited on its own, just return
|
||||
if self.detect_process and self.detect_process.exitcode:
|
||||
return
|
||||
|
||||
if self.detect_process is None:
|
||||
return
|
||||
|
||||
logging.info("Waiting for detection process to exit gracefully...")
|
||||
self.detect_process.join(timeout=30)
|
||||
if self.detect_process.exitcode is None:
|
||||
@@ -343,8 +351,8 @@ class ObjectDetectProcess:
|
||||
self.detect_process.join()
|
||||
logging.info("Detection process has exited...")
|
||||
|
||||
def start_or_restart(self):
|
||||
self.detection_start.value = 0.0
|
||||
def start_or_restart(self) -> None:
|
||||
self.detection_start.value = 0.0 # type: ignore[attr-defined]
|
||||
if (self.detect_process is not None) and self.detect_process.is_alive():
|
||||
self.stop()
|
||||
|
||||
@@ -389,17 +397,19 @@ class RemoteObjectDetector:
|
||||
self.detection_queue = detection_queue
|
||||
self.stop_event = stop_event
|
||||
self.shm = UntrackedSharedMemory(name=self.name, create=False)
|
||||
self.np_shm = np.ndarray(
|
||||
self.np_shm: np.ndarray = np.ndarray(
|
||||
(1, model_config.height, model_config.width, 3),
|
||||
dtype=np.uint8,
|
||||
buffer=self.shm.buf,
|
||||
)
|
||||
self.out_shm = UntrackedSharedMemory(name=f"out-{self.name}", create=False)
|
||||
self.out_np_shm = np.ndarray((20, 6), dtype=np.float32, buffer=self.out_shm.buf)
|
||||
self.out_np_shm: np.ndarray = np.ndarray(
|
||||
(20, 6), dtype=np.float32, buffer=self.out_shm.buf
|
||||
)
|
||||
self.detector_subscriber = ObjectDetectorSubscriber(name)
|
||||
|
||||
def detect(self, tensor_input, threshold=0.4):
|
||||
detections = []
|
||||
def detect(self, tensor_input: np.ndarray, threshold: float = 0.4) -> list:
|
||||
detections: list = []
|
||||
|
||||
if self.stop_event.is_set():
|
||||
return detections
|
||||
@@ -431,7 +441,7 @@ class RemoteObjectDetector:
|
||||
self.fps.update()
|
||||
return detections
|
||||
|
||||
def cleanup(self):
|
||||
def cleanup(self) -> None:
|
||||
self.detector_subscriber.stop()
|
||||
self.shm.unlink()
|
||||
self.out_shm.unlink()
|
||||
|
||||
@@ -13,10 +13,10 @@ class RequestStore:
|
||||
A thread-safe hash-based response store that handles creating requests.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.request_counter = 0
|
||||
self.request_counter_lock = threading.Lock()
|
||||
self.input_queue = queue.Queue()
|
||||
self.input_queue: queue.Queue[tuple[int, ndarray]] = queue.Queue()
|
||||
|
||||
def __get_request_id(self) -> int:
|
||||
with self.request_counter_lock:
|
||||
@@ -45,17 +45,19 @@ class ResponseStore:
|
||||
their request's result appears.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.responses = {} # Maps request_id -> (original_input, infer_results)
|
||||
def __init__(self) -> None:
|
||||
self.responses: dict[
|
||||
int, ndarray
|
||||
] = {} # Maps request_id -> (original_input, infer_results)
|
||||
self.lock = threading.Lock()
|
||||
self.cond = threading.Condition(self.lock)
|
||||
|
||||
def put(self, request_id: int, response: ndarray):
|
||||
def put(self, request_id: int, response: ndarray) -> None:
|
||||
with self.cond:
|
||||
self.responses[request_id] = response
|
||||
self.cond.notify_all()
|
||||
|
||||
def get(self, request_id: int, timeout=None) -> ndarray:
|
||||
def get(self, request_id: int, timeout: float | None = None) -> ndarray:
|
||||
with self.cond:
|
||||
if not self.cond.wait_for(
|
||||
lambda: request_id in self.responses, timeout=timeout
|
||||
@@ -65,7 +67,9 @@ class ResponseStore:
|
||||
return self.responses.pop(request_id)
|
||||
|
||||
|
||||
def tensor_transform(desired_shape: InputTensorEnum):
|
||||
def tensor_transform(
|
||||
desired_shape: InputTensorEnum,
|
||||
) -> tuple[int, int, int, int] | None:
|
||||
# Currently this function only supports BHWC permutations
|
||||
if desired_shape == InputTensorEnum.nhwc:
|
||||
return None
|
||||
|
||||
+50
-38
@@ -4,13 +4,13 @@ import datetime
|
||||
import glob
|
||||
import logging
|
||||
import math
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import queue
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any, Optional
|
||||
|
||||
import cv2
|
||||
@@ -74,25 +74,25 @@ class Canvas:
|
||||
self,
|
||||
canvas_width: int,
|
||||
canvas_height: int,
|
||||
scaling_factor: int,
|
||||
scaling_factor: float,
|
||||
) -> None:
|
||||
self.scaling_factor = scaling_factor
|
||||
gcd = math.gcd(canvas_width, canvas_height)
|
||||
self.aspect = get_standard_aspect_ratio(
|
||||
(canvas_width / gcd), (canvas_height / gcd)
|
||||
int(canvas_width / gcd), int(canvas_height / gcd)
|
||||
)
|
||||
self.width = canvas_width
|
||||
self.height = (self.width * self.aspect[1]) / self.aspect[0]
|
||||
self.coefficient_cache: dict[int, int] = {}
|
||||
self.height: float = (self.width * self.aspect[1]) / self.aspect[0]
|
||||
self.coefficient_cache: dict[int, float] = {}
|
||||
self.aspect_cache: dict[str, tuple[int, int]] = {}
|
||||
|
||||
def get_aspect(self, coefficient: int) -> tuple[int, int]:
|
||||
def get_aspect(self, coefficient: float) -> tuple[float, float]:
|
||||
return (self.aspect[0] * coefficient, self.aspect[1] * coefficient)
|
||||
|
||||
def get_coefficient(self, camera_count: int) -> int:
|
||||
def get_coefficient(self, camera_count: int) -> float:
|
||||
return self.coefficient_cache.get(camera_count, self.scaling_factor)
|
||||
|
||||
def set_coefficient(self, camera_count: int, coefficient: int) -> None:
|
||||
def set_coefficient(self, camera_count: int, coefficient: float) -> None:
|
||||
self.coefficient_cache[camera_count] = coefficient
|
||||
|
||||
def get_camera_aspect(
|
||||
@@ -105,7 +105,7 @@ class Canvas:
|
||||
|
||||
gcd = math.gcd(camera_width, camera_height)
|
||||
camera_aspect = get_standard_aspect_ratio(
|
||||
camera_width / gcd, camera_height / gcd
|
||||
int(camera_width / gcd), int(camera_height / gcd)
|
||||
)
|
||||
self.aspect_cache[cam_name] = camera_aspect
|
||||
return camera_aspect
|
||||
@@ -116,7 +116,7 @@ class FFMpegConverter(threading.Thread):
|
||||
self,
|
||||
ffmpeg: FfmpegConfig,
|
||||
input_queue: queue.Queue,
|
||||
stop_event: mp.Event,
|
||||
stop_event: MpEvent,
|
||||
in_width: int,
|
||||
in_height: int,
|
||||
out_width: int,
|
||||
@@ -128,7 +128,7 @@ class FFMpegConverter(threading.Thread):
|
||||
self.camera = "birdseye"
|
||||
self.input_queue = input_queue
|
||||
self.stop_event = stop_event
|
||||
self.bd_pipe = None
|
||||
self.bd_pipe: int | None = None
|
||||
|
||||
if birdseye_rtsp:
|
||||
self.recreate_birdseye_pipe()
|
||||
@@ -181,7 +181,8 @@ class FFMpegConverter(threading.Thread):
|
||||
os.close(stdin)
|
||||
self.reading_birdseye = False
|
||||
|
||||
def __write(self, b) -> None:
|
||||
def __write(self, b: bytes) -> None:
|
||||
assert self.process.stdin is not None
|
||||
self.process.stdin.write(b)
|
||||
|
||||
if self.bd_pipe:
|
||||
@@ -200,13 +201,13 @@ class FFMpegConverter(threading.Thread):
|
||||
|
||||
return
|
||||
|
||||
def read(self, length):
|
||||
def read(self, length: int) -> Any:
|
||||
try:
|
||||
return self.process.stdout.read1(length)
|
||||
return self.process.stdout.read1(length) # type: ignore[union-attr]
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def exit(self):
|
||||
def exit(self) -> None:
|
||||
if self.bd_pipe:
|
||||
os.close(self.bd_pipe)
|
||||
|
||||
@@ -233,8 +234,8 @@ class BroadcastThread(threading.Thread):
|
||||
self,
|
||||
camera: str,
|
||||
converter: FFMpegConverter,
|
||||
websocket_server,
|
||||
stop_event: mp.Event,
|
||||
websocket_server: Any,
|
||||
stop_event: MpEvent,
|
||||
):
|
||||
super().__init__()
|
||||
self.camera = camera
|
||||
@@ -242,7 +243,7 @@ class BroadcastThread(threading.Thread):
|
||||
self.websocket_server = websocket_server
|
||||
self.stop_event = stop_event
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
buf = self.converter.read(65536)
|
||||
if buf:
|
||||
@@ -270,16 +271,16 @@ class BirdsEyeFrameManager:
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
stop_event: mp.Event,
|
||||
stop_event: MpEvent,
|
||||
):
|
||||
self.config = config
|
||||
width, height = get_canvas_shape(config.birdseye.width, config.birdseye.height)
|
||||
self.frame_shape = (height, width)
|
||||
self.yuv_shape = (height * 3 // 2, width)
|
||||
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
|
||||
self.frame: np.ndarray = np.ndarray(self.yuv_shape, dtype=np.uint8)
|
||||
self.canvas = Canvas(width, height, config.birdseye.layout.scaling_factor)
|
||||
self.stop_event = stop_event
|
||||
self.last_refresh_time = 0
|
||||
self.last_refresh_time: float = 0
|
||||
|
||||
# initialize the frame as black and with the Frigate logo
|
||||
self.blank_frame = np.zeros(self.yuv_shape, np.uint8)
|
||||
@@ -323,15 +324,15 @@ class BirdsEyeFrameManager:
|
||||
|
||||
self.frame[:] = self.blank_frame
|
||||
|
||||
self.cameras = {}
|
||||
self.cameras: dict[str, Any] = {}
|
||||
for camera in self.config.cameras.keys():
|
||||
self.add_camera(camera)
|
||||
|
||||
self.camera_layout = []
|
||||
self.active_cameras = set()
|
||||
self.camera_layout: list[Any] = []
|
||||
self.active_cameras: set[str] = set()
|
||||
self.last_output_time = 0.0
|
||||
|
||||
def add_camera(self, cam: str):
|
||||
def add_camera(self, cam: str) -> None:
|
||||
"""Add a camera to self.cameras with the correct structure."""
|
||||
settings = self.config.cameras[cam]
|
||||
# precalculate the coordinates for all the channels
|
||||
@@ -361,16 +362,21 @@ class BirdsEyeFrameManager:
|
||||
},
|
||||
}
|
||||
|
||||
def remove_camera(self, cam: str):
|
||||
def remove_camera(self, cam: str) -> None:
|
||||
"""Remove a camera from self.cameras."""
|
||||
if cam in self.cameras:
|
||||
del self.cameras[cam]
|
||||
|
||||
def clear_frame(self):
|
||||
def clear_frame(self) -> None:
|
||||
logger.debug("Clearing the birdseye frame")
|
||||
self.frame[:] = self.blank_frame
|
||||
|
||||
def copy_to_position(self, position, camera=None, frame: np.ndarray = None):
|
||||
def copy_to_position(
|
||||
self,
|
||||
position: Any,
|
||||
camera: Optional[str] = None,
|
||||
frame: Optional[np.ndarray] = None,
|
||||
) -> None:
|
||||
if camera is None:
|
||||
frame = None
|
||||
channel_dims = None
|
||||
@@ -389,7 +395,9 @@ class BirdsEyeFrameManager:
|
||||
channel_dims,
|
||||
)
|
||||
|
||||
def camera_active(self, mode, object_box_count, motion_box_count):
|
||||
def camera_active(
|
||||
self, mode: Any, object_box_count: int, motion_box_count: int
|
||||
) -> bool:
|
||||
if mode == BirdseyeModeEnum.continuous:
|
||||
return True
|
||||
|
||||
@@ -399,6 +407,8 @@ class BirdsEyeFrameManager:
|
||||
if mode == BirdseyeModeEnum.objects and object_box_count > 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_camera_coordinates(self) -> dict[str, dict[str, int]]:
|
||||
"""Return the coordinates of each camera in the current layout."""
|
||||
coordinates = {}
|
||||
@@ -451,7 +461,7 @@ class BirdsEyeFrameManager:
|
||||
- self.cameras[active_camera]["last_active_frame"]
|
||||
),
|
||||
)
|
||||
active_cameras = limited_active_cameras[:max_cameras]
|
||||
active_cameras = set(limited_active_cameras[:max_cameras])
|
||||
max_camera_refresh = True
|
||||
self.last_refresh_time = now
|
||||
|
||||
@@ -510,7 +520,7 @@ class BirdsEyeFrameManager:
|
||||
|
||||
# center camera view in canvas and ensure that it fits
|
||||
if scaled_width < self.canvas.width:
|
||||
coefficient = 1
|
||||
coefficient: float = 1
|
||||
x_offset = int((self.canvas.width - scaled_width) / 2)
|
||||
else:
|
||||
coefficient = self.canvas.width / scaled_width
|
||||
@@ -557,7 +567,7 @@ class BirdsEyeFrameManager:
|
||||
calculating = False
|
||||
self.canvas.set_coefficient(len(active_cameras), coefficient)
|
||||
|
||||
self.camera_layout = layout_candidate
|
||||
self.camera_layout = layout_candidate or []
|
||||
frame_changed = True
|
||||
|
||||
# Draw the layout
|
||||
@@ -577,10 +587,12 @@ class BirdsEyeFrameManager:
|
||||
self,
|
||||
cameras_to_add: list[str],
|
||||
coefficient: float,
|
||||
) -> tuple[Any]:
|
||||
) -> Optional[list[list[Any]]]:
|
||||
"""Calculate the optimal layout for 2+ cameras."""
|
||||
|
||||
def map_layout(camera_layout: list[list[Any]], row_height: int):
|
||||
def map_layout(
|
||||
camera_layout: list[list[Any]], row_height: int
|
||||
) -> tuple[int, int, Optional[list[list[Any]]]]:
|
||||
"""Map the calculated layout."""
|
||||
candidate_layout = []
|
||||
starting_x = 0
|
||||
@@ -777,11 +789,11 @@ class Birdseye:
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
stop_event: mp.Event,
|
||||
websocket_server,
|
||||
stop_event: MpEvent,
|
||||
websocket_server: Any,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.input = queue.Queue(maxsize=10)
|
||||
self.input: queue.Queue[bytes] = queue.Queue(maxsize=10)
|
||||
self.converter = FFMpegConverter(
|
||||
config.ffmpeg,
|
||||
self.input,
|
||||
@@ -806,7 +818,7 @@ class Birdseye:
|
||||
)
|
||||
|
||||
if config.birdseye.restream:
|
||||
self.birdseye_buffer = self.frame_manager.create(
|
||||
self.birdseye_buffer: Any = self.frame_manager.create(
|
||||
"birdseye",
|
||||
self.birdseye_manager.yuv_shape[0] * self.birdseye_manager.yuv_shape[1],
|
||||
)
|
||||
|
||||
+16
-14
@@ -1,10 +1,11 @@
|
||||
"""Handle outputting individual cameras via jsmpeg."""
|
||||
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
import queue
|
||||
import subprocess as sp
|
||||
import threading
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any
|
||||
|
||||
from frigate.config import CameraConfig, FfmpegConfig
|
||||
|
||||
@@ -17,7 +18,7 @@ class FFMpegConverter(threading.Thread):
|
||||
camera: str,
|
||||
ffmpeg: FfmpegConfig,
|
||||
input_queue: queue.Queue,
|
||||
stop_event: mp.Event,
|
||||
stop_event: MpEvent,
|
||||
in_width: int,
|
||||
in_height: int,
|
||||
out_width: int,
|
||||
@@ -64,16 +65,17 @@ class FFMpegConverter(threading.Thread):
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
def __write(self, b) -> None:
|
||||
def __write(self, b: bytes) -> None:
|
||||
assert self.process.stdin is not None
|
||||
self.process.stdin.write(b)
|
||||
|
||||
def read(self, length):
|
||||
def read(self, length: int) -> Any:
|
||||
try:
|
||||
return self.process.stdout.read1(length)
|
||||
return self.process.stdout.read1(length) # type: ignore[union-attr]
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def exit(self):
|
||||
def exit(self) -> None:
|
||||
self.process.terminate()
|
||||
|
||||
try:
|
||||
@@ -98,8 +100,8 @@ class BroadcastThread(threading.Thread):
|
||||
self,
|
||||
camera: str,
|
||||
converter: FFMpegConverter,
|
||||
websocket_server,
|
||||
stop_event: mp.Event,
|
||||
websocket_server: Any,
|
||||
stop_event: MpEvent,
|
||||
):
|
||||
super().__init__()
|
||||
self.camera = camera
|
||||
@@ -107,7 +109,7 @@ class BroadcastThread(threading.Thread):
|
||||
self.websocket_server = websocket_server
|
||||
self.stop_event = stop_event
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
buf = self.converter.read(65536)
|
||||
if buf:
|
||||
@@ -133,15 +135,15 @@ class BroadcastThread(threading.Thread):
|
||||
|
||||
class JsmpegCamera:
|
||||
def __init__(
|
||||
self, config: CameraConfig, stop_event: mp.Event, websocket_server
|
||||
self, config: CameraConfig, stop_event: MpEvent, websocket_server: Any
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.input = queue.Queue(maxsize=config.detect.fps)
|
||||
self.input: queue.Queue[bytes] = queue.Queue(maxsize=config.detect.fps)
|
||||
width = int(
|
||||
config.live.height * (config.frame_shape[1] / config.frame_shape[0])
|
||||
)
|
||||
self.converter = FFMpegConverter(
|
||||
config.name,
|
||||
config.name or "",
|
||||
config.ffmpeg,
|
||||
self.input,
|
||||
stop_event,
|
||||
@@ -152,13 +154,13 @@ class JsmpegCamera:
|
||||
config.live.quality,
|
||||
)
|
||||
self.broadcaster = BroadcastThread(
|
||||
config.name, self.converter, websocket_server, stop_event
|
||||
config.name or "", self.converter, websocket_server, stop_event
|
||||
)
|
||||
|
||||
self.converter.start()
|
||||
self.broadcaster.start()
|
||||
|
||||
def write_frame(self, frame_bytes) -> None:
|
||||
def write_frame(self, frame_bytes: bytes) -> None:
|
||||
try:
|
||||
self.input.put_nowait(frame_bytes)
|
||||
except queue.Full:
|
||||
|
||||
+28
-15
@@ -61,6 +61,12 @@ def check_disabled_camera_update(
|
||||
# last camera update was more than 1 second ago
|
||||
# need to send empty data to birdseye because current
|
||||
# frame is now out of date
|
||||
cam_width = config.cameras[camera].detect.width
|
||||
cam_height = config.cameras[camera].detect.height
|
||||
|
||||
if cam_width is None or cam_height is None:
|
||||
raise ValueError(f"Camera {camera} detect dimensions not configured")
|
||||
|
||||
if birdseye and offline_time < 10:
|
||||
# we only need to send blank frames to birdseye at the beginning of a camera being offline
|
||||
birdseye.write_data(
|
||||
@@ -68,10 +74,7 @@ def check_disabled_camera_update(
|
||||
[],
|
||||
[],
|
||||
now,
|
||||
get_blank_yuv_frame(
|
||||
config.cameras[camera].detect.width,
|
||||
config.cameras[camera].detect.height,
|
||||
),
|
||||
get_blank_yuv_frame(cam_width, cam_height),
|
||||
)
|
||||
|
||||
if not has_enabled_camera and birdseye:
|
||||
@@ -173,7 +176,7 @@ class OutputProcess(FrigateProcess):
|
||||
birdseye_config_subscriber.check_for_update()
|
||||
)
|
||||
|
||||
if update_topic is not None:
|
||||
if update_topic is not None and birdseye_config is not None:
|
||||
previous_global_mode = self.config.birdseye.mode
|
||||
self.config.birdseye = birdseye_config
|
||||
|
||||
@@ -198,7 +201,10 @@ class OutputProcess(FrigateProcess):
|
||||
birdseye,
|
||||
)
|
||||
|
||||
(topic, data) = detection_subscriber.check_for_update(timeout=1)
|
||||
_result = detection_subscriber.check_for_update(timeout=1)
|
||||
if _result is None:
|
||||
continue
|
||||
(topic, data) = _result
|
||||
now = datetime.datetime.now().timestamp()
|
||||
|
||||
if now - last_disabled_cam_check > 5:
|
||||
@@ -208,7 +214,7 @@ class OutputProcess(FrigateProcess):
|
||||
self.config, birdseye, preview_recorders, preview_write_times
|
||||
)
|
||||
|
||||
if not topic:
|
||||
if not topic or data is None:
|
||||
continue
|
||||
|
||||
(
|
||||
@@ -262,11 +268,15 @@ class OutputProcess(FrigateProcess):
|
||||
jsmpeg_cameras[camera].write_frame(frame.tobytes())
|
||||
|
||||
# send output data to birdseye if websocket is connected or restreaming
|
||||
if self.config.birdseye.enabled and (
|
||||
self.config.birdseye.restream
|
||||
or any(
|
||||
ws.environ["PATH_INFO"].endswith("birdseye")
|
||||
for ws in websocket_server.manager
|
||||
if (
|
||||
self.config.birdseye.enabled
|
||||
and birdseye is not None
|
||||
and (
|
||||
self.config.birdseye.restream
|
||||
or any(
|
||||
ws.environ["PATH_INFO"].endswith("birdseye")
|
||||
for ws in websocket_server.manager
|
||||
)
|
||||
)
|
||||
):
|
||||
birdseye.write_data(
|
||||
@@ -282,9 +292,12 @@ class OutputProcess(FrigateProcess):
|
||||
move_preview_frames("clips")
|
||||
|
||||
while True:
|
||||
(topic, data) = detection_subscriber.check_for_update(timeout=0)
|
||||
_cleanup_result = detection_subscriber.check_for_update(timeout=0)
|
||||
if _cleanup_result is None:
|
||||
break
|
||||
(topic, data) = _cleanup_result
|
||||
|
||||
if not topic:
|
||||
if not topic or data is None:
|
||||
break
|
||||
|
||||
(
|
||||
@@ -322,7 +335,7 @@ class OutputProcess(FrigateProcess):
|
||||
logger.info("exiting output process...")
|
||||
|
||||
|
||||
def move_preview_frames(loc: str):
|
||||
def move_preview_frames(loc: str) -> None:
|
||||
preview_holdover = os.path.join(CLIPS_DIR, "preview_restart_cache")
|
||||
preview_cache = os.path.join(CACHE_DIR, "preview_frames")
|
||||
|
||||
|
||||
+32
-29
@@ -22,7 +22,6 @@ from frigate.ffmpeg_presets import (
|
||||
parse_preset_hardware_acceleration_encode,
|
||||
)
|
||||
from frigate.models import Previews
|
||||
from frigate.track.object_processing import TrackedObject
|
||||
from frigate.util.image import copy_yuv_to_position, get_blank_yuv_frame, get_yuv_crop
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -66,7 +65,9 @@ def get_cache_image_name(camera: str, frame_time: float) -> str:
|
||||
)
|
||||
|
||||
|
||||
def get_most_recent_preview_frame(camera: str, before: float = None) -> str | None:
|
||||
def get_most_recent_preview_frame(
|
||||
camera: str, before: float | None = None
|
||||
) -> str | None:
|
||||
"""Get the most recent preview frame for a camera."""
|
||||
if not os.path.exists(PREVIEW_CACHE_DIR):
|
||||
return None
|
||||
@@ -147,12 +148,12 @@ class FFMpegConverter(threading.Thread):
|
||||
if t_idx == item_count - 1:
|
||||
# last frame does not get a duration
|
||||
playlist.append(
|
||||
f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'"
|
||||
f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'" # type: ignore[arg-type]
|
||||
)
|
||||
continue
|
||||
|
||||
playlist.append(
|
||||
f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'"
|
||||
f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'" # type: ignore[arg-type]
|
||||
)
|
||||
playlist.append(
|
||||
f"duration {self.frame_times[t_idx + 1] - self.frame_times[t_idx]}"
|
||||
@@ -199,30 +200,33 @@ class FFMpegConverter(threading.Thread):
|
||||
# unlink files from cache
|
||||
# don't delete last frame as it will be used as first frame in next segment
|
||||
for t in self.frame_times[0:-1]:
|
||||
Path(get_cache_image_name(self.config.name, t)).unlink(missing_ok=True)
|
||||
Path(get_cache_image_name(self.config.name, t)).unlink(missing_ok=True) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class PreviewRecorder:
|
||||
def __init__(self, config: CameraConfig) -> None:
|
||||
self.config = config
|
||||
self.start_time = 0
|
||||
self.last_output_time = 0
|
||||
self.camera_name: str = config.name or ""
|
||||
self.start_time: float = 0
|
||||
self.last_output_time: float = 0
|
||||
self.offline = False
|
||||
self.output_frames = []
|
||||
self.output_frames: list[float] = []
|
||||
|
||||
if config.detect.width > config.detect.height:
|
||||
if config.detect.width is None or config.detect.height is None:
|
||||
raise ValueError("Detect width and height must be set for previews.")
|
||||
|
||||
self.detect_width: int = config.detect.width
|
||||
self.detect_height: int = config.detect.height
|
||||
|
||||
if self.detect_width > self.detect_height:
|
||||
self.out_height = PREVIEW_HEIGHT
|
||||
self.out_width = (
|
||||
int((config.detect.width / config.detect.height) * self.out_height)
|
||||
// 4
|
||||
* 4
|
||||
int((self.detect_width / self.detect_height) * self.out_height) // 4 * 4
|
||||
)
|
||||
else:
|
||||
self.out_width = PREVIEW_HEIGHT
|
||||
self.out_height = (
|
||||
int((config.detect.height / config.detect.width) * self.out_width)
|
||||
// 4
|
||||
* 4
|
||||
int((self.detect_height / self.detect_width) * self.out_width) // 4 * 4
|
||||
)
|
||||
|
||||
# create communication for finished previews
|
||||
@@ -266,8 +270,8 @@ class PreviewRecorder:
|
||||
.timestamp()
|
||||
)
|
||||
|
||||
file_start = f"preview_{config.name}"
|
||||
start_file = f"{file_start}-{start_ts}.webp"
|
||||
file_start = f"preview_{config.name}-"
|
||||
start_file = f"{file_start}{start_ts}.webp"
|
||||
|
||||
for file in sorted(os.listdir(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES))):
|
||||
if not file.startswith(file_start):
|
||||
@@ -302,7 +306,7 @@ class PreviewRecorder:
|
||||
)
|
||||
self.start_time = frame_time
|
||||
self.last_output_time = frame_time
|
||||
self.output_frames: list[float] = []
|
||||
self.output_frames = []
|
||||
|
||||
def should_write_frame(
|
||||
self,
|
||||
@@ -342,7 +346,9 @@ class PreviewRecorder:
|
||||
|
||||
def write_frame_to_cache(self, frame_time: float, frame: np.ndarray) -> None:
|
||||
# resize yuv frame
|
||||
small_frame = np.zeros((self.out_height * 3 // 2, self.out_width), np.uint8)
|
||||
small_frame: np.ndarray = np.zeros(
|
||||
(self.out_height * 3 // 2, self.out_width), np.uint8
|
||||
)
|
||||
copy_yuv_to_position(
|
||||
small_frame,
|
||||
(0, 0),
|
||||
@@ -356,7 +362,7 @@ class PreviewRecorder:
|
||||
cv2.COLOR_YUV2BGR_I420,
|
||||
)
|
||||
cv2.imwrite(
|
||||
get_cache_image_name(self.config.name, frame_time),
|
||||
get_cache_image_name(self.camera_name, frame_time),
|
||||
small_frame,
|
||||
[
|
||||
int(cv2.IMWRITE_WEBP_QUALITY),
|
||||
@@ -396,7 +402,7 @@ class PreviewRecorder:
|
||||
).start()
|
||||
else:
|
||||
logger.debug(
|
||||
f"Not saving preview for {self.config.name} because there are no saved frames."
|
||||
f"Not saving preview for {self.camera_name} because there are no saved frames."
|
||||
)
|
||||
|
||||
self.reset_frame_cache(frame_time)
|
||||
@@ -416,9 +422,7 @@ class PreviewRecorder:
|
||||
if not self.offline:
|
||||
self.write_frame_to_cache(
|
||||
frame_time,
|
||||
get_blank_yuv_frame(
|
||||
self.config.detect.width, self.config.detect.height
|
||||
),
|
||||
get_blank_yuv_frame(self.detect_width, self.detect_height),
|
||||
)
|
||||
self.offline = True
|
||||
|
||||
@@ -431,9 +435,9 @@ class PreviewRecorder:
|
||||
return
|
||||
|
||||
old_frame_path = get_cache_image_name(
|
||||
self.config.name, self.output_frames[-1]
|
||||
self.camera_name, self.output_frames[-1]
|
||||
)
|
||||
new_frame_path = get_cache_image_name(self.config.name, frame_time)
|
||||
new_frame_path = get_cache_image_name(self.camera_name, frame_time)
|
||||
shutil.copy(old_frame_path, new_frame_path)
|
||||
|
||||
# save last frame to ensure consistent duration
|
||||
@@ -447,13 +451,12 @@ class PreviewRecorder:
|
||||
self.reset_frame_cache(frame_time)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.config_subscriber.stop()
|
||||
self.requestor.stop()
|
||||
|
||||
|
||||
def get_active_objects(
|
||||
frame_time: float, camera_config: CameraConfig, all_objects: list[TrackedObject]
|
||||
) -> list[TrackedObject]:
|
||||
frame_time: float, camera_config: CameraConfig, all_objects: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""get active objects for detection."""
|
||||
return [
|
||||
o
|
||||
|
||||
+255
-126
@@ -15,6 +15,10 @@ from zeep.exceptions import Fault, TransportError
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.config import FrigateConfig, ZoomingModeEnum
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.util.builtin import find_by_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -65,7 +69,14 @@ class OnvifController:
|
||||
self.camera_configs[cam_name] = cam
|
||||
self.status_locks[cam_name] = asyncio.Lock()
|
||||
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config,
|
||||
self.config.cameras,
|
||||
[CameraConfigUpdateEnum.onvif],
|
||||
)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(self._init_cameras(), self.loop)
|
||||
asyncio.run_coroutine_threadsafe(self._poll_config_updates(), self.loop)
|
||||
|
||||
def _run_event_loop(self) -> None:
|
||||
"""Run the event loop in a separate thread."""
|
||||
@@ -80,6 +91,52 @@ class OnvifController:
|
||||
for cam_name in self.camera_configs:
|
||||
await self._init_single_camera(cam_name)
|
||||
|
||||
async def _poll_config_updates(self) -> None:
|
||||
"""Poll for ONVIF config updates and re-initialize cameras as needed."""
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
updates = self.config_subscriber.check_for_updates()
|
||||
for update_type, cameras in updates.items():
|
||||
if update_type == CameraConfigUpdateEnum.onvif.name:
|
||||
for cam_name in cameras:
|
||||
await self._reinit_camera(cam_name)
|
||||
except Exception:
|
||||
logger.error("Error checking for ONVIF config updates")
|
||||
|
||||
async def _close_camera(self, cam_name: str) -> None:
|
||||
"""Close the ONVIF client session for a camera."""
|
||||
cam_state = self.cams.get(cam_name)
|
||||
if cam_state and "onvif" in cam_state:
|
||||
try:
|
||||
await cam_state["onvif"].close()
|
||||
except Exception:
|
||||
logger.debug(f"Error closing ONVIF session for {cam_name}")
|
||||
|
||||
async def _reinit_camera(self, cam_name: str) -> None:
|
||||
"""Re-initialize a camera after config change."""
|
||||
logger.info(f"Re-initializing ONVIF for {cam_name} due to config change")
|
||||
|
||||
# close existing session before re-init
|
||||
await self._close_camera(cam_name)
|
||||
|
||||
cam = self.config.cameras.get(cam_name)
|
||||
if not cam or not cam.onvif.host:
|
||||
# ONVIF removed from config, clean up
|
||||
self.cams.pop(cam_name, None)
|
||||
self.camera_configs.pop(cam_name, None)
|
||||
self.failed_cams.pop(cam_name, None)
|
||||
return
|
||||
|
||||
# update stored config and reset state
|
||||
self.camera_configs[cam_name] = cam
|
||||
if cam_name not in self.status_locks:
|
||||
self.status_locks[cam_name] = asyncio.Lock()
|
||||
self.cams.pop(cam_name, None)
|
||||
self.failed_cams.pop(cam_name, None)
|
||||
|
||||
await self._init_single_camera(cam_name)
|
||||
|
||||
async def _init_single_camera(self, cam_name: str) -> bool:
|
||||
"""Initialize a single camera by name.
|
||||
|
||||
@@ -118,6 +175,7 @@ class OnvifController:
|
||||
"active": False,
|
||||
"features": [],
|
||||
"presets": {},
|
||||
"profiles": [],
|
||||
}
|
||||
return True
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
@@ -161,22 +219,60 @@ class OnvifController:
|
||||
)
|
||||
return False
|
||||
|
||||
# build list of valid PTZ profiles
|
||||
valid_profiles = [
|
||||
p
|
||||
for p in profiles
|
||||
if p.VideoEncoderConfiguration
|
||||
and p.PTZConfiguration
|
||||
and (
|
||||
p.PTZConfiguration.DefaultContinuousPanTiltVelocitySpace is not None
|
||||
or p.PTZConfiguration.DefaultContinuousZoomVelocitySpace is not None
|
||||
)
|
||||
]
|
||||
|
||||
# store available profiles for API response and log for debugging
|
||||
self.cams[camera_name]["profiles"] = [
|
||||
{"name": getattr(p, "Name", None) or p.token, "token": p.token}
|
||||
for p in valid_profiles
|
||||
]
|
||||
for p in valid_profiles:
|
||||
logger.debug(
|
||||
"Onvif profile for %s: name='%s', token='%s'",
|
||||
camera_name,
|
||||
getattr(p, "Name", None),
|
||||
p.token,
|
||||
)
|
||||
|
||||
configured_profile = self.config.cameras[camera_name].onvif.profile
|
||||
profile = None
|
||||
for _, onvif_profile in enumerate(profiles):
|
||||
if (
|
||||
onvif_profile.VideoEncoderConfiguration
|
||||
and onvif_profile.PTZConfiguration
|
||||
and (
|
||||
onvif_profile.PTZConfiguration.DefaultContinuousPanTiltVelocitySpace
|
||||
is not None
|
||||
or onvif_profile.PTZConfiguration.DefaultContinuousZoomVelocitySpace
|
||||
is not None
|
||||
|
||||
if configured_profile is not None:
|
||||
# match by exact token first, then by name
|
||||
for p in valid_profiles:
|
||||
if p.token == configured_profile:
|
||||
profile = p
|
||||
break
|
||||
if profile is None:
|
||||
for p in valid_profiles:
|
||||
if getattr(p, "Name", None) == configured_profile:
|
||||
profile = p
|
||||
break
|
||||
if profile is None:
|
||||
available = [
|
||||
f"name='{getattr(p, 'Name', None)}', token='{p.token}'"
|
||||
for p in valid_profiles
|
||||
]
|
||||
logger.error(
|
||||
"Onvif profile '%s' not found for camera %s. Available profiles: %s",
|
||||
configured_profile,
|
||||
camera_name,
|
||||
available,
|
||||
)
|
||||
):
|
||||
# use the first profile that has a valid ptz configuration
|
||||
profile = onvif_profile
|
||||
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
|
||||
break
|
||||
return False
|
||||
else:
|
||||
# use the first profile that has a valid ptz configuration
|
||||
profile = valid_profiles[0] if valid_profiles else None
|
||||
|
||||
if profile is None:
|
||||
logger.error(
|
||||
@@ -184,6 +280,8 @@ class OnvifController:
|
||||
)
|
||||
return False
|
||||
|
||||
logger.debug(f"Selected Onvif profile for {camera_name}: {profile}")
|
||||
|
||||
# get the PTZ config for the profile
|
||||
try:
|
||||
configs = profile.PTZConfiguration
|
||||
@@ -218,48 +316,92 @@ class OnvifController:
|
||||
move_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["move_request"] = move_request
|
||||
|
||||
# extra setup for autotracking cameras
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
|
||||
and self.config.cameras[camera_name].onvif.autotracking.enabled
|
||||
):
|
||||
# get PTZ configuration options for feature detection and relative movement
|
||||
ptz_config = None
|
||||
fov_space_id = None
|
||||
|
||||
try:
|
||||
request = ptz.create_type("GetConfigurationOptions")
|
||||
request.ConfigurationToken = profile.PTZConfiguration.token
|
||||
ptz_config = await ptz.GetConfigurationOptions(request)
|
||||
logger.debug(f"Onvif config for {camera_name}: {ptz_config}")
|
||||
logger.debug(
|
||||
f"Onvif PTZ configuration options for {camera_name}: {ptz_config}"
|
||||
)
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.debug(
|
||||
f"Unable to get PTZ configuration options for {camera_name}: {e}"
|
||||
)
|
||||
|
||||
# detect FOV translation space for relative movement
|
||||
if ptz_config is not None:
|
||||
try:
|
||||
fov_space_id = next(
|
||||
(
|
||||
i
|
||||
for i, space in enumerate(
|
||||
ptz_config.Spaces.RelativePanTiltTranslationSpace
|
||||
)
|
||||
if "TranslationSpaceFov" in space["URI"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
except (AttributeError, TypeError):
|
||||
fov_space_id = None
|
||||
|
||||
autotracking_config = self.config.cameras[camera_name].onvif.autotracking
|
||||
autotracking_enabled = (
|
||||
autotracking_config.enabled_in_config and autotracking_config.enabled
|
||||
)
|
||||
|
||||
# autotracking-only: status request and service capabilities
|
||||
if autotracking_enabled:
|
||||
status_request = ptz.create_type("GetStatus")
|
||||
status_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["status_request"] = status_request
|
||||
|
||||
service_capabilities_request = ptz.create_type("GetServiceCapabilities")
|
||||
self.cams[camera_name]["service_capabilities_request"] = (
|
||||
service_capabilities_request
|
||||
)
|
||||
|
||||
fov_space_id = next(
|
||||
(
|
||||
i
|
||||
for i, space in enumerate(
|
||||
ptz_config.Spaces.RelativePanTiltTranslationSpace
|
||||
)
|
||||
if "TranslationSpaceFov" in space["URI"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# status request for autotracking and filling ptz-parameters
|
||||
status_request = ptz.create_type("GetStatus")
|
||||
status_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["status_request"] = status_request
|
||||
# setup relative move request when FOV relative movement is supported
|
||||
if (
|
||||
fov_space_id is not None
|
||||
and configs.DefaultRelativePanTiltTranslationSpace is not None
|
||||
):
|
||||
# one-off GetStatus to seed Translation field
|
||||
status = None
|
||||
try:
|
||||
status = await ptz.GetStatus(status_request)
|
||||
logger.debug(f"Onvif status config for {camera_name}: {status}")
|
||||
one_off_status_request = ptz.create_type("GetStatus")
|
||||
one_off_status_request.ProfileToken = profile.token
|
||||
status = await ptz.GetStatus(one_off_status_request)
|
||||
logger.debug(f"Onvif status for {camera_name}: {status}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Unable to get status from camera: {camera_name}: {e}")
|
||||
status = None
|
||||
logger.warning(f"Unable to get status from camera {camera_name}: {e}")
|
||||
|
||||
# autotracking relative panning/tilting needs a relative zoom value set to 0
|
||||
# if camera supports relative movement
|
||||
rel_move_request = ptz.create_type("RelativeMove")
|
||||
rel_move_request.ProfileToken = profile.token
|
||||
logger.debug(f"{camera_name}: Relative move request: {rel_move_request}")
|
||||
|
||||
fov_uri = ptz_config["Spaces"]["RelativePanTiltTranslationSpace"][
|
||||
fov_space_id
|
||||
]["URI"]
|
||||
|
||||
if rel_move_request.Translation is None:
|
||||
if status is not None:
|
||||
# seed from current position
|
||||
rel_move_request.Translation = status.Position
|
||||
rel_move_request.Translation.PanTilt.space = fov_uri
|
||||
else:
|
||||
# fallback: construct Translation explicitly
|
||||
rel_move_request.Translation = {
|
||||
"PanTilt": {"x": 0, "y": 0, "space": fov_uri}
|
||||
}
|
||||
|
||||
# configure zoom on relative move request
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
!= ZoomingModeEnum.disabled
|
||||
autotracking_enabled
|
||||
and autotracking_config.zooming != ZoomingModeEnum.disabled
|
||||
):
|
||||
zoom_space_id = next(
|
||||
(
|
||||
@@ -271,60 +413,43 @@ class OnvifController:
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# setup relative moving request for autotracking
|
||||
move_request = ptz.create_type("RelativeMove")
|
||||
move_request.ProfileToken = profile.token
|
||||
logger.debug(f"{camera_name}: Relative move request: {move_request}")
|
||||
if move_request.Translation is None and fov_space_id is not None:
|
||||
move_request.Translation = status.Position
|
||||
move_request.Translation.PanTilt.space = ptz_config["Spaces"][
|
||||
"RelativePanTiltTranslationSpace"
|
||||
][fov_space_id]["URI"]
|
||||
|
||||
# try setting relative zoom translation space
|
||||
try:
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
!= ZoomingModeEnum.disabled
|
||||
):
|
||||
try:
|
||||
if zoom_space_id is not None:
|
||||
move_request.Translation.Zoom.space = ptz_config["Spaces"][
|
||||
rel_move_request.Translation.Zoom.space = ptz_config["Spaces"][
|
||||
"RelativeZoomTranslationSpace"
|
||||
][zoom_space_id]["URI"]
|
||||
else:
|
||||
if (
|
||||
move_request["Translation"] is not None
|
||||
and "Zoom" in move_request["Translation"]
|
||||
):
|
||||
del move_request["Translation"]["Zoom"]
|
||||
if (
|
||||
move_request["Speed"] is not None
|
||||
and "Zoom" in move_request["Speed"]
|
||||
):
|
||||
del move_request["Speed"]["Zoom"]
|
||||
logger.debug(
|
||||
f"{camera_name}: Relative move request after deleting zoom: {move_request}"
|
||||
except Exception as e:
|
||||
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.config.cameras[
|
||||
camera_name
|
||||
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
|
||||
else:
|
||||
# remove zoom fields from relative move request
|
||||
if (
|
||||
rel_move_request["Translation"] is not None
|
||||
and "Zoom" in rel_move_request["Translation"]
|
||||
):
|
||||
del rel_move_request["Translation"]["Zoom"]
|
||||
if (
|
||||
rel_move_request["Speed"] is not None
|
||||
and "Zoom" in rel_move_request["Speed"]
|
||||
):
|
||||
del rel_move_request["Speed"]["Zoom"]
|
||||
logger.debug(
|
||||
f"{camera_name}: Relative move request after deleting zoom: {rel_move_request}"
|
||||
)
|
||||
|
||||
if move_request.Speed is None:
|
||||
move_request.Speed = configs.DefaultPTZSpeed if configs else None
|
||||
if rel_move_request.Speed is None:
|
||||
rel_move_request.Speed = configs.DefaultPTZSpeed if configs else None
|
||||
logger.debug(
|
||||
f"{camera_name}: Relative move request after setup: {move_request}"
|
||||
f"{camera_name}: Relative move request after setup: {rel_move_request}"
|
||||
)
|
||||
self.cams[camera_name]["relative_move_request"] = move_request
|
||||
self.cams[camera_name]["relative_move_request"] = rel_move_request
|
||||
|
||||
# setup absolute moving request for autotracking zooming
|
||||
move_request = ptz.create_type("AbsoluteMove")
|
||||
move_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["absolute_move_request"] = move_request
|
||||
# setup absolute move request
|
||||
abs_move_request = ptz.create_type("AbsoluteMove")
|
||||
abs_move_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["absolute_move_request"] = abs_move_request
|
||||
|
||||
# setup existing presets
|
||||
try:
|
||||
@@ -358,48 +483,48 @@ class OnvifController:
|
||||
|
||||
if configs.DefaultRelativeZoomTranslationSpace:
|
||||
supported_features.append("zoom-r")
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
|
||||
and self.config.cameras[camera_name].onvif.autotracking.enabled
|
||||
):
|
||||
if ptz_config is not None:
|
||||
try:
|
||||
# get camera's zoom limits from onvif config
|
||||
self.cams[camera_name]["relative_zoom_range"] = (
|
||||
ptz_config.Spaces.RelativeZoomTranslationSpace[0]
|
||||
)
|
||||
except Exception as e:
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
== ZoomingModeEnum.relative
|
||||
):
|
||||
self.config.cameras[
|
||||
camera_name
|
||||
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
|
||||
if autotracking_config.zooming == ZoomingModeEnum.relative:
|
||||
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
|
||||
)
|
||||
|
||||
if configs.DefaultAbsoluteZoomPositionSpace:
|
||||
supported_features.append("zoom-a")
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
|
||||
and self.config.cameras[camera_name].onvif.autotracking.enabled
|
||||
):
|
||||
if ptz_config is not None:
|
||||
try:
|
||||
# get camera's zoom limits from onvif config
|
||||
self.cams[camera_name]["absolute_zoom_range"] = (
|
||||
ptz_config.Spaces.AbsoluteZoomPositionSpace[0]
|
||||
)
|
||||
self.cams[camera_name]["zoom_limits"] = configs.ZoomLimits
|
||||
except Exception as e:
|
||||
if self.config.cameras[camera_name].onvif.autotracking.zooming:
|
||||
self.config.cameras[
|
||||
camera_name
|
||||
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
|
||||
if autotracking_config.zooming != ZoomingModeEnum.disabled:
|
||||
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
|
||||
)
|
||||
|
||||
# disable autotracking zoom if required ranges are unavailable
|
||||
if autotracking_config.zooming != ZoomingModeEnum.disabled:
|
||||
if autotracking_config.zooming == ZoomingModeEnum.relative:
|
||||
if "relative_zoom_range" not in self.cams[camera_name]:
|
||||
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom range unavailable"
|
||||
)
|
||||
if autotracking_config.zooming == ZoomingModeEnum.absolute:
|
||||
if "absolute_zoom_range" not in self.cams[camera_name]:
|
||||
autotracking_config.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Absolute zoom range unavailable"
|
||||
)
|
||||
|
||||
if (
|
||||
self.cams[camera_name]["video_source_token"] is not None
|
||||
and imaging is not None
|
||||
@@ -416,10 +541,9 @@ class OnvifController:
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.debug(f"Focus not supported for {camera_name}: {e}")
|
||||
|
||||
# detect FOV relative movement support
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.enabled_in_config
|
||||
and self.config.cameras[camera_name].onvif.autotracking.enabled
|
||||
and fov_space_id is not None
|
||||
fov_space_id is not None
|
||||
and configs.DefaultRelativePanTiltTranslationSpace is not None
|
||||
):
|
||||
supported_features.append("pt-r-fov")
|
||||
@@ -548,11 +672,8 @@ class OnvifController:
|
||||
move_request.Translation.PanTilt.x = pan
|
||||
move_request.Translation.PanTilt.y = tilt
|
||||
|
||||
if (
|
||||
"zoom-r" in self.cams[camera_name]["features"]
|
||||
and self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
== ZoomingModeEnum.relative
|
||||
):
|
||||
# include zoom if requested and camera supports relative zoom
|
||||
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
||||
move_request.Speed = {
|
||||
"PanTilt": {
|
||||
"x": speed,
|
||||
@@ -560,7 +681,7 @@ class OnvifController:
|
||||
},
|
||||
"Zoom": {"x": speed},
|
||||
}
|
||||
move_request.Translation.Zoom.x = zoom
|
||||
move_request["Translation"]["Zoom"] = {"x": zoom}
|
||||
|
||||
await self.cams[camera_name]["ptz"].RelativeMove(move_request)
|
||||
|
||||
@@ -568,12 +689,8 @@ class OnvifController:
|
||||
move_request.Translation.PanTilt.x = 0
|
||||
move_request.Translation.PanTilt.y = 0
|
||||
|
||||
if (
|
||||
"zoom-r" in self.cams[camera_name]["features"]
|
||||
and self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
== ZoomingModeEnum.relative
|
||||
):
|
||||
move_request.Translation.Zoom.x = 0
|
||||
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
|
||||
del move_request["Translation"]["Zoom"]
|
||||
|
||||
self.cams[camera_name]["active"] = False
|
||||
|
||||
@@ -717,8 +834,18 @@ class OnvifController:
|
||||
elif command == OnvifCommandEnum.preset:
|
||||
await self._move_to_preset(camera_name, param)
|
||||
elif command == OnvifCommandEnum.move_relative:
|
||||
_, pan, tilt = param.split("_")
|
||||
await self._move_relative(camera_name, float(pan), float(tilt), 0, 1)
|
||||
parts = param.split("_")
|
||||
if len(parts) == 3:
|
||||
_, pan, tilt = parts
|
||||
zoom = 0.0
|
||||
elif len(parts) == 4:
|
||||
_, pan, tilt, zoom = parts
|
||||
else:
|
||||
logger.error(f"Invalid move_relative params: {param}")
|
||||
return
|
||||
await self._move_relative(
|
||||
camera_name, float(pan), float(tilt), float(zoom), 1
|
||||
)
|
||||
elif command in (OnvifCommandEnum.zoom_in, OnvifCommandEnum.zoom_out):
|
||||
await self._zoom(camera_name, command)
|
||||
elif command in (OnvifCommandEnum.focus_in, OnvifCommandEnum.focus_out):
|
||||
@@ -773,6 +900,7 @@ class OnvifController:
|
||||
"name": camera_name,
|
||||
"features": self.cams[camera_name]["features"],
|
||||
"presets": list(self.cams[camera_name]["presets"].keys()),
|
||||
"profiles": self.cams[camera_name].get("profiles", []),
|
||||
}
|
||||
|
||||
if camera_name not in self.cams.keys() and camera_name in self.config.cameras:
|
||||
@@ -970,6 +1098,7 @@ class OnvifController:
|
||||
return
|
||||
|
||||
logger.info("Exiting ONVIF controller...")
|
||||
self.config_subscriber.stop()
|
||||
|
||||
def stop_and_cleanup():
|
||||
try:
|
||||
|
||||
+12
-10
@@ -7,6 +7,7 @@ import os
|
||||
import threading
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
|
||||
@@ -60,7 +61,9 @@ class RecordingCleanup(threading.Thread):
|
||||
db.execute_sql("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
db.close()
|
||||
|
||||
def expire_review_segments(self, config: CameraConfig, now: datetime) -> set[Path]:
|
||||
def expire_review_segments(
|
||||
self, config: CameraConfig, now: datetime.datetime
|
||||
) -> set[Path]:
|
||||
"""Delete review segments that are expired"""
|
||||
alert_expire_date = (
|
||||
now - datetime.timedelta(days=config.record.alerts.retain.days)
|
||||
@@ -68,7 +71,7 @@ class RecordingCleanup(threading.Thread):
|
||||
detection_expire_date = (
|
||||
now - datetime.timedelta(days=config.record.detections.retain.days)
|
||||
).timestamp()
|
||||
expired_reviews: ReviewSegment = (
|
||||
expired_reviews = (
|
||||
ReviewSegment.select(ReviewSegment.id, ReviewSegment.thumb_path)
|
||||
.where(ReviewSegment.camera == config.name)
|
||||
.where(
|
||||
@@ -109,13 +112,13 @@ class RecordingCleanup(threading.Thread):
|
||||
continuous_expire_date: float,
|
||||
motion_expire_date: float,
|
||||
config: CameraConfig,
|
||||
reviews: ReviewSegment,
|
||||
reviews: list[Any],
|
||||
) -> set[Path]:
|
||||
"""Delete recordings for existing camera based on retention config."""
|
||||
# Get the timestamp for cutoff of retained days
|
||||
|
||||
# Get recordings to check for expiration
|
||||
recordings: Recordings = (
|
||||
recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.start_time,
|
||||
@@ -148,13 +151,12 @@ class RecordingCleanup(threading.Thread):
|
||||
review_start = 0
|
||||
deleted_recordings = set()
|
||||
kept_recordings: list[tuple[float, float]] = []
|
||||
recording: Recordings
|
||||
for recording in recordings:
|
||||
keep = False
|
||||
mode = None
|
||||
# Now look for a reason to keep this recording segment
|
||||
for idx in range(review_start, len(reviews)):
|
||||
review: ReviewSegment = reviews[idx]
|
||||
review = reviews[idx]
|
||||
severity = review.severity
|
||||
pre_capture = config.record.get_review_pre_capture(severity)
|
||||
post_capture = config.record.get_review_post_capture(severity)
|
||||
@@ -214,7 +216,7 @@ class RecordingCleanup(threading.Thread):
|
||||
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
||||
).execute()
|
||||
|
||||
previews: list[Previews] = (
|
||||
previews = (
|
||||
Previews.select(
|
||||
Previews.id,
|
||||
Previews.start_time,
|
||||
@@ -290,13 +292,13 @@ class RecordingCleanup(threading.Thread):
|
||||
expire_before = (
|
||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||
).timestamp()
|
||||
no_camera_recordings: Recordings = (
|
||||
no_camera_recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.path,
|
||||
)
|
||||
.where(
|
||||
Recordings.camera.not_in(list(self.config.cameras.keys())),
|
||||
Recordings.camera.not_in(list(self.config.cameras.keys())), # type: ignore[call-arg, arg-type, misc]
|
||||
Recordings.end_time < expire_before,
|
||||
)
|
||||
.namedtuples()
|
||||
@@ -341,7 +343,7 @@ class RecordingCleanup(threading.Thread):
|
||||
).timestamp()
|
||||
|
||||
# Get all the reviews to check against
|
||||
reviews: ReviewSegment = (
|
||||
reviews = (
|
||||
ReviewSegment.select(
|
||||
ReviewSegment.start_time,
|
||||
ReviewSegment.end_time,
|
||||
|
||||
+61
-21
@@ -36,9 +36,53 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
|
||||
TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey"
|
||||
|
||||
# ffmpeg flags that can read from or write to arbitrary files
|
||||
BLOCKED_FFMPEG_ARGS = frozenset(
|
||||
{
|
||||
"-i",
|
||||
"-filter_script",
|
||||
"-vstats_file",
|
||||
"-passlogfile",
|
||||
"-sdp_file",
|
||||
"-dump_attachment",
|
||||
}
|
||||
)
|
||||
|
||||
def lower_priority():
|
||||
os.nice(PROCESS_PRIORITY_LOW)
|
||||
|
||||
def validate_ffmpeg_args(args: str) -> tuple[bool, str]:
|
||||
"""Validate that user-provided ffmpeg args don't allow input/output injection.
|
||||
|
||||
Blocks:
|
||||
- The -i flag and other flags that read/write arbitrary files
|
||||
- Absolute/relative file paths (potential extra outputs)
|
||||
- URLs and ffmpeg protocol references (data exfiltration)
|
||||
"""
|
||||
if not args or not args.strip():
|
||||
return True, ""
|
||||
|
||||
tokens = args.split()
|
||||
for token in tokens:
|
||||
# Block flags that could inject inputs or write to arbitrary files
|
||||
if token.lower() in BLOCKED_FFMPEG_ARGS:
|
||||
return False, f"Forbidden ffmpeg argument: {token}"
|
||||
|
||||
# Block tokens that look like file paths (potential output injection)
|
||||
if (
|
||||
token.startswith("/")
|
||||
or token.startswith("./")
|
||||
or token.startswith("../")
|
||||
or token.startswith("~")
|
||||
):
|
||||
return False, "File paths are not allowed in custom ffmpeg arguments"
|
||||
|
||||
# Block URLs and ffmpeg protocol references (e.g. http://, tcp://, pipe:, file:)
|
||||
if "://" in token or token.startswith("pipe:") or token.startswith("file:"):
|
||||
return (
|
||||
False,
|
||||
"Protocol references are not allowed in custom ffmpeg arguments",
|
||||
)
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
class PlaybackSourceEnum(str, Enum):
|
||||
@@ -102,7 +146,7 @@ class RecordingExporter(threading.Thread):
|
||||
):
|
||||
# has preview mp4
|
||||
try:
|
||||
preview: Previews = (
|
||||
preview = (
|
||||
Previews.select(
|
||||
Previews.camera,
|
||||
Previews.path,
|
||||
@@ -156,9 +200,9 @@ class RecordingExporter(threading.Thread):
|
||||
else:
|
||||
# need to generate from existing images
|
||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||
file_start = f"preview_{self.camera}"
|
||||
start_file = f"{file_start}-{self.start_time}.{PREVIEW_FRAME_TYPE}"
|
||||
end_file = f"{file_start}-{self.end_time}.{PREVIEW_FRAME_TYPE}"
|
||||
file_start = f"preview_{self.camera}-"
|
||||
start_file = f"{file_start}{self.start_time}.{PREVIEW_FRAME_TYPE}"
|
||||
end_file = f"{file_start}{self.end_time}.{PREVIEW_FRAME_TYPE}"
|
||||
selected_preview = None
|
||||
|
||||
for file in sorted(os.listdir(preview_dir)):
|
||||
@@ -183,20 +227,19 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
def get_record_export_command(
|
||||
self, video_path: str, use_hwaccel: bool = True
|
||||
) -> list[str]:
|
||||
) -> tuple[list[str], str | list[str]]:
|
||||
# handle case where internal port is a string with ip:port
|
||||
internal_port = self.config.networking.listen.internal
|
||||
if type(internal_port) is str:
|
||||
internal_port = int(internal_port.split(":")[-1])
|
||||
|
||||
playlist_lines: list[str] = []
|
||||
if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS:
|
||||
playlist_lines = f"http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
|
||||
playlist_url = f"http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
|
||||
ffmpeg_input = (
|
||||
f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_lines}"
|
||||
f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_url}"
|
||||
)
|
||||
else:
|
||||
playlist_lines = []
|
||||
|
||||
# get full set of recordings
|
||||
export_recordings = (
|
||||
Recordings.select(
|
||||
@@ -257,16 +300,16 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
def get_preview_export_command(
|
||||
self, video_path: str, use_hwaccel: bool = True
|
||||
) -> list[str]:
|
||||
) -> tuple[list[str], list[str]]:
|
||||
playlist_lines = []
|
||||
codec = "-c copy"
|
||||
|
||||
if is_current_hour(self.start_time):
|
||||
# get list of current preview frames
|
||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||
file_start = f"preview_{self.camera}"
|
||||
start_file = f"{file_start}-{self.start_time}.{PREVIEW_FRAME_TYPE}"
|
||||
end_file = f"{file_start}-{self.end_time}.{PREVIEW_FRAME_TYPE}"
|
||||
file_start = f"preview_{self.camera}-"
|
||||
start_file = f"{file_start}{self.start_time}.{PREVIEW_FRAME_TYPE}"
|
||||
end_file = f"{file_start}{self.end_time}.{PREVIEW_FRAME_TYPE}"
|
||||
|
||||
for file in sorted(os.listdir(preview_dir)):
|
||||
if not file.startswith(file_start):
|
||||
@@ -307,7 +350,6 @@ class RecordingExporter(threading.Thread):
|
||||
.iterator()
|
||||
)
|
||||
|
||||
preview: Previews
|
||||
for preview in export_previews:
|
||||
playlist_lines.append(f"file '{preview.path}'")
|
||||
|
||||
@@ -393,10 +435,9 @@ class RecordingExporter(threading.Thread):
|
||||
return
|
||||
|
||||
p = sp.run(
|
||||
ffmpeg_cmd,
|
||||
["nice", "-n", str(PROCESS_PRIORITY_LOW)] + ffmpeg_cmd,
|
||||
input="\n".join(playlist_lines),
|
||||
encoding="ascii",
|
||||
preexec_fn=lower_priority,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
@@ -421,10 +462,9 @@ class RecordingExporter(threading.Thread):
|
||||
)
|
||||
|
||||
p = sp.run(
|
||||
ffmpeg_cmd,
|
||||
["nice", "-n", str(PROCESS_PRIORITY_LOW)] + ffmpeg_cmd,
|
||||
input="\n".join(playlist_lines),
|
||||
encoding="ascii",
|
||||
preexec_fn=lower_priority,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
@@ -445,7 +485,7 @@ class RecordingExporter(threading.Thread):
|
||||
logger.debug(f"Finished exporting {video_path}")
|
||||
|
||||
|
||||
def migrate_exports(ffmpeg: FfmpegConfig, camera_names: list[str]):
|
||||
def migrate_exports(ffmpeg: FfmpegConfig, camera_names: list[str]) -> None:
|
||||
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
|
||||
|
||||
exports = []
|
||||
|
||||
@@ -266,7 +266,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
|
||||
# get all reviews with the end time after the start of the oldest cache file
|
||||
# or with end_time None
|
||||
reviews: ReviewSegment = (
|
||||
reviews = (
|
||||
ReviewSegment.select(
|
||||
ReviewSegment.start_time,
|
||||
ReviewSegment.end_time,
|
||||
@@ -301,7 +301,9 @@ class RecordingMaintainer(threading.Thread):
|
||||
RecordingsDataTypeEnum.saved.value,
|
||||
)
|
||||
|
||||
recordings_to_insert: list[Optional[Recordings]] = await asyncio.gather(*tasks)
|
||||
recordings_to_insert: list[Optional[dict[str, Any]]] = await asyncio.gather(
|
||||
*tasks
|
||||
)
|
||||
|
||||
# fire and forget recordings entries
|
||||
self.requestor.send_data(
|
||||
@@ -314,8 +316,8 @@ class RecordingMaintainer(threading.Thread):
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
|
||||
async def validate_and_move_segment(
|
||||
self, camera: str, reviews: list[ReviewSegment], recording: dict[str, Any]
|
||||
) -> Optional[Recordings]:
|
||||
self, camera: str, reviews: Any, recording: dict[str, Any]
|
||||
) -> Optional[dict[str, Any]]:
|
||||
cache_path: str = recording["cache_path"]
|
||||
start_time: datetime.datetime = recording["start_time"]
|
||||
|
||||
@@ -456,6 +458,8 @@ class RecordingMaintainer(threading.Thread):
|
||||
if end_time < retain_cutoff:
|
||||
self.drop_segment(cache_path)
|
||||
|
||||
return None
|
||||
|
||||
def _compute_motion_heatmap(
|
||||
self, camera: str, motion_boxes: list[tuple[int, int, int, int]]
|
||||
) -> dict[str, int] | None:
|
||||
@@ -481,7 +485,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
frame_width = camera_config.detect.width
|
||||
frame_height = camera_config.detect.height
|
||||
|
||||
if frame_width <= 0 or frame_height <= 0:
|
||||
if not frame_width or frame_width <= 0 or not frame_height or frame_height <= 0:
|
||||
return None
|
||||
|
||||
GRID_SIZE = 16
|
||||
@@ -575,13 +579,13 @@ class RecordingMaintainer(threading.Thread):
|
||||
duration: float,
|
||||
cache_path: str,
|
||||
store_mode: RetainModeEnum,
|
||||
) -> Optional[Recordings]:
|
||||
) -> Optional[dict[str, Any]]:
|
||||
segment_info = self.segment_stats(camera, start_time, end_time)
|
||||
|
||||
# check if the segment shouldn't be stored
|
||||
if segment_info.should_discard_segment(store_mode):
|
||||
self.drop_segment(cache_path)
|
||||
return
|
||||
return None
|
||||
|
||||
# directory will be in utc due to start_time being in utc
|
||||
directory = os.path.join(
|
||||
@@ -620,7 +624,8 @@ class RecordingMaintainer(threading.Thread):
|
||||
|
||||
if p.returncode != 0:
|
||||
logger.error(f"Unable to convert {cache_path} to {file_path}")
|
||||
logger.error((await p.stderr.read()).decode("ascii"))
|
||||
if p.stderr:
|
||||
logger.error((await p.stderr.read()).decode("ascii"))
|
||||
return None
|
||||
else:
|
||||
logger.debug(
|
||||
@@ -684,11 +689,16 @@ class RecordingMaintainer(threading.Thread):
|
||||
stale_frame_count_threshold = 10
|
||||
# empty the object recordings info queue
|
||||
while True:
|
||||
(topic, data) = self.detection_subscriber.check_for_update(
|
||||
result = self.detection_subscriber.check_for_update(
|
||||
timeout=FAST_QUEUE_TIMEOUT
|
||||
)
|
||||
|
||||
if not topic:
|
||||
if not result:
|
||||
break
|
||||
|
||||
topic, data = result
|
||||
|
||||
if not topic or not data:
|
||||
break
|
||||
|
||||
if topic == DetectionTypeEnum.video.value:
|
||||
|
||||
@@ -31,7 +31,7 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.models import ReviewSegment
|
||||
from frigate.review.types import SeverityEnum
|
||||
from frigate.track.object_processing import ManualEventState, TrackedObject
|
||||
from frigate.track.object_processing import ManualEventState
|
||||
from frigate.util.image import SharedMemoryFrameManager, calculate_16_9_crop
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -69,7 +69,9 @@ class PendingReviewSegment:
|
||||
self.last_alert_time = frame_time
|
||||
|
||||
# thumbnail
|
||||
self._frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8)
|
||||
self._frame: np.ndarray[Any, Any] = np.zeros(
|
||||
(THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8
|
||||
)
|
||||
self.has_frame = False
|
||||
self.frame_active_count = 0
|
||||
self.frame_path = os.path.join(
|
||||
@@ -77,8 +79,11 @@ class PendingReviewSegment:
|
||||
)
|
||||
|
||||
def update_frame(
|
||||
self, camera_config: CameraConfig, frame, objects: list[TrackedObject]
|
||||
):
|
||||
self,
|
||||
camera_config: CameraConfig,
|
||||
frame: np.ndarray,
|
||||
objects: list[dict[str, Any]],
|
||||
) -> None:
|
||||
min_x = camera_config.frame_shape[1]
|
||||
min_y = camera_config.frame_shape[0]
|
||||
max_x = 0
|
||||
@@ -114,7 +119,7 @@ class PendingReviewSegment:
|
||||
self.frame_path, self._frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60]
|
||||
)
|
||||
|
||||
def save_full_frame(self, camera_config: CameraConfig, frame):
|
||||
def save_full_frame(self, camera_config: CameraConfig, frame: np.ndarray) -> None:
|
||||
color_frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
|
||||
width = int(THUMB_HEIGHT * color_frame.shape[1] / color_frame.shape[0])
|
||||
self._frame = cv2.resize(
|
||||
@@ -165,13 +170,13 @@ class ActiveObjects:
|
||||
self,
|
||||
frame_time: float,
|
||||
camera_config: CameraConfig,
|
||||
all_objects: list[TrackedObject],
|
||||
all_objects: list[dict[str, Any]],
|
||||
):
|
||||
self.camera_config = camera_config
|
||||
|
||||
# get current categorization of objects to know if
|
||||
# these objects are currently being categorized
|
||||
self.categorized_objects = {
|
||||
self.categorized_objects: dict[str, list[dict[str, Any]]] = {
|
||||
"alerts": [],
|
||||
"detections": [],
|
||||
}
|
||||
@@ -250,7 +255,7 @@ class ActiveObjects:
|
||||
|
||||
return False
|
||||
|
||||
def get_all_objects(self) -> list[TrackedObject]:
|
||||
def get_all_objects(self) -> list[dict[str, Any]]:
|
||||
return (
|
||||
self.categorized_objects["alerts"] + self.categorized_objects["detections"]
|
||||
)
|
||||
@@ -309,7 +314,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
"reviews",
|
||||
json.dumps(review_update),
|
||||
)
|
||||
self.review_publisher.publish(review_update, segment.camera)
|
||||
self.review_publisher.publish(review_update, segment.camera) # type: ignore[arg-type]
|
||||
self.requestor.send_data(
|
||||
f"{segment.camera}/review_status", segment.severity.value.upper()
|
||||
)
|
||||
@@ -318,8 +323,8 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
self,
|
||||
segment: PendingReviewSegment,
|
||||
camera_config: CameraConfig,
|
||||
frame,
|
||||
objects: list[TrackedObject],
|
||||
frame: Optional[np.ndarray],
|
||||
objects: list[dict[str, Any]],
|
||||
prev_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update segment."""
|
||||
@@ -337,7 +342,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
"reviews",
|
||||
json.dumps(review_update),
|
||||
)
|
||||
self.review_publisher.publish(review_update, segment.camera)
|
||||
self.review_publisher.publish(review_update, segment.camera) # type: ignore[arg-type]
|
||||
self.requestor.send_data(
|
||||
f"{segment.camera}/review_status", segment.severity.value.upper()
|
||||
)
|
||||
@@ -346,7 +351,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
self,
|
||||
segment: PendingReviewSegment,
|
||||
prev_data: dict[str, Any],
|
||||
) -> float:
|
||||
) -> Any:
|
||||
"""End segment."""
|
||||
final_data = segment.get_data(ended=True)
|
||||
end_time = final_data[ReviewSegment.end_time.name]
|
||||
@@ -360,24 +365,25 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
"reviews",
|
||||
json.dumps(review_update),
|
||||
)
|
||||
self.review_publisher.publish(review_update, segment.camera)
|
||||
self.review_publisher.publish(review_update, segment.camera) # type: ignore[arg-type]
|
||||
self.requestor.send_data(f"{segment.camera}/review_status", "NONE")
|
||||
self.active_review_segments[segment.camera] = None
|
||||
return end_time
|
||||
|
||||
def forcibly_end_segment(self, camera: str) -> float:
|
||||
def forcibly_end_segment(self, camera: str) -> Any:
|
||||
"""Forcibly end the pending segment for a camera."""
|
||||
segment = self.active_review_segments.get(camera)
|
||||
if segment:
|
||||
prev_data = segment.get_data(False)
|
||||
return self._publish_segment_end(segment, prev_data)
|
||||
return None
|
||||
|
||||
def update_existing_segment(
|
||||
self,
|
||||
segment: PendingReviewSegment,
|
||||
frame_name: str,
|
||||
frame_time: float,
|
||||
objects: list[TrackedObject],
|
||||
objects: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Validate if existing review segment should continue."""
|
||||
camera_config = self.config.cameras[segment.camera]
|
||||
@@ -492,8 +498,11 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
if segment.severity == SeverityEnum.alert and frame_time > (
|
||||
segment.last_alert_time + camera_config.review.alerts.cutoff_time
|
||||
if (
|
||||
segment.severity == SeverityEnum.alert
|
||||
and segment.last_alert_time is not None
|
||||
and frame_time
|
||||
> (segment.last_alert_time + camera_config.review.alerts.cutoff_time)
|
||||
):
|
||||
needs_new_detection = (
|
||||
segment.last_detection_time > segment.last_alert_time
|
||||
@@ -516,23 +525,18 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
new_zones.update(o["current_zones"])
|
||||
|
||||
if new_detections:
|
||||
self.active_review_segments[activity.camera_config.name] = (
|
||||
PendingReviewSegment(
|
||||
activity.camera_config.name,
|
||||
end_time,
|
||||
SeverityEnum.detection,
|
||||
new_detections,
|
||||
sub_labels={},
|
||||
audio=set(),
|
||||
zones=list(new_zones),
|
||||
)
|
||||
new_segment = PendingReviewSegment(
|
||||
segment.camera,
|
||||
end_time,
|
||||
SeverityEnum.detection,
|
||||
new_detections,
|
||||
sub_labels={},
|
||||
audio=set(),
|
||||
zones=list(new_zones),
|
||||
)
|
||||
self._publish_segment_start(
|
||||
self.active_review_segments[activity.camera_config.name]
|
||||
)
|
||||
self.active_review_segments[
|
||||
activity.camera_config.name
|
||||
].last_detection_time = last_detection_time
|
||||
self.active_review_segments[segment.camera] = new_segment
|
||||
self._publish_segment_start(new_segment)
|
||||
new_segment.last_detection_time = last_detection_time
|
||||
elif segment.severity == SeverityEnum.detection and frame_time > (
|
||||
segment.last_detection_time
|
||||
+ camera_config.review.detections.cutoff_time
|
||||
@@ -544,7 +548,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
camera: str,
|
||||
frame_name: str,
|
||||
frame_time: float,
|
||||
objects: list[TrackedObject],
|
||||
objects: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Check if a new review segment should be created."""
|
||||
camera_config = self.config.cameras[camera]
|
||||
@@ -581,7 +585,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
zones.append(zone)
|
||||
|
||||
if severity:
|
||||
self.active_review_segments[camera] = PendingReviewSegment(
|
||||
new_segment = PendingReviewSegment(
|
||||
camera,
|
||||
frame_time,
|
||||
severity,
|
||||
@@ -590,6 +594,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
audio=set(),
|
||||
zones=zones,
|
||||
)
|
||||
self.active_review_segments[camera] = new_segment
|
||||
|
||||
try:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
@@ -600,11 +605,11 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
logger.debug(f"Failed to get frame {frame_name} from SHM")
|
||||
return
|
||||
|
||||
self.active_review_segments[camera].update_frame(
|
||||
new_segment.update_frame(
|
||||
camera_config, yuv_frame, activity.get_all_objects()
|
||||
)
|
||||
self.frame_manager.close(frame_name)
|
||||
self._publish_segment_start(self.active_review_segments[camera])
|
||||
self._publish_segment_start(new_segment)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
@@ -621,9 +626,14 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
for camera in updated_topics["enabled"]:
|
||||
self.forcibly_end_segment(camera)
|
||||
|
||||
(topic, data) = self.detection_subscriber.check_for_update(timeout=1)
|
||||
result = self.detection_subscriber.check_for_update(timeout=1)
|
||||
|
||||
if not topic:
|
||||
if not result:
|
||||
continue
|
||||
|
||||
topic, data = result
|
||||
|
||||
if not topic or not data:
|
||||
continue
|
||||
|
||||
if topic == DetectionTypeEnum.video.value:
|
||||
@@ -712,10 +722,13 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
if topic == DetectionTypeEnum.api:
|
||||
# manual_info["label"] contains 'label: sub_label'
|
||||
# so split out the label without modifying manual_info
|
||||
det_labels = self.config.cameras[
|
||||
camera
|
||||
].review.detections.labels
|
||||
if (
|
||||
self.config.cameras[camera].review.detections.enabled
|
||||
and manual_info["label"].split(": ")[0]
|
||||
in self.config.cameras[camera].review.detections.labels
|
||||
and det_labels is not None
|
||||
and manual_info["label"].split(": ")[0] in det_labels
|
||||
):
|
||||
current_segment.last_detection_time = manual_info[
|
||||
"end_time"
|
||||
@@ -744,14 +757,15 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
):
|
||||
# manual_info["label"] contains 'label: sub_label'
|
||||
# so split out the label without modifying manual_info
|
||||
det_labels = self.config.cameras[
|
||||
camera
|
||||
].review.detections.labels
|
||||
if (
|
||||
not self.config.cameras[
|
||||
camera
|
||||
].review.detections.enabled
|
||||
or manual_info["label"].split(": ")[0]
|
||||
not in self.config.cameras[
|
||||
camera
|
||||
].review.detections.labels
|
||||
or det_labels is None
|
||||
or manual_info["label"].split(": ")[0] not in det_labels
|
||||
):
|
||||
current_segment.severity = SeverityEnum.alert
|
||||
elif (
|
||||
@@ -828,17 +842,18 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
severity = None
|
||||
# manual_info["label"] contains 'label: sub_label'
|
||||
# so split out the label without modifying manual_info
|
||||
det_labels = self.config.cameras[camera].review.detections.labels
|
||||
if (
|
||||
self.config.cameras[camera].review.detections.enabled
|
||||
and manual_info["label"].split(": ")[0]
|
||||
in self.config.cameras[camera].review.detections.labels
|
||||
and det_labels is not None
|
||||
and manual_info["label"].split(": ")[0] in det_labels
|
||||
):
|
||||
severity = SeverityEnum.detection
|
||||
elif self.config.cameras[camera].review.alerts.enabled:
|
||||
severity = SeverityEnum.alert
|
||||
|
||||
if severity:
|
||||
self.active_review_segments[camera] = PendingReviewSegment(
|
||||
api_segment = PendingReviewSegment(
|
||||
camera,
|
||||
frame_time,
|
||||
severity,
|
||||
@@ -847,32 +862,25 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
[],
|
||||
set(),
|
||||
)
|
||||
self.active_review_segments[camera] = api_segment
|
||||
|
||||
if manual_info["state"] == ManualEventState.start:
|
||||
self.indefinite_events[camera][manual_info["event_id"]] = (
|
||||
manual_info["label"]
|
||||
)
|
||||
# temporarily make it so this event can not end
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_alert_time = sys.maxsize
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_detection_time = sys.maxsize
|
||||
api_segment.last_alert_time = sys.maxsize
|
||||
api_segment.last_detection_time = sys.maxsize
|
||||
elif manual_info["state"] == ManualEventState.complete:
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_alert_time = manual_info["end_time"]
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_detection_time = manual_info["end_time"]
|
||||
api_segment.last_alert_time = manual_info["end_time"]
|
||||
api_segment.last_detection_time = manual_info["end_time"]
|
||||
else:
|
||||
logger.warning(
|
||||
f"Manual event API has been called for {camera}, but alerts and detections are disabled. This manual event will not appear as an alert or detection."
|
||||
)
|
||||
elif topic == DetectionTypeEnum.lpr:
|
||||
if self.config.cameras[camera].review.detections.enabled:
|
||||
self.active_review_segments[camera] = PendingReviewSegment(
|
||||
lpr_segment = PendingReviewSegment(
|
||||
camera,
|
||||
frame_time,
|
||||
SeverityEnum.detection,
|
||||
@@ -881,25 +889,18 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
[],
|
||||
set(),
|
||||
)
|
||||
self.active_review_segments[camera] = lpr_segment
|
||||
|
||||
if manual_info["state"] == ManualEventState.start:
|
||||
self.indefinite_events[camera][manual_info["event_id"]] = (
|
||||
manual_info["label"]
|
||||
)
|
||||
# temporarily make it so this event can not end
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_alert_time = sys.maxsize
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_detection_time = sys.maxsize
|
||||
lpr_segment.last_alert_time = sys.maxsize
|
||||
lpr_segment.last_detection_time = sys.maxsize
|
||||
elif manual_info["state"] == ManualEventState.complete:
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_alert_time = manual_info["end_time"]
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_detection_time = manual_info["end_time"]
|
||||
lpr_segment.last_alert_time = manual_info["end_time"]
|
||||
lpr_segment.last_detection_time = manual_info["end_time"]
|
||||
else:
|
||||
logger.warning(
|
||||
f"Dedicated LPR camera API has been called for {camera}, but detections are disabled. LPR events will not appear as a detection."
|
||||
|
||||
@@ -19,6 +19,7 @@ from frigate.types import StatsTrackingTypes
|
||||
from frigate.util.services import (
|
||||
calculate_shm_requirements,
|
||||
get_amd_gpu_stats,
|
||||
get_axcl_npu_stats,
|
||||
get_bandwidth_stats,
|
||||
get_cpu_stats,
|
||||
get_fs_type,
|
||||
@@ -324,6 +325,10 @@ async def set_npu_usages(config: FrigateConfig, all_stats: dict[str, Any]) -> No
|
||||
# OpenVINO NPU usage
|
||||
ov_usage = get_openvino_npu_stats()
|
||||
stats["openvino"] = ov_usage
|
||||
elif detector.type == "axengine":
|
||||
# AXERA NPU usage
|
||||
axcl_usage = get_axcl_npu_stats()
|
||||
stats["axengine"] = axcl_usage
|
||||
|
||||
if stats:
|
||||
all_stats["npu_usages"] = stats
|
||||
|
||||
+6
-5
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
import shutil
|
||||
import threading
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from pathlib import Path
|
||||
|
||||
from peewee import SQL, fn
|
||||
@@ -23,7 +24,7 @@ MAX_CALCULATED_BANDWIDTH = 10000 # 10Gb/hr
|
||||
class StorageMaintainer(threading.Thread):
|
||||
"""Maintain frigates recording storage."""
|
||||
|
||||
def __init__(self, config: FrigateConfig, stop_event) -> None:
|
||||
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
|
||||
super().__init__(name="storage_maintainer")
|
||||
self.config = config
|
||||
self.stop_event = stop_event
|
||||
@@ -114,7 +115,7 @@ class StorageMaintainer(threading.Thread):
|
||||
logger.debug(
|
||||
f"Storage cleanup check: {hourly_bandwidth} hourly with remaining storage: {remaining_storage}."
|
||||
)
|
||||
return remaining_storage < hourly_bandwidth
|
||||
return remaining_storage < float(hourly_bandwidth)
|
||||
|
||||
def reduce_storage_consumption(self) -> None:
|
||||
"""Remove oldest hour of recordings."""
|
||||
@@ -124,7 +125,7 @@ class StorageMaintainer(threading.Thread):
|
||||
[b["bandwidth"] for b in self.camera_storage_stats.values()]
|
||||
)
|
||||
|
||||
recordings: Recordings = (
|
||||
recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.camera,
|
||||
@@ -138,7 +139,7 @@ class StorageMaintainer(threading.Thread):
|
||||
.iterator()
|
||||
)
|
||||
|
||||
retained_events: Event = (
|
||||
retained_events = (
|
||||
Event.select(
|
||||
Event.start_time,
|
||||
Event.end_time,
|
||||
@@ -278,7 +279,7 @@ class StorageMaintainer(threading.Thread):
|
||||
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
||||
).execute()
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
"""Check every 5 minutes if storage needs to be cleaned up."""
|
||||
if self.config.safe_mode:
|
||||
logger.info("Safe mode enabled, skipping storage maintenance")
|
||||
|
||||
@@ -10,7 +10,7 @@ from ruamel.yaml.constructor import DuplicateKeyError
|
||||
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.detectors import DetectorTypeEnum
|
||||
from frigate.util.builtin import deep_merge
|
||||
from frigate.util.builtin import deep_merge, load_labels
|
||||
|
||||
|
||||
class TestConfig(unittest.TestCase):
|
||||
@@ -288,6 +288,65 @@ class TestConfig(unittest.TestCase):
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert "dog" in frigate_config.cameras["back"].objects.filters
|
||||
|
||||
def test_default_audio_filters(self):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"audio": {"listen": ["speech", "yell"]},
|
||||
"cameras": {
|
||||
"back": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**config)
|
||||
all_audio_labels = {
|
||||
label
|
||||
for label in load_labels("/audio-labelmap.txt", prefill=521).values()
|
||||
if label
|
||||
}
|
||||
|
||||
assert all_audio_labels.issubset(
|
||||
set(frigate_config.cameras["back"].audio.filters.keys())
|
||||
)
|
||||
|
||||
def test_override_audio_filters(self):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"cameras": {
|
||||
"back": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
"audio": {
|
||||
"listen": ["speech", "yell"],
|
||||
"filters": {"speech": {"threshold": 0.9}},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert "speech" in frigate_config.cameras["back"].audio.filters
|
||||
assert frigate_config.cameras["back"].audio.filters["speech"].threshold == 0.9
|
||||
assert "babbling" in frigate_config.cameras["back"].audio.filters
|
||||
|
||||
def test_inherit_object_filters(self):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
|
||||
@@ -2,6 +2,7 @@ import unittest
|
||||
|
||||
from frigate.api.auth import resolve_role
|
||||
from frigate.config import HeaderMappingConfig, ProxyConfig
|
||||
from frigate.config.env import FRIGATE_ENV_VARS
|
||||
|
||||
|
||||
class TestProxyRoleResolution(unittest.TestCase):
|
||||
@@ -91,3 +92,39 @@ class TestProxyRoleResolution(unittest.TestCase):
|
||||
headers = {"x-remote-role": "group_unknown"}
|
||||
role = resolve_role(headers, self.proxy_config, self.config_roles)
|
||||
self.assertEqual(role, self.proxy_config.default_role)
|
||||
|
||||
|
||||
class TestProxyAuthSecretEnvString(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._original_env_vars = dict(FRIGATE_ENV_VARS)
|
||||
|
||||
def tearDown(self):
|
||||
FRIGATE_ENV_VARS.clear()
|
||||
FRIGATE_ENV_VARS.update(self._original_env_vars)
|
||||
|
||||
def test_auth_secret_env_substitution(self):
|
||||
"""auth_secret resolves FRIGATE_ env vars via EnvString."""
|
||||
FRIGATE_ENV_VARS["FRIGATE_PROXY_SECRET"] = "my_secret_value"
|
||||
config = ProxyConfig(auth_secret="{FRIGATE_PROXY_SECRET}")
|
||||
self.assertEqual(config.auth_secret, "my_secret_value")
|
||||
|
||||
def test_auth_secret_env_embedded_in_string(self):
|
||||
"""auth_secret resolves env vars embedded in a larger string."""
|
||||
FRIGATE_ENV_VARS["FRIGATE_SECRET_PART"] = "abc123"
|
||||
config = ProxyConfig(auth_secret="prefix-{FRIGATE_SECRET_PART}-suffix")
|
||||
self.assertEqual(config.auth_secret, "prefix-abc123-suffix")
|
||||
|
||||
def test_auth_secret_plain_string(self):
|
||||
"""auth_secret accepts a plain string without substitution."""
|
||||
config = ProxyConfig(auth_secret="literal_secret")
|
||||
self.assertEqual(config.auth_secret, "literal_secret")
|
||||
|
||||
def test_auth_secret_none(self):
|
||||
"""auth_secret defaults to None."""
|
||||
config = ProxyConfig()
|
||||
self.assertIsNone(config.auth_secret)
|
||||
|
||||
def test_auth_secret_unknown_var_raises(self):
|
||||
"""auth_secret raises KeyError for unknown env var references."""
|
||||
with self.assertRaises(Exception):
|
||||
ProxyConfig(auth_secret="{FRIGATE_NONEXISTENT_VAR}")
|
||||
|
||||
+10
-6
@@ -8,7 +8,7 @@ from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.events.maintainer import EventStateEnum, EventTypeEnum
|
||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||
from frigate.models import Timeline
|
||||
from frigate.util.builtin import to_relative_box
|
||||
|
||||
@@ -28,7 +28,7 @@ class TimelineProcessor(threading.Thread):
|
||||
self.config = config
|
||||
self.queue = queue
|
||||
self.stop_event = stop_event
|
||||
self.pre_event_cache: dict[str, list[dict[str, Any]]] = {}
|
||||
self.pre_event_cache: dict[str, list[dict[Any, Any]]] = {}
|
||||
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
@@ -56,7 +56,7 @@ class TimelineProcessor(threading.Thread):
|
||||
|
||||
def insert_or_save(
|
||||
self,
|
||||
entry: dict[str, Any],
|
||||
entry: dict[Any, Any],
|
||||
prev_event_data: dict[Any, Any],
|
||||
event_data: dict[Any, Any],
|
||||
) -> None:
|
||||
@@ -84,11 +84,15 @@ class TimelineProcessor(threading.Thread):
|
||||
event_type: str,
|
||||
prev_event_data: dict[Any, Any],
|
||||
event_data: dict[Any, Any],
|
||||
) -> bool:
|
||||
) -> None:
|
||||
"""Handle object detection."""
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return False
|
||||
if (
|
||||
camera_config is None
|
||||
or camera_config.detect.width is None
|
||||
or camera_config.detect.height is None
|
||||
):
|
||||
return
|
||||
event_id = event_data["id"]
|
||||
|
||||
# Base timeline entry data that all entries will share
|
||||
|
||||
@@ -81,6 +81,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
CameraConfigUpdateEnum.motion,
|
||||
CameraConfigUpdateEnum.objects,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
CameraConfigUpdateEnum.timestamp_style,
|
||||
CameraConfigUpdateEnum.zones,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -67,8 +67,8 @@ class TrackedObject:
|
||||
self.has_snapshot = False
|
||||
self.top_score = self.computed_score = 0.0
|
||||
self.thumbnail_data: dict[str, Any] | None = None
|
||||
self.last_updated = 0
|
||||
self.last_published = 0
|
||||
self.last_updated: float = 0
|
||||
self.last_published: float = 0
|
||||
self.frame = None
|
||||
self.active = True
|
||||
self.pending_loitering = False
|
||||
|
||||
@@ -12,7 +12,7 @@ import shlex
|
||||
import struct
|
||||
import urllib.parse
|
||||
from collections.abc import Mapping
|
||||
from multiprocessing.sharedctypes import Synchronized
|
||||
from multiprocessing.managers import ValueProxy
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
@@ -64,7 +64,7 @@ class EventsPerSecond:
|
||||
|
||||
|
||||
class InferenceSpeed:
|
||||
def __init__(self, metric: Synchronized) -> None:
|
||||
def __init__(self, metric: ValueProxy[float]) -> None:
|
||||
self.__metric = metric
|
||||
self.__initialized = False
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
|
||||
import cv2
|
||||
@@ -397,6 +398,8 @@ def collect_state_classification_examples(
|
||||
|
||||
# Step 5: Save to train directory for later classification
|
||||
train_dir = os.path.join(CLIPS_DIR, model_name, "train")
|
||||
if os.path.exists(train_dir):
|
||||
shutil.rmtree(train_dir)
|
||||
os.makedirs(train_dir, exist_ok=True)
|
||||
|
||||
saved_count = 0
|
||||
@@ -411,8 +414,6 @@ def collect_state_classification_examples(
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save image {image_path}: {e}")
|
||||
|
||||
import shutil
|
||||
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
except Exception as e:
|
||||
@@ -750,6 +751,8 @@ def collect_object_classification_examples(
|
||||
|
||||
# Step 5: Save to train directory for later classification
|
||||
train_dir = os.path.join(CLIPS_DIR, model_name, "train")
|
||||
if os.path.exists(train_dir):
|
||||
shutil.rmtree(train_dir)
|
||||
os.makedirs(train_dir, exist_ok=True)
|
||||
|
||||
saved_count = 0
|
||||
@@ -764,8 +767,6 @@ def collect_object_classification_examples(
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save image {image_path}: {e}")
|
||||
|
||||
import shutil
|
||||
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
except Exception as e:
|
||||
@@ -806,24 +807,25 @@ def _select_balanced_events(
|
||||
selected = []
|
||||
|
||||
for group_events in grouped.values():
|
||||
# Take top events by score, then randomly sample from them
|
||||
sorted_events = sorted(
|
||||
group_events,
|
||||
key=lambda e: e.data.get("score", 0) if e.data else 0,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
sample_size = min(samples_per_group, len(sorted_events))
|
||||
selected.extend(sorted_events[:sample_size])
|
||||
# Consider top 3x candidates to allow randomness while preferring higher scores
|
||||
candidate_pool = sorted_events[: samples_per_group * 3]
|
||||
sample_size = min(samples_per_group, len(candidate_pool))
|
||||
selected.extend(random.sample(candidate_pool, sample_size))
|
||||
|
||||
if len(selected) < target_count:
|
||||
remaining = [e for e in events if e not in selected]
|
||||
remaining_sorted = sorted(
|
||||
remaining,
|
||||
key=lambda e: e.data.get("score", 0) if e.data else 0,
|
||||
reverse=True,
|
||||
)
|
||||
needed = target_count - len(selected)
|
||||
selected.extend(remaining_sorted[:needed])
|
||||
if len(remaining) > needed:
|
||||
selected.extend(random.sample(remaining, needed))
|
||||
else:
|
||||
selected.extend(remaining)
|
||||
|
||||
return selected[:target_count]
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""FFmpeg utility functions for managing ffmpeg processes."""
|
||||
|
||||
import logging
|
||||
import subprocess as sp
|
||||
from typing import Any
|
||||
|
||||
from frigate.log import LogPipe
|
||||
|
||||
|
||||
def stop_ffmpeg(ffmpeg_process: sp.Popen[Any], logger: logging.Logger):
|
||||
logger.info("Terminating the existing ffmpeg process...")
|
||||
ffmpeg_process.terminate()
|
||||
try:
|
||||
logger.info("Waiting for ffmpeg to exit gracefully...")
|
||||
ffmpeg_process.communicate(timeout=30)
|
||||
logger.info("FFmpeg has exited")
|
||||
except sp.TimeoutExpired:
|
||||
logger.info("FFmpeg didn't exit. Force killing...")
|
||||
ffmpeg_process.kill()
|
||||
ffmpeg_process.communicate()
|
||||
logger.info("FFmpeg has been killed")
|
||||
ffmpeg_process = None
|
||||
|
||||
|
||||
def start_or_restart_ffmpeg(
|
||||
ffmpeg_cmd, logger, logpipe: LogPipe, frame_size=None, ffmpeg_process=None
|
||||
) -> sp.Popen[Any]:
|
||||
if ffmpeg_process is not None:
|
||||
stop_ffmpeg(ffmpeg_process, logger)
|
||||
|
||||
if frame_size is None:
|
||||
process = sp.Popen(
|
||||
ffmpeg_cmd,
|
||||
stdout=sp.DEVNULL,
|
||||
stderr=logpipe,
|
||||
stdin=sp.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
else:
|
||||
process = sp.Popen(
|
||||
ffmpeg_cmd,
|
||||
stdout=sp.PIPE,
|
||||
stderr=logpipe,
|
||||
stdin=sp.DEVNULL,
|
||||
bufsize=frame_size * 10,
|
||||
start_new_session=True,
|
||||
)
|
||||
return process
|
||||
+57
-1
@@ -49,6 +49,7 @@ class SyncResult:
|
||||
orphans_found: int = 0
|
||||
orphans_deleted: int = 0
|
||||
orphan_paths: list[str] = field(default_factory=list)
|
||||
orphan_db_paths: list[str] = field(default_factory=list)
|
||||
aborted: bool = False
|
||||
error: str | None = None
|
||||
|
||||
@@ -132,7 +133,7 @@ def sync_recordings(
|
||||
)
|
||||
|
||||
result.orphans_found += len(recordings_to_delete)
|
||||
result.orphan_paths.extend(
|
||||
result.orphan_db_paths.extend(
|
||||
[
|
||||
recording["path"]
|
||||
for recording in recordings_to_delete
|
||||
@@ -773,6 +774,61 @@ class MediaSyncResults:
|
||||
return results
|
||||
|
||||
|
||||
def write_orphan_report(
|
||||
results: "MediaSyncResults",
|
||||
path: str,
|
||||
job_id: str = "",
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""Write a verbose orphan report file listing all orphan paths by media type.
|
||||
|
||||
Args:
|
||||
results: The completed MediaSyncResults.
|
||||
path: File path to write the report to.
|
||||
job_id: Job ID for the report header.
|
||||
dry_run: Whether the sync was a dry run, for the report header.
|
||||
"""
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
f.write("# Media Sync Orphan Report\n")
|
||||
f.write(f"# Job: {job_id}\n")
|
||||
f.write(
|
||||
f"# Date: {datetime.datetime.now().astimezone(datetime.timezone.utc).isoformat()}\n"
|
||||
)
|
||||
f.write(f"# Mode: dry_run={dry_run}\n\n")
|
||||
|
||||
for name, result in [
|
||||
("recordings", results.recordings),
|
||||
("event_snapshots", results.event_snapshots),
|
||||
("event_thumbnails", results.event_thumbnails),
|
||||
("review_thumbnails", results.review_thumbnails),
|
||||
("previews", results.previews),
|
||||
("exports", results.exports),
|
||||
]:
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
if result.orphan_db_paths:
|
||||
f.write(
|
||||
f"## {name} - orphaned db entries ({len(result.orphan_db_paths)})\n"
|
||||
)
|
||||
for orphan_path in result.orphan_db_paths:
|
||||
f.write(f"{orphan_path}\n")
|
||||
f.write("\n")
|
||||
|
||||
if result.orphan_paths:
|
||||
f.write(
|
||||
f"## {name} - orphaned files ({len(result.orphan_paths)})\n"
|
||||
)
|
||||
for orphan_path in result.orphan_paths:
|
||||
f.write(f"{orphan_path}\n")
|
||||
f.write("\n")
|
||||
|
||||
logger.debug("Wrote verbose orphan report to %s", path)
|
||||
except OSError as e:
|
||||
logger.error("Failed to write orphan report to %s: %s", path, e)
|
||||
|
||||
|
||||
def sync_all_media(
|
||||
dry_run: bool = False, media_types: list[str] = ["all"], force: bool = False
|
||||
) -> MediaSyncResults:
|
||||
|
||||
+9
-10
@@ -271,18 +271,17 @@ def get_min_region_size(model_config: ModelConfig) -> int:
|
||||
"""Get the min region size."""
|
||||
largest_dimension = max(model_config.height, model_config.width)
|
||||
|
||||
if largest_dimension > 320:
|
||||
# We originally tested allowing any model to have a region down to half of the model size
|
||||
# but this led to many false positives. In this case we specifically target larger models
|
||||
# which can benefit from a smaller region in some cases to detect smaller objects.
|
||||
half = int(largest_dimension / 2)
|
||||
# return largest dimension for smaller models, but make sure the dimension is normalized
|
||||
if largest_dimension < 320:
|
||||
if largest_dimension % 4 == 0:
|
||||
return largest_dimension
|
||||
|
||||
if half % 4 == 0:
|
||||
return half
|
||||
return int((largest_dimension + 3) / 4) * 4
|
||||
|
||||
return int((half + 3) / 4) * 4
|
||||
|
||||
return largest_dimension
|
||||
# Any model that is 320 or larger should have a minimum region size of 320
|
||||
# this allows larger models to use smaller regions to detect smaller objects
|
||||
# in the case that the motion area is smaller so that it can be upscaled.
|
||||
return 320
|
||||
|
||||
|
||||
def create_tensor_input(frame, model_config: ModelConfig, region):
|
||||
|
||||
@@ -117,12 +117,16 @@ def get_cpu_stats() -> dict[str, dict]:
|
||||
"mem": str(system_mem.percent),
|
||||
}
|
||||
|
||||
keywords = ["ffmpeg", "go2rtc", "frigate.", "python3"]
|
||||
for process in psutil.process_iter(["pid", "name", "cpu_percent", "cmdline"]):
|
||||
pid = str(process.info["pid"])
|
||||
try:
|
||||
cpu_percent = process.info["cpu_percent"]
|
||||
cmdline = " ".join(process.info["cmdline"]).rstrip()
|
||||
|
||||
if not any(keyword in cmdline for keyword in keywords):
|
||||
continue
|
||||
|
||||
with open(f"/proc/{pid}/stat", "r") as f:
|
||||
stats = f.readline().split()
|
||||
utime = int(stats[13])
|
||||
@@ -484,6 +488,43 @@ def get_rockchip_npu_stats() -> Optional[dict[str, float | str]]:
|
||||
return stats
|
||||
|
||||
|
||||
def get_axcl_npu_stats() -> Optional[dict[str, str | float]]:
|
||||
"""Get NPU stats using axcl."""
|
||||
# Check if axcl-smi exists
|
||||
axcl_smi_path = "/usr/bin/axcl/axcl-smi"
|
||||
if not os.path.exists(axcl_smi_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
# Run axcl-smi command to get NPU stats
|
||||
axcl_command = [axcl_smi_path, "sh", "cat", "/proc/ax_proc/npu/top"]
|
||||
p = sp.run(
|
||||
axcl_command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if p.returncode != 0:
|
||||
pass
|
||||
else:
|
||||
utilization = None
|
||||
|
||||
for line in p.stdout.strip().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("utilization:"):
|
||||
match = re.search(r"utilization:(\d+)%", line)
|
||||
if match:
|
||||
utilization = float(match.group(1))
|
||||
|
||||
if utilization is not None:
|
||||
stats: dict[str, str | float] = {"npu": utilization, "mem": "-%"}
|
||||
return stats
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def try_get_info(f, h, default="N/A", sensor=None):
|
||||
try:
|
||||
if h:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from .detect import * # noqa: F403
|
||||
from .ffmpeg import * # noqa: F403
|
||||
@@ -0,0 +1,563 @@
|
||||
"""Manages camera object detection processes."""
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from multiprocessing import Queue
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
|
||||
from frigate.camera import CameraMetrics, PTZMetrics
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import CameraConfig, DetectConfig, LoggerConfig, ModelConfig
|
||||
from frigate.config.camera.camera import CameraTypeEnum
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.const import (
|
||||
PROCESS_PRIORITY_HIGH,
|
||||
REQUEST_REGION_GRID,
|
||||
)
|
||||
from frigate.motion import MotionDetector
|
||||
from frigate.motion.improved_motion import ImprovedMotionDetector
|
||||
from frigate.object_detection.base import RemoteObjectDetector
|
||||
from frigate.ptz.autotrack import ptz_moving_at_frame_time
|
||||
from frigate.track import ObjectTracker
|
||||
from frigate.track.norfair_tracker import NorfairTracker
|
||||
from frigate.track.tracked_object import TrackedObjectAttribute
|
||||
from frigate.util.builtin import EventsPerSecond
|
||||
from frigate.util.image import (
|
||||
FrameManager,
|
||||
SharedMemoryFrameManager,
|
||||
draw_box_with_label,
|
||||
)
|
||||
from frigate.util.object import (
|
||||
create_tensor_input,
|
||||
get_cluster_candidates,
|
||||
get_cluster_region,
|
||||
get_cluster_region_from_grid,
|
||||
get_min_region_size,
|
||||
get_startup_regions,
|
||||
inside_any,
|
||||
intersects_any,
|
||||
is_object_filtered,
|
||||
reduce_detections,
|
||||
)
|
||||
from frigate.util.process import FrigateProcess
|
||||
from frigate.util.time import get_tomorrow_at_time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CameraTracker(FrigateProcess):
|
||||
def __init__(
|
||||
self,
|
||||
config: CameraConfig,
|
||||
model_config: ModelConfig,
|
||||
labelmap: dict[int, str],
|
||||
detection_queue: Queue,
|
||||
detected_objects_queue,
|
||||
camera_metrics: CameraMetrics,
|
||||
ptz_metrics: PTZMetrics,
|
||||
region_grid: list[list[dict[str, Any]]],
|
||||
stop_event: MpEvent,
|
||||
log_config: LoggerConfig | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
stop_event,
|
||||
PROCESS_PRIORITY_HIGH,
|
||||
name=f"frigate.process:{config.name}",
|
||||
daemon=True,
|
||||
)
|
||||
self.config = config
|
||||
self.model_config = model_config
|
||||
self.labelmap = labelmap
|
||||
self.detection_queue = detection_queue
|
||||
self.detected_objects_queue = detected_objects_queue
|
||||
self.camera_metrics = camera_metrics
|
||||
self.ptz_metrics = ptz_metrics
|
||||
self.region_grid = region_grid
|
||||
self.log_config = log_config
|
||||
|
||||
def run(self) -> None:
|
||||
self.pre_run_setup(self.log_config)
|
||||
frame_queue = self.camera_metrics.frame_queue
|
||||
frame_shape = self.config.frame_shape
|
||||
|
||||
motion_detector = ImprovedMotionDetector(
|
||||
frame_shape,
|
||||
self.config.motion,
|
||||
self.config.detect.fps,
|
||||
name=self.config.name,
|
||||
ptz_metrics=self.ptz_metrics,
|
||||
)
|
||||
object_detector = RemoteObjectDetector(
|
||||
self.config.name,
|
||||
self.labelmap,
|
||||
self.detection_queue,
|
||||
self.model_config,
|
||||
self.stop_event,
|
||||
)
|
||||
|
||||
object_tracker = NorfairTracker(self.config, self.ptz_metrics)
|
||||
|
||||
frame_manager = SharedMemoryFrameManager()
|
||||
|
||||
# create communication for region grid updates
|
||||
requestor = InterProcessRequestor()
|
||||
|
||||
process_frames(
|
||||
requestor,
|
||||
frame_queue,
|
||||
frame_shape,
|
||||
self.model_config,
|
||||
self.config,
|
||||
frame_manager,
|
||||
motion_detector,
|
||||
object_detector,
|
||||
object_tracker,
|
||||
self.detected_objects_queue,
|
||||
self.camera_metrics,
|
||||
self.stop_event,
|
||||
self.ptz_metrics,
|
||||
self.region_grid,
|
||||
)
|
||||
|
||||
# empty the frame queue
|
||||
logger.info(f"{self.config.name}: emptying frame queue")
|
||||
while not frame_queue.empty():
|
||||
(frame_name, _) = frame_queue.get(False)
|
||||
frame_manager.delete(frame_name)
|
||||
|
||||
logger.info(f"{self.config.name}: exiting subprocess")
|
||||
|
||||
|
||||
def detect(
|
||||
detect_config: DetectConfig,
|
||||
object_detector,
|
||||
frame,
|
||||
model_config: ModelConfig,
|
||||
region,
|
||||
objects_to_track,
|
||||
object_filters,
|
||||
):
|
||||
tensor_input = create_tensor_input(frame, model_config, region)
|
||||
|
||||
detections = []
|
||||
region_detections = object_detector.detect(tensor_input)
|
||||
for d in region_detections:
|
||||
box = d[2]
|
||||
size = region[2] - region[0]
|
||||
x_min = int(max(0, (box[1] * size) + region[0]))
|
||||
y_min = int(max(0, (box[0] * size) + region[1]))
|
||||
x_max = int(min(detect_config.width - 1, (box[3] * size) + region[0]))
|
||||
y_max = int(min(detect_config.height - 1, (box[2] * size) + region[1]))
|
||||
|
||||
# ignore objects that were detected outside the frame
|
||||
if (x_min >= detect_config.width - 1) or (y_min >= detect_config.height - 1):
|
||||
continue
|
||||
|
||||
width = x_max - x_min
|
||||
height = y_max - y_min
|
||||
area = width * height
|
||||
ratio = width / max(1, height)
|
||||
det = (d[0], d[1], (x_min, y_min, x_max, y_max), area, ratio, region)
|
||||
# apply object filters
|
||||
if is_object_filtered(det, objects_to_track, object_filters):
|
||||
continue
|
||||
detections.append(det)
|
||||
return detections
|
||||
|
||||
|
||||
def process_frames(
|
||||
requestor: InterProcessRequestor,
|
||||
frame_queue: Queue,
|
||||
frame_shape: tuple[int, int],
|
||||
model_config: ModelConfig,
|
||||
camera_config: CameraConfig,
|
||||
frame_manager: FrameManager,
|
||||
motion_detector: MotionDetector,
|
||||
object_detector: RemoteObjectDetector,
|
||||
object_tracker: ObjectTracker,
|
||||
detected_objects_queue: Queue,
|
||||
camera_metrics: CameraMetrics,
|
||||
stop_event: MpEvent,
|
||||
ptz_metrics: PTZMetrics,
|
||||
region_grid: list[list[dict[str, Any]]],
|
||||
exit_on_empty: bool = False,
|
||||
):
|
||||
next_region_update = get_tomorrow_at_time(2)
|
||||
config_subscriber = CameraConfigUpdateSubscriber(
|
||||
None,
|
||||
{camera_config.name: camera_config},
|
||||
[
|
||||
CameraConfigUpdateEnum.detect,
|
||||
CameraConfigUpdateEnum.enabled,
|
||||
CameraConfigUpdateEnum.motion,
|
||||
CameraConfigUpdateEnum.objects,
|
||||
],
|
||||
)
|
||||
|
||||
fps_tracker = EventsPerSecond()
|
||||
fps_tracker.start()
|
||||
|
||||
startup_scan = True
|
||||
stationary_frame_counter = 0
|
||||
camera_enabled = True
|
||||
|
||||
region_min_size = get_min_region_size(model_config)
|
||||
|
||||
attributes_map = model_config.attributes_map
|
||||
all_attributes = model_config.all_attributes
|
||||
|
||||
# remove license_plate from attributes if this camera is a dedicated LPR cam
|
||||
if camera_config.type == CameraTypeEnum.lpr:
|
||||
modified_attributes_map = model_config.attributes_map.copy()
|
||||
|
||||
if (
|
||||
"car" in modified_attributes_map
|
||||
and "license_plate" in modified_attributes_map["car"]
|
||||
):
|
||||
modified_attributes_map["car"] = [
|
||||
attr
|
||||
for attr in modified_attributes_map["car"]
|
||||
if attr != "license_plate"
|
||||
]
|
||||
|
||||
attributes_map = modified_attributes_map
|
||||
|
||||
all_attributes = [
|
||||
attr for attr in model_config.all_attributes if attr != "license_plate"
|
||||
]
|
||||
|
||||
while not stop_event.is_set():
|
||||
updated_configs = config_subscriber.check_for_updates()
|
||||
|
||||
if "enabled" in updated_configs:
|
||||
prev_enabled = camera_enabled
|
||||
camera_enabled = camera_config.enabled
|
||||
|
||||
if "motion" in updated_configs:
|
||||
motion_detector.config = camera_config.motion
|
||||
motion_detector.update_mask()
|
||||
|
||||
if (
|
||||
not camera_enabled
|
||||
and prev_enabled != camera_enabled
|
||||
and camera_metrics.frame_queue.empty()
|
||||
):
|
||||
logger.debug(
|
||||
f"Camera {camera_config.name} disabled, clearing tracked objects"
|
||||
)
|
||||
prev_enabled = camera_enabled
|
||||
|
||||
# Clear norfair's dictionaries
|
||||
object_tracker.tracked_objects.clear()
|
||||
object_tracker.disappeared.clear()
|
||||
object_tracker.stationary_box_history.clear()
|
||||
object_tracker.positions.clear()
|
||||
object_tracker.track_id_map.clear()
|
||||
|
||||
# Clear internal norfair states
|
||||
for trackers_by_type in object_tracker.trackers.values():
|
||||
for tracker in trackers_by_type.values():
|
||||
tracker.tracked_objects = []
|
||||
for tracker in object_tracker.default_tracker.values():
|
||||
tracker.tracked_objects = []
|
||||
|
||||
if not camera_enabled:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
if datetime.now().astimezone(timezone.utc) > next_region_update:
|
||||
region_grid = requestor.send_data(REQUEST_REGION_GRID, camera_config.name)
|
||||
next_region_update = get_tomorrow_at_time(2)
|
||||
|
||||
try:
|
||||
if exit_on_empty:
|
||||
frame_name, frame_time = frame_queue.get(False)
|
||||
else:
|
||||
frame_name, frame_time = frame_queue.get(True, 1)
|
||||
except queue.Empty:
|
||||
if exit_on_empty:
|
||||
logger.info("Exiting track_objects...")
|
||||
break
|
||||
continue
|
||||
|
||||
camera_metrics.detection_frame.value = frame_time
|
||||
ptz_metrics.frame_time.value = frame_time
|
||||
|
||||
frame = frame_manager.get(frame_name, (frame_shape[0] * 3 // 2, frame_shape[1]))
|
||||
|
||||
if frame is None:
|
||||
logger.debug(
|
||||
f"{camera_config.name}: frame {frame_time} is not in memory store."
|
||||
)
|
||||
continue
|
||||
|
||||
# look for motion if enabled
|
||||
motion_boxes = motion_detector.detect(frame)
|
||||
|
||||
regions = []
|
||||
consolidated_detections = []
|
||||
|
||||
# if detection is disabled
|
||||
if not camera_config.detect.enabled:
|
||||
object_tracker.match_and_update(frame_name, frame_time, [])
|
||||
else:
|
||||
# get stationary object ids
|
||||
# check every Nth frame for stationary objects
|
||||
# disappeared objects are not stationary
|
||||
# also check for overlapping motion boxes
|
||||
if stationary_frame_counter == camera_config.detect.stationary.interval:
|
||||
stationary_frame_counter = 0
|
||||
stationary_object_ids = []
|
||||
else:
|
||||
stationary_frame_counter += 1
|
||||
stationary_object_ids = [
|
||||
obj["id"]
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
# if it has exceeded the stationary threshold
|
||||
if obj["motionless_count"]
|
||||
>= camera_config.detect.stationary.threshold
|
||||
# and it hasn't disappeared
|
||||
and object_tracker.disappeared[obj["id"]] == 0
|
||||
# and it doesn't overlap with any current motion boxes when not calibrating
|
||||
and not intersects_any(
|
||||
obj["box"],
|
||||
[] if motion_detector.is_calibrating() else motion_boxes,
|
||||
)
|
||||
]
|
||||
|
||||
# get tracked object boxes that aren't stationary
|
||||
tracked_object_boxes = [
|
||||
(
|
||||
# use existing object box for stationary objects
|
||||
obj["estimate"]
|
||||
if obj["motionless_count"]
|
||||
< camera_config.detect.stationary.threshold
|
||||
else obj["box"]
|
||||
)
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
if obj["id"] not in stationary_object_ids
|
||||
]
|
||||
object_boxes = tracked_object_boxes + object_tracker.untracked_object_boxes
|
||||
|
||||
# get consolidated regions for tracked objects
|
||||
regions = [
|
||||
get_cluster_region(
|
||||
frame_shape, region_min_size, candidate, object_boxes
|
||||
)
|
||||
for candidate in get_cluster_candidates(
|
||||
frame_shape, region_min_size, object_boxes
|
||||
)
|
||||
]
|
||||
|
||||
# only add in the motion boxes when not calibrating and a ptz is not moving via autotracking
|
||||
# ptz_moving_at_frame_time() always returns False for non-autotracking cameras
|
||||
if not motion_detector.is_calibrating() and not ptz_moving_at_frame_time(
|
||||
frame_time,
|
||||
ptz_metrics.start_time.value,
|
||||
ptz_metrics.stop_time.value,
|
||||
):
|
||||
# find motion boxes that are not inside tracked object regions
|
||||
standalone_motion_boxes = [
|
||||
b for b in motion_boxes if not inside_any(b, regions)
|
||||
]
|
||||
|
||||
if standalone_motion_boxes:
|
||||
motion_clusters = get_cluster_candidates(
|
||||
frame_shape,
|
||||
region_min_size,
|
||||
standalone_motion_boxes,
|
||||
)
|
||||
motion_regions = [
|
||||
get_cluster_region_from_grid(
|
||||
frame_shape,
|
||||
region_min_size,
|
||||
candidate,
|
||||
standalone_motion_boxes,
|
||||
region_grid,
|
||||
)
|
||||
for candidate in motion_clusters
|
||||
]
|
||||
regions += motion_regions
|
||||
|
||||
# if starting up, get the next startup scan region
|
||||
if startup_scan:
|
||||
for region in get_startup_regions(
|
||||
frame_shape, region_min_size, region_grid
|
||||
):
|
||||
regions.append(region)
|
||||
startup_scan = False
|
||||
|
||||
# resize regions and detect
|
||||
# seed with stationary objects
|
||||
detections = [
|
||||
(
|
||||
obj["label"],
|
||||
obj["score"],
|
||||
obj["box"],
|
||||
obj["area"],
|
||||
obj["ratio"],
|
||||
obj["region"],
|
||||
)
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
if obj["id"] in stationary_object_ids
|
||||
]
|
||||
|
||||
for region in regions:
|
||||
detections.extend(
|
||||
detect(
|
||||
camera_config.detect,
|
||||
object_detector,
|
||||
frame,
|
||||
model_config,
|
||||
region,
|
||||
camera_config.objects.track,
|
||||
camera_config.objects.filters,
|
||||
)
|
||||
)
|
||||
|
||||
consolidated_detections = reduce_detections(frame_shape, detections)
|
||||
|
||||
# if detection was run on this frame, consolidate
|
||||
if len(regions) > 0:
|
||||
tracked_detections = [
|
||||
d for d in consolidated_detections if d[0] not in all_attributes
|
||||
]
|
||||
# now that we have refined our detections, we need to track objects
|
||||
object_tracker.match_and_update(
|
||||
frame_name, frame_time, tracked_detections
|
||||
)
|
||||
# else, just update the frame times for the stationary objects
|
||||
else:
|
||||
object_tracker.update_frame_times(frame_name, frame_time)
|
||||
|
||||
# group the attribute detections based on what label they apply to
|
||||
attribute_detections: dict[str, list[TrackedObjectAttribute]] = {}
|
||||
for label, attribute_labels in attributes_map.items():
|
||||
attribute_detections[label] = [
|
||||
TrackedObjectAttribute(d)
|
||||
for d in consolidated_detections
|
||||
if d[0] in attribute_labels
|
||||
]
|
||||
|
||||
# build detections
|
||||
detections = {}
|
||||
for obj in object_tracker.tracked_objects.values():
|
||||
detections[obj["id"]] = {**obj, "attributes": []}
|
||||
|
||||
# find the best object for each attribute to be assigned to
|
||||
all_objects: list[dict[str, Any]] = object_tracker.tracked_objects.values()
|
||||
for attributes in attribute_detections.values():
|
||||
for attribute in attributes:
|
||||
filtered_objects = filter(
|
||||
lambda o: attribute.label in attributes_map.get(o["label"], []),
|
||||
all_objects,
|
||||
)
|
||||
selected_object_id = attribute.find_best_object(filtered_objects)
|
||||
|
||||
if selected_object_id is not None:
|
||||
detections[selected_object_id]["attributes"].append(
|
||||
attribute.get_tracking_data()
|
||||
)
|
||||
|
||||
# debug object tracking
|
||||
if False:
|
||||
bgr_frame = cv2.cvtColor(
|
||||
frame,
|
||||
cv2.COLOR_YUV2BGR_I420,
|
||||
)
|
||||
object_tracker.debug_draw(bgr_frame, frame_time)
|
||||
cv2.imwrite(
|
||||
f"debug/frames/track-{'{:.6f}'.format(frame_time)}.jpg", bgr_frame
|
||||
)
|
||||
# debug
|
||||
if False:
|
||||
bgr_frame = cv2.cvtColor(
|
||||
frame,
|
||||
cv2.COLOR_YUV2BGR_I420,
|
||||
)
|
||||
|
||||
for m_box in motion_boxes:
|
||||
cv2.rectangle(
|
||||
bgr_frame,
|
||||
(m_box[0], m_box[1]),
|
||||
(m_box[2], m_box[3]),
|
||||
(0, 0, 255),
|
||||
2,
|
||||
)
|
||||
|
||||
for b in tracked_object_boxes:
|
||||
cv2.rectangle(
|
||||
bgr_frame,
|
||||
(b[0], b[1]),
|
||||
(b[2], b[3]),
|
||||
(255, 0, 0),
|
||||
2,
|
||||
)
|
||||
|
||||
for obj in object_tracker.tracked_objects.values():
|
||||
if obj["frame_time"] == frame_time:
|
||||
thickness = 2
|
||||
color = model_config.colormap.get(obj["label"], (255, 255, 255))
|
||||
else:
|
||||
thickness = 1
|
||||
color = (255, 0, 0)
|
||||
|
||||
# draw the bounding boxes on the frame
|
||||
box = obj["box"]
|
||||
|
||||
draw_box_with_label(
|
||||
bgr_frame,
|
||||
box[0],
|
||||
box[1],
|
||||
box[2],
|
||||
box[3],
|
||||
obj["label"],
|
||||
obj["id"],
|
||||
thickness=thickness,
|
||||
color=color,
|
||||
)
|
||||
|
||||
for region in regions:
|
||||
cv2.rectangle(
|
||||
bgr_frame,
|
||||
(region[0], region[1]),
|
||||
(region[2], region[3]),
|
||||
(0, 255, 0),
|
||||
2,
|
||||
)
|
||||
|
||||
cv2.imwrite(
|
||||
f"debug/frames/{camera_config.name}-{'{:.6f}'.format(frame_time)}.jpg",
|
||||
bgr_frame,
|
||||
)
|
||||
# add to the queue if not full
|
||||
if detected_objects_queue.full():
|
||||
frame_manager.close(frame_name)
|
||||
continue
|
||||
else:
|
||||
fps_tracker.update()
|
||||
camera_metrics.process_fps.value = fps_tracker.eps()
|
||||
detected_objects_queue.put(
|
||||
(
|
||||
camera_config.name,
|
||||
frame_name,
|
||||
frame_time,
|
||||
detections,
|
||||
motion_boxes,
|
||||
regions,
|
||||
)
|
||||
)
|
||||
camera_metrics.detection_fps.value = object_detector.fps.eps()
|
||||
frame_manager.close(frame_name)
|
||||
|
||||
motion_detector.stop()
|
||||
requestor.stop()
|
||||
config_subscriber.stop()
|
||||
Executable → Regular
+6
-581
@@ -1,3 +1,5 @@
|
||||
"""Manages ffmpeg processes for camera frame capture."""
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import subprocess as sp
|
||||
@@ -9,97 +11,30 @@ from multiprocessing import Queue, Value
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
|
||||
from frigate.camera import CameraMetrics, PTZMetrics
|
||||
from frigate.camera import CameraMetrics
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.comms.recordings_updater import (
|
||||
RecordingsDataSubscriber,
|
||||
RecordingsDataTypeEnum,
|
||||
)
|
||||
from frigate.config import CameraConfig, DetectConfig, LoggerConfig, ModelConfig
|
||||
from frigate.config.camera.camera import CameraTypeEnum
|
||||
from frigate.config import CameraConfig, LoggerConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.const import (
|
||||
PROCESS_PRIORITY_HIGH,
|
||||
REQUEST_REGION_GRID,
|
||||
)
|
||||
from frigate.const import PROCESS_PRIORITY_HIGH
|
||||
from frigate.log import LogPipe
|
||||
from frigate.motion import MotionDetector
|
||||
from frigate.motion.improved_motion import ImprovedMotionDetector
|
||||
from frigate.object_detection.base import RemoteObjectDetector
|
||||
from frigate.ptz.autotrack import ptz_moving_at_frame_time
|
||||
from frigate.track import ObjectTracker
|
||||
from frigate.track.norfair_tracker import NorfairTracker
|
||||
from frigate.track.tracked_object import TrackedObjectAttribute
|
||||
from frigate.util.builtin import EventsPerSecond
|
||||
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
|
||||
from frigate.util.image import (
|
||||
FrameManager,
|
||||
SharedMemoryFrameManager,
|
||||
draw_box_with_label,
|
||||
)
|
||||
from frigate.util.object import (
|
||||
create_tensor_input,
|
||||
get_cluster_candidates,
|
||||
get_cluster_region,
|
||||
get_cluster_region_from_grid,
|
||||
get_min_region_size,
|
||||
get_startup_regions,
|
||||
inside_any,
|
||||
intersects_any,
|
||||
is_object_filtered,
|
||||
reduce_detections,
|
||||
)
|
||||
from frigate.util.process import FrigateProcess
|
||||
from frigate.util.time import get_tomorrow_at_time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def stop_ffmpeg(ffmpeg_process: sp.Popen[Any], logger: logging.Logger):
|
||||
logger.info("Terminating the existing ffmpeg process...")
|
||||
ffmpeg_process.terminate()
|
||||
try:
|
||||
logger.info("Waiting for ffmpeg to exit gracefully...")
|
||||
ffmpeg_process.communicate(timeout=30)
|
||||
logger.info("FFmpeg has exited")
|
||||
except sp.TimeoutExpired:
|
||||
logger.info("FFmpeg didn't exit. Force killing...")
|
||||
ffmpeg_process.kill()
|
||||
ffmpeg_process.communicate()
|
||||
logger.info("FFmpeg has been killed")
|
||||
ffmpeg_process = None
|
||||
|
||||
|
||||
def start_or_restart_ffmpeg(
|
||||
ffmpeg_cmd, logger, logpipe: LogPipe, frame_size=None, ffmpeg_process=None
|
||||
) -> sp.Popen[Any]:
|
||||
if ffmpeg_process is not None:
|
||||
stop_ffmpeg(ffmpeg_process, logger)
|
||||
|
||||
if frame_size is None:
|
||||
process = sp.Popen(
|
||||
ffmpeg_cmd,
|
||||
stdout=sp.DEVNULL,
|
||||
stderr=logpipe,
|
||||
stdin=sp.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
else:
|
||||
process = sp.Popen(
|
||||
ffmpeg_cmd,
|
||||
stdout=sp.PIPE,
|
||||
stderr=logpipe,
|
||||
stdin=sp.DEVNULL,
|
||||
bufsize=frame_size * 10,
|
||||
start_new_session=True,
|
||||
)
|
||||
return process
|
||||
|
||||
|
||||
def capture_frames(
|
||||
ffmpeg_process: sp.Popen[Any],
|
||||
config: CameraConfig,
|
||||
@@ -708,513 +643,3 @@ class CameraCapture(FrigateProcess):
|
||||
)
|
||||
camera_watchdog.start()
|
||||
camera_watchdog.join()
|
||||
|
||||
|
||||
class CameraTracker(FrigateProcess):
|
||||
def __init__(
|
||||
self,
|
||||
config: CameraConfig,
|
||||
model_config: ModelConfig,
|
||||
labelmap: dict[int, str],
|
||||
detection_queue: Queue,
|
||||
detected_objects_queue,
|
||||
camera_metrics: CameraMetrics,
|
||||
ptz_metrics: PTZMetrics,
|
||||
region_grid: list[list[dict[str, Any]]],
|
||||
stop_event: MpEvent,
|
||||
log_config: LoggerConfig | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
stop_event,
|
||||
PROCESS_PRIORITY_HIGH,
|
||||
name=f"frigate.process:{config.name}",
|
||||
daemon=True,
|
||||
)
|
||||
self.config = config
|
||||
self.model_config = model_config
|
||||
self.labelmap = labelmap
|
||||
self.detection_queue = detection_queue
|
||||
self.detected_objects_queue = detected_objects_queue
|
||||
self.camera_metrics = camera_metrics
|
||||
self.ptz_metrics = ptz_metrics
|
||||
self.region_grid = region_grid
|
||||
self.log_config = log_config
|
||||
|
||||
def run(self) -> None:
|
||||
self.pre_run_setup(self.log_config)
|
||||
frame_queue = self.camera_metrics.frame_queue
|
||||
frame_shape = self.config.frame_shape
|
||||
|
||||
motion_detector = ImprovedMotionDetector(
|
||||
frame_shape,
|
||||
self.config.motion,
|
||||
self.config.detect.fps,
|
||||
name=self.config.name,
|
||||
ptz_metrics=self.ptz_metrics,
|
||||
)
|
||||
object_detector = RemoteObjectDetector(
|
||||
self.config.name,
|
||||
self.labelmap,
|
||||
self.detection_queue,
|
||||
self.model_config,
|
||||
self.stop_event,
|
||||
)
|
||||
|
||||
object_tracker = NorfairTracker(self.config, self.ptz_metrics)
|
||||
|
||||
frame_manager = SharedMemoryFrameManager()
|
||||
|
||||
# create communication for region grid updates
|
||||
requestor = InterProcessRequestor()
|
||||
|
||||
process_frames(
|
||||
requestor,
|
||||
frame_queue,
|
||||
frame_shape,
|
||||
self.model_config,
|
||||
self.config,
|
||||
frame_manager,
|
||||
motion_detector,
|
||||
object_detector,
|
||||
object_tracker,
|
||||
self.detected_objects_queue,
|
||||
self.camera_metrics,
|
||||
self.stop_event,
|
||||
self.ptz_metrics,
|
||||
self.region_grid,
|
||||
)
|
||||
|
||||
# empty the frame queue
|
||||
logger.info(f"{self.config.name}: emptying frame queue")
|
||||
while not frame_queue.empty():
|
||||
(frame_name, _) = frame_queue.get(False)
|
||||
frame_manager.delete(frame_name)
|
||||
|
||||
logger.info(f"{self.config.name}: exiting subprocess")
|
||||
|
||||
|
||||
def detect(
|
||||
detect_config: DetectConfig,
|
||||
object_detector,
|
||||
frame,
|
||||
model_config: ModelConfig,
|
||||
region,
|
||||
objects_to_track,
|
||||
object_filters,
|
||||
):
|
||||
tensor_input = create_tensor_input(frame, model_config, region)
|
||||
|
||||
detections = []
|
||||
region_detections = object_detector.detect(tensor_input)
|
||||
for d in region_detections:
|
||||
box = d[2]
|
||||
size = region[2] - region[0]
|
||||
x_min = int(max(0, (box[1] * size) + region[0]))
|
||||
y_min = int(max(0, (box[0] * size) + region[1]))
|
||||
x_max = int(min(detect_config.width - 1, (box[3] * size) + region[0]))
|
||||
y_max = int(min(detect_config.height - 1, (box[2] * size) + region[1]))
|
||||
|
||||
# ignore objects that were detected outside the frame
|
||||
if (x_min >= detect_config.width - 1) or (y_min >= detect_config.height - 1):
|
||||
continue
|
||||
|
||||
width = x_max - x_min
|
||||
height = y_max - y_min
|
||||
area = width * height
|
||||
ratio = width / max(1, height)
|
||||
det = (d[0], d[1], (x_min, y_min, x_max, y_max), area, ratio, region)
|
||||
# apply object filters
|
||||
if is_object_filtered(det, objects_to_track, object_filters):
|
||||
continue
|
||||
detections.append(det)
|
||||
return detections
|
||||
|
||||
|
||||
def process_frames(
|
||||
requestor: InterProcessRequestor,
|
||||
frame_queue: Queue,
|
||||
frame_shape: tuple[int, int],
|
||||
model_config: ModelConfig,
|
||||
camera_config: CameraConfig,
|
||||
frame_manager: FrameManager,
|
||||
motion_detector: MotionDetector,
|
||||
object_detector: RemoteObjectDetector,
|
||||
object_tracker: ObjectTracker,
|
||||
detected_objects_queue: Queue,
|
||||
camera_metrics: CameraMetrics,
|
||||
stop_event: MpEvent,
|
||||
ptz_metrics: PTZMetrics,
|
||||
region_grid: list[list[dict[str, Any]]],
|
||||
exit_on_empty: bool = False,
|
||||
):
|
||||
next_region_update = get_tomorrow_at_time(2)
|
||||
config_subscriber = CameraConfigUpdateSubscriber(
|
||||
None,
|
||||
{camera_config.name: camera_config},
|
||||
[
|
||||
CameraConfigUpdateEnum.detect,
|
||||
CameraConfigUpdateEnum.enabled,
|
||||
CameraConfigUpdateEnum.motion,
|
||||
CameraConfigUpdateEnum.objects,
|
||||
],
|
||||
)
|
||||
|
||||
fps_tracker = EventsPerSecond()
|
||||
fps_tracker.start()
|
||||
|
||||
startup_scan = True
|
||||
stationary_frame_counter = 0
|
||||
camera_enabled = True
|
||||
|
||||
region_min_size = get_min_region_size(model_config)
|
||||
|
||||
attributes_map = model_config.attributes_map
|
||||
all_attributes = model_config.all_attributes
|
||||
|
||||
# remove license_plate from attributes if this camera is a dedicated LPR cam
|
||||
if camera_config.type == CameraTypeEnum.lpr:
|
||||
modified_attributes_map = model_config.attributes_map.copy()
|
||||
|
||||
if (
|
||||
"car" in modified_attributes_map
|
||||
and "license_plate" in modified_attributes_map["car"]
|
||||
):
|
||||
modified_attributes_map["car"] = [
|
||||
attr
|
||||
for attr in modified_attributes_map["car"]
|
||||
if attr != "license_plate"
|
||||
]
|
||||
|
||||
attributes_map = modified_attributes_map
|
||||
|
||||
all_attributes = [
|
||||
attr for attr in model_config.all_attributes if attr != "license_plate"
|
||||
]
|
||||
|
||||
while not stop_event.is_set():
|
||||
updated_configs = config_subscriber.check_for_updates()
|
||||
|
||||
if "enabled" in updated_configs:
|
||||
prev_enabled = camera_enabled
|
||||
camera_enabled = camera_config.enabled
|
||||
|
||||
if "motion" in updated_configs:
|
||||
motion_detector.config = camera_config.motion
|
||||
motion_detector.update_mask()
|
||||
|
||||
if (
|
||||
not camera_enabled
|
||||
and prev_enabled != camera_enabled
|
||||
and camera_metrics.frame_queue.empty()
|
||||
):
|
||||
logger.debug(
|
||||
f"Camera {camera_config.name} disabled, clearing tracked objects"
|
||||
)
|
||||
prev_enabled = camera_enabled
|
||||
|
||||
# Clear norfair's dictionaries
|
||||
object_tracker.tracked_objects.clear()
|
||||
object_tracker.disappeared.clear()
|
||||
object_tracker.stationary_box_history.clear()
|
||||
object_tracker.positions.clear()
|
||||
object_tracker.track_id_map.clear()
|
||||
|
||||
# Clear internal norfair states
|
||||
for trackers_by_type in object_tracker.trackers.values():
|
||||
for tracker in trackers_by_type.values():
|
||||
tracker.tracked_objects = []
|
||||
for tracker in object_tracker.default_tracker.values():
|
||||
tracker.tracked_objects = []
|
||||
|
||||
if not camera_enabled:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
if datetime.now().astimezone(timezone.utc) > next_region_update:
|
||||
region_grid = requestor.send_data(REQUEST_REGION_GRID, camera_config.name)
|
||||
next_region_update = get_tomorrow_at_time(2)
|
||||
|
||||
try:
|
||||
if exit_on_empty:
|
||||
frame_name, frame_time = frame_queue.get(False)
|
||||
else:
|
||||
frame_name, frame_time = frame_queue.get(True, 1)
|
||||
except queue.Empty:
|
||||
if exit_on_empty:
|
||||
logger.info("Exiting track_objects...")
|
||||
break
|
||||
continue
|
||||
|
||||
camera_metrics.detection_frame.value = frame_time
|
||||
ptz_metrics.frame_time.value = frame_time
|
||||
|
||||
frame = frame_manager.get(frame_name, (frame_shape[0] * 3 // 2, frame_shape[1]))
|
||||
|
||||
if frame is None:
|
||||
logger.debug(
|
||||
f"{camera_config.name}: frame {frame_time} is not in memory store."
|
||||
)
|
||||
continue
|
||||
|
||||
# look for motion if enabled
|
||||
motion_boxes = motion_detector.detect(frame)
|
||||
|
||||
regions = []
|
||||
consolidated_detections = []
|
||||
|
||||
# if detection is disabled
|
||||
if not camera_config.detect.enabled:
|
||||
object_tracker.match_and_update(frame_name, frame_time, [])
|
||||
else:
|
||||
# get stationary object ids
|
||||
# check every Nth frame for stationary objects
|
||||
# disappeared objects are not stationary
|
||||
# also check for overlapping motion boxes
|
||||
if stationary_frame_counter == camera_config.detect.stationary.interval:
|
||||
stationary_frame_counter = 0
|
||||
stationary_object_ids = []
|
||||
else:
|
||||
stationary_frame_counter += 1
|
||||
stationary_object_ids = [
|
||||
obj["id"]
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
# if it has exceeded the stationary threshold
|
||||
if obj["motionless_count"]
|
||||
>= camera_config.detect.stationary.threshold
|
||||
# and it hasn't disappeared
|
||||
and object_tracker.disappeared[obj["id"]] == 0
|
||||
# and it doesn't overlap with any current motion boxes when not calibrating
|
||||
and not intersects_any(
|
||||
obj["box"],
|
||||
[] if motion_detector.is_calibrating() else motion_boxes,
|
||||
)
|
||||
]
|
||||
|
||||
# get tracked object boxes that aren't stationary
|
||||
tracked_object_boxes = [
|
||||
(
|
||||
# use existing object box for stationary objects
|
||||
obj["estimate"]
|
||||
if obj["motionless_count"]
|
||||
< camera_config.detect.stationary.threshold
|
||||
else obj["box"]
|
||||
)
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
if obj["id"] not in stationary_object_ids
|
||||
]
|
||||
object_boxes = tracked_object_boxes + object_tracker.untracked_object_boxes
|
||||
|
||||
# get consolidated regions for tracked objects
|
||||
regions = [
|
||||
get_cluster_region(
|
||||
frame_shape, region_min_size, candidate, object_boxes
|
||||
)
|
||||
for candidate in get_cluster_candidates(
|
||||
frame_shape, region_min_size, object_boxes
|
||||
)
|
||||
]
|
||||
|
||||
# only add in the motion boxes when not calibrating and a ptz is not moving via autotracking
|
||||
# ptz_moving_at_frame_time() always returns False for non-autotracking cameras
|
||||
if not motion_detector.is_calibrating() and not ptz_moving_at_frame_time(
|
||||
frame_time,
|
||||
ptz_metrics.start_time.value,
|
||||
ptz_metrics.stop_time.value,
|
||||
):
|
||||
# find motion boxes that are not inside tracked object regions
|
||||
standalone_motion_boxes = [
|
||||
b for b in motion_boxes if not inside_any(b, regions)
|
||||
]
|
||||
|
||||
if standalone_motion_boxes:
|
||||
motion_clusters = get_cluster_candidates(
|
||||
frame_shape,
|
||||
region_min_size,
|
||||
standalone_motion_boxes,
|
||||
)
|
||||
motion_regions = [
|
||||
get_cluster_region_from_grid(
|
||||
frame_shape,
|
||||
region_min_size,
|
||||
candidate,
|
||||
standalone_motion_boxes,
|
||||
region_grid,
|
||||
)
|
||||
for candidate in motion_clusters
|
||||
]
|
||||
regions += motion_regions
|
||||
|
||||
# if starting up, get the next startup scan region
|
||||
if startup_scan:
|
||||
for region in get_startup_regions(
|
||||
frame_shape, region_min_size, region_grid
|
||||
):
|
||||
regions.append(region)
|
||||
startup_scan = False
|
||||
|
||||
# resize regions and detect
|
||||
# seed with stationary objects
|
||||
detections = [
|
||||
(
|
||||
obj["label"],
|
||||
obj["score"],
|
||||
obj["box"],
|
||||
obj["area"],
|
||||
obj["ratio"],
|
||||
obj["region"],
|
||||
)
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
if obj["id"] in stationary_object_ids
|
||||
]
|
||||
|
||||
for region in regions:
|
||||
detections.extend(
|
||||
detect(
|
||||
camera_config.detect,
|
||||
object_detector,
|
||||
frame,
|
||||
model_config,
|
||||
region,
|
||||
camera_config.objects.track,
|
||||
camera_config.objects.filters,
|
||||
)
|
||||
)
|
||||
|
||||
consolidated_detections = reduce_detections(frame_shape, detections)
|
||||
|
||||
# if detection was run on this frame, consolidate
|
||||
if len(regions) > 0:
|
||||
tracked_detections = [
|
||||
d for d in consolidated_detections if d[0] not in all_attributes
|
||||
]
|
||||
# now that we have refined our detections, we need to track objects
|
||||
object_tracker.match_and_update(
|
||||
frame_name, frame_time, tracked_detections
|
||||
)
|
||||
# else, just update the frame times for the stationary objects
|
||||
else:
|
||||
object_tracker.update_frame_times(frame_name, frame_time)
|
||||
|
||||
# group the attribute detections based on what label they apply to
|
||||
attribute_detections: dict[str, list[TrackedObjectAttribute]] = {}
|
||||
for label, attribute_labels in attributes_map.items():
|
||||
attribute_detections[label] = [
|
||||
TrackedObjectAttribute(d)
|
||||
for d in consolidated_detections
|
||||
if d[0] in attribute_labels
|
||||
]
|
||||
|
||||
# build detections
|
||||
detections = {}
|
||||
for obj in object_tracker.tracked_objects.values():
|
||||
detections[obj["id"]] = {**obj, "attributes": []}
|
||||
|
||||
# find the best object for each attribute to be assigned to
|
||||
all_objects: list[dict[str, Any]] = object_tracker.tracked_objects.values()
|
||||
for attributes in attribute_detections.values():
|
||||
for attribute in attributes:
|
||||
filtered_objects = filter(
|
||||
lambda o: attribute.label in attributes_map.get(o["label"], []),
|
||||
all_objects,
|
||||
)
|
||||
selected_object_id = attribute.find_best_object(filtered_objects)
|
||||
|
||||
if selected_object_id is not None:
|
||||
detections[selected_object_id]["attributes"].append(
|
||||
attribute.get_tracking_data()
|
||||
)
|
||||
|
||||
# debug object tracking
|
||||
if False:
|
||||
bgr_frame = cv2.cvtColor(
|
||||
frame,
|
||||
cv2.COLOR_YUV2BGR_I420,
|
||||
)
|
||||
object_tracker.debug_draw(bgr_frame, frame_time)
|
||||
cv2.imwrite(
|
||||
f"debug/frames/track-{'{:.6f}'.format(frame_time)}.jpg", bgr_frame
|
||||
)
|
||||
# debug
|
||||
if False:
|
||||
bgr_frame = cv2.cvtColor(
|
||||
frame,
|
||||
cv2.COLOR_YUV2BGR_I420,
|
||||
)
|
||||
|
||||
for m_box in motion_boxes:
|
||||
cv2.rectangle(
|
||||
bgr_frame,
|
||||
(m_box[0], m_box[1]),
|
||||
(m_box[2], m_box[3]),
|
||||
(0, 0, 255),
|
||||
2,
|
||||
)
|
||||
|
||||
for b in tracked_object_boxes:
|
||||
cv2.rectangle(
|
||||
bgr_frame,
|
||||
(b[0], b[1]),
|
||||
(b[2], b[3]),
|
||||
(255, 0, 0),
|
||||
2,
|
||||
)
|
||||
|
||||
for obj in object_tracker.tracked_objects.values():
|
||||
if obj["frame_time"] == frame_time:
|
||||
thickness = 2
|
||||
color = model_config.colormap.get(obj["label"], (255, 255, 255))
|
||||
else:
|
||||
thickness = 1
|
||||
color = (255, 0, 0)
|
||||
|
||||
# draw the bounding boxes on the frame
|
||||
box = obj["box"]
|
||||
|
||||
draw_box_with_label(
|
||||
bgr_frame,
|
||||
box[0],
|
||||
box[1],
|
||||
box[2],
|
||||
box[3],
|
||||
obj["label"],
|
||||
obj["id"],
|
||||
thickness=thickness,
|
||||
color=color,
|
||||
)
|
||||
|
||||
for region in regions:
|
||||
cv2.rectangle(
|
||||
bgr_frame,
|
||||
(region[0], region[1]),
|
||||
(region[2], region[3]),
|
||||
(0, 255, 0),
|
||||
2,
|
||||
)
|
||||
|
||||
cv2.imwrite(
|
||||
f"debug/frames/{camera_config.name}-{'{:.6f}'.format(frame_time)}.jpg",
|
||||
bgr_frame,
|
||||
)
|
||||
# add to the queue if not full
|
||||
if detected_objects_queue.full():
|
||||
frame_manager.close(frame_name)
|
||||
continue
|
||||
else:
|
||||
fps_tracker.update()
|
||||
camera_metrics.process_fps.value = fps_tracker.eps()
|
||||
detected_objects_queue.put(
|
||||
(
|
||||
camera_config.name,
|
||||
frame_name,
|
||||
frame_time,
|
||||
detections,
|
||||
motion_boxes,
|
||||
regions,
|
||||
)
|
||||
)
|
||||
camera_metrics.detection_fps.value = object_detector.fps.eps()
|
||||
frame_manager.close(frame_name)
|
||||
|
||||
motion_detector.stop()
|
||||
requestor.stop()
|
||||
config_subscriber.stop()
|
||||
@@ -0,0 +1,51 @@
|
||||
import { defineConfig, type Plugin } from "i18next-cli";
|
||||
|
||||
/**
|
||||
* Plugin to remove false positive keys generated by dynamic namespace patterns
|
||||
* like useTranslation([i18nLibrary]) and t("key", { ns: configNamespace }).
|
||||
* These keys already exist in their correct runtime namespaces.
|
||||
*/
|
||||
function ignoreDynamicNamespaceKeys(): Plugin {
|
||||
// Keys that the extractor misattributes to the wrong namespace
|
||||
// because it can't resolve dynamic ns values at build time.
|
||||
const falsePositiveKeys = new Set([
|
||||
// From useTranslation([i18nLibrary]) in ClassificationCard.tsx
|
||||
// Already in views/classificationModel and views/faceLibrary
|
||||
"details.unknown",
|
||||
"details.none",
|
||||
// From t("key", { ns: configNamespace }) in DetectorHardwareField.tsx
|
||||
// Already in config/global
|
||||
"detectors.type.label",
|
||||
// From t(`${prefix}`) template literals producing empty/partial keys
|
||||
"",
|
||||
"_one",
|
||||
"_other",
|
||||
]);
|
||||
|
||||
return {
|
||||
name: "ignore-dynamic-namespace-keys",
|
||||
onEnd: async (keys) => {
|
||||
for (const key of keys.keys()) {
|
||||
// Each map key is "ns:actualKey" format
|
||||
const separatorIndex = key.indexOf(":");
|
||||
const actualKey =
|
||||
separatorIndex >= 0 ? key.slice(separatorIndex + 1) : key;
|
||||
if (falsePositiveKeys.has(actualKey)) {
|
||||
keys.delete(key);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
locales: ["en"],
|
||||
extract: {
|
||||
input: ["src/**/*.{ts,tsx}"],
|
||||
output: "public/locales/{{language}}/{{namespace}}.json",
|
||||
defaultNS: "common",
|
||||
removeUnusedKeys: false,
|
||||
sort: false,
|
||||
},
|
||||
plugins: [ignoreDynamicNamespaceKeys()],
|
||||
});
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/images/branding/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Frigate</title>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
|
||||
Generated
+1434
-335
File diff suppressed because it is too large
Load Diff
+21
-17
@@ -12,11 +12,14 @@
|
||||
"preview": "vite preview",
|
||||
"prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
"coverage": "vitest run --coverage",
|
||||
"i18n:extract": "i18next-cli extract",
|
||||
"i18n:extract:ci": "i18next-cli extract --ci",
|
||||
"i18n:status": "i18next-cli status"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@melloware/react-logviewer": "^6.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
||||
@@ -40,38 +43,38 @@
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@rjsf/core": "^6.3.1",
|
||||
"@rjsf/shadcn": "^6.3.1",
|
||||
"@rjsf/utils": "^6.3.1",
|
||||
"@rjsf/validator-ajv8": "^6.3.1",
|
||||
"@rjsf/core": "^6.4.1",
|
||||
"@rjsf/shadcn": "^6.4.1",
|
||||
"@rjsf/utils": "^6.4.1",
|
||||
"@rjsf/validator-ajv8": "^6.4.1",
|
||||
"apexcharts": "^3.52.0",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.13.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^12.35.0",
|
||||
"hls.js": "^1.5.20",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"konva": "^9.3.18",
|
||||
"konva": "^10.2.3",
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.477.0",
|
||||
"monaco-yaml": "^5.3.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"monaco-yaml": "^5.4.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"react": "^19.2.4",
|
||||
"react-apexcharts": "^1.4.1",
|
||||
"react-day-picker": "^9.7.0",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-hook-form": "^7.72.0",
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-konva": "^19.2.3",
|
||||
@@ -84,7 +87,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"sort-by": "^1.2.0",
|
||||
"strftime": "^0.10.3",
|
||||
"swr": "^2.3.2",
|
||||
"swr": "^2.4.1",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
@@ -114,12 +117,13 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.8",
|
||||
"eslint-plugin-vitest-globals": "^1.5.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"i18next-cli": "^1.5.11",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"jsdom": "^24.1.1",
|
||||
"monaco-editor": "^0.52.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"msw": "^2.3.5",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss": "^8.5.8",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.9",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user