mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-20 18:59:01 +03:00
Compare commits
154
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d7aca34b6 | ||
|
|
76a1230885 | ||
|
|
52a3301726 | ||
|
|
f448b259a2 | ||
|
|
ef9d7e07b7 | ||
|
|
814c497bef | ||
|
|
5bc15d4aa9 | ||
|
|
7ad233ef15 | ||
|
|
882b3a8ffd | ||
|
|
b6fd86a066 | ||
|
|
147cd5cc2b | ||
|
|
6a2b914b10 | ||
|
|
45213d0420 | ||
|
|
ba4a6a53d7 | ||
|
|
e90079ab2f | ||
|
|
edcf0b0d2c | ||
|
|
578d6391fb | ||
|
|
c9c9c1793d | ||
|
|
a3b10c308b | ||
|
|
540290bba7 | ||
|
|
b02d928056 | ||
|
|
057351e492 | ||
|
|
c7af51376b | ||
|
|
74cfa61952 | ||
|
|
81899ddf57 | ||
|
|
33e2b23f2e | ||
|
|
b5193c9978 | ||
|
|
93443773f4 | ||
|
|
57eb7db5c9 | ||
|
|
eb73277322 | ||
|
|
d3853688ed | ||
|
|
5fd8fc881a | ||
|
|
7000712454 | ||
|
|
72068b6823 | ||
|
|
04233c0574 | ||
|
|
1584400916 | ||
|
|
0d1189f26a | ||
|
|
29eff13a4c | ||
|
|
1162c01b3e | ||
|
|
27b7ef0a7a | ||
|
|
01a7ec1060 | ||
|
|
95b5b89ed9 | ||
|
|
a182385618 | ||
|
|
088e1ad7ef | ||
|
|
011ad8eda7 | ||
|
|
4171efcd79 | ||
|
|
0ea8924727 | ||
|
|
1a1994ca17 | ||
|
|
819e8de172 | ||
|
|
ea246384bf | ||
|
|
d8f70b7fed | ||
|
|
434ef358a2 | ||
|
|
fe269b77b8 | ||
|
|
77831304a7 | ||
|
|
1a6d04fde7 | ||
|
|
4a1b7a1629 | ||
|
|
8eace9c3e7 | ||
|
|
8fc1e97df5 | ||
|
|
0a332cada9 | ||
|
|
ba499201e6 | ||
|
|
c244e6582a | ||
|
|
fff3594553 | ||
|
|
25bfb2c481 | ||
|
|
b7261c8e70 | ||
|
|
ad9092d0da | ||
|
|
20705a3e97 | ||
|
|
f4ac063b37 | ||
|
|
2dcaeb6809 | ||
|
|
962d36323b | ||
|
|
2cfb530dbf | ||
|
|
3b81416299 | ||
|
|
7d315c5e6b | ||
|
|
6cdf4fe3b8 | ||
|
|
1a5d15ba81 | ||
|
|
043c746a8b | ||
|
|
423ee2fe72 | ||
|
|
d7f42735fc | ||
|
|
81b0d94793 | ||
|
|
cfb87f9744 | ||
|
|
74fcd720d3 | ||
|
|
a94d1b5d9e | ||
|
|
d830d47c9b | ||
|
|
3e85b18ee3 | ||
|
|
2ffe47511a | ||
|
|
82e14d71fb | ||
|
|
15ac76f20d | ||
|
|
a47be12ac5 | ||
|
|
4232792248 | ||
|
|
67837f61d0 | ||
|
|
48abac9b45 | ||
|
|
e7e6f87682 | ||
|
|
18c068a3f9 | ||
|
|
58c93c2e9e | ||
|
|
335229d0d4 | ||
|
|
d113be5e19 | ||
|
|
98c2fe00c1 | ||
|
|
556d5d8c9d | ||
|
|
8f13932c64 | ||
|
|
5d2a725428 | ||
|
|
dfe365cd28 | ||
|
|
49c3732726 | ||
|
|
c3628a339d | ||
|
|
c750372586 | ||
|
|
ed3bebc967 | ||
|
|
e95e9b52f3 | ||
|
|
67a1531da0 | ||
|
|
d8c35d5a0f | ||
|
|
9ba81d6dc8 | ||
|
|
2b8fd74ae8 | ||
|
|
c0ffb395f8 | ||
|
|
416b69439c | ||
|
|
8d7134319a | ||
|
|
23e7881b99 | ||
|
|
b8796838d7 | ||
|
|
89c258e18c | ||
|
|
680cf146ef | ||
|
|
7e2b6de1b3 | ||
|
|
e09a95921e | ||
|
|
61d8943793 | ||
|
|
95f08db494 | ||
|
|
d5972e2ea7 | ||
|
|
3e502ee084 | ||
|
|
ecae4e36cd | ||
|
|
985b834a51 | ||
|
|
5098df10cc | ||
|
|
91af284677 | ||
|
|
1e2c8af3dc | ||
|
|
ba7725b9bf | ||
|
|
cf457f67bd | ||
|
|
0fa6053839 | ||
|
|
287ea3f4f0 | ||
|
|
a16c5731da | ||
|
|
aec7e7443a | ||
|
|
32a7adaa23 | ||
|
|
b3dce759d9 | ||
|
|
c65e6d84e9 | ||
|
|
41cfd23255 | ||
|
|
75e074fb4f | ||
|
|
56fbc4c185 | ||
|
|
a6015631d4 | ||
|
|
99076cfe51 | ||
|
|
75d654b6d5 | ||
|
|
e24eb676a9 | ||
|
|
9cb76d0bd9 | ||
|
|
bb77a01779 | ||
|
|
6b71feffab | ||
|
|
68dfb157ea | ||
|
|
520d9eeb7f | ||
|
|
5059311c9d | ||
|
|
adc8c2a6e8 | ||
|
|
f1983b25ca | ||
|
|
1c26bc289e | ||
|
|
0371b60c71 | ||
|
|
01392e03ac |
@@ -26,7 +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):
|
||||
- Link to discussion with maintainers (**required** for any large or "planned" features):
|
||||
|
||||
## For new features
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check PR description against template
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const maintainers = ['blakeblackshear', 'NickM-27', 'hawkeye217', 'dependabot[bot]'];
|
||||
const maintainers = ['blakeblackshear', 'NickM-27', 'hawkeye217', 'dependabot[bot]', 'weblate'];
|
||||
const author = context.payload.pull_request.user.login;
|
||||
|
||||
if (maintainers.includes(author)) {
|
||||
|
||||
@@ -50,6 +50,37 @@ jobs:
|
||||
# run: npm run test
|
||||
# working-directory: ./web
|
||||
|
||||
web_e2e:
|
||||
name: Web - E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
- run: npm install
|
||||
working-directory: ./web
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ./web
|
||||
- name: Build web for E2E
|
||||
run: npm run e2e:build
|
||||
working-directory: ./web
|
||||
- name: Run E2E tests
|
||||
run: npm run e2e
|
||||
working-directory: ./web
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
python_checks:
|
||||
runs-on: ubuntu-latest
|
||||
name: Python Checks
|
||||
|
||||
@@ -18,9 +18,9 @@ jobs:
|
||||
close-issue-message: ""
|
||||
days-before-stale: 30
|
||||
days-before-close: 3
|
||||
exempt-draft-pr: true
|
||||
exempt-issue-labels: "pinned,security"
|
||||
exempt-pr-labels: "pinned,security,dependencies"
|
||||
exempt-draft-pr: false
|
||||
exempt-issue-labels: "planned,security"
|
||||
exempt-pr-labels: "planned,security,dependencies"
|
||||
operations-per-run: 120
|
||||
- name: Print outputs
|
||||
env:
|
||||
|
||||
@@ -22,3 +22,8 @@ core
|
||||
!/web/**/*.ts
|
||||
.idea/*
|
||||
.ipynb_checkpoints
|
||||
|
||||
# Auto-generated Docker Compose Generator config files
|
||||
docs/src/components/DockerComposeGenerator/config/devices.ts
|
||||
docs/src/components/DockerComposeGenerator/config/hardware.ts
|
||||
docs/src/components/DockerComposeGenerator/config/ports.ts
|
||||
|
||||
+8
-3
@@ -10,11 +10,14 @@ If you've found a bug and want to fix it, go for it. Link to the relevant issue
|
||||
|
||||
### New features
|
||||
|
||||
Every new feature adds scope that the maintainers must test, maintain, and support long-term. Before writing code for a new feature:
|
||||
A pull request is more than just code — it's a request for the maintainers to review, integrate, and support the change long-term. We're selective about what we take on, and prioritize changes that align with the project's direction and can be responsibly maintained in the long term.
|
||||
|
||||
1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Pinned feature requests are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one.
|
||||
**Large or highly-requested features** raise the bar even higher. Popularity signals demand, but it doesn't pre-approve any particular implementation. The bigger the change, the higher the long-term cost, and the more important it is that we're aligned on scope and approach before any code is written. A large PR that lands without prior discussion is unlikely to be merged as-is, no matter how well it's implemented.
|
||||
|
||||
Before writing code for a new feature:
|
||||
|
||||
1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Feature requests tagged with "planned" are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one.
|
||||
2. **Start a discussion or feature request first.** This helps ensure your idea aligns with Frigate's direction before you invest time building it. Community interest in a feature request helps us gauge demand, though a great idea is a great idea even without a crowd behind it.
|
||||
3. **Be open to "no".** We try to be thoughtful about what we take on, and sometimes that means saying no to good code if the feature isn't the right fit for the project. These calls are sometimes subjective, and we won't always get them right. We're happy to discuss and reconsider.
|
||||
|
||||
## AI usage policy
|
||||
|
||||
@@ -39,6 +42,8 @@ We're not trying to gatekeep how you write code. Use whatever tools make you pro
|
||||
|
||||
Some honest context: when we review a PR, we're not just evaluating whether the code works today. We're evaluating whether we can maintain it, debug it, and extend it long-term — often without the original author's involvement. Code that the author doesn't deeply understand is code that nobody understands, and that's a liability.
|
||||
|
||||
One more thing worth saying directly: most maintainers already have access to the same AI tools you do. A PR that's entirely AI-generated — where the author can't explain the design, debug issues independently, or engage substantively in design discussions — doesn't offer something we couldn't produce ourselves. What makes a contribution genuinely valuable is the human judgment and domain understanding behind it, as well as the engagement during review that shapes it into something we can confidently take on long-term.
|
||||
|
||||
## Pull request guidelines
|
||||
|
||||
### Before submitting
|
||||
|
||||
@@ -14,6 +14,8 @@ services:
|
||||
dockerfile: docker/main/Dockerfile
|
||||
# Use target devcontainer-trt for TensorRT dev
|
||||
target: devcontainer
|
||||
cache_from:
|
||||
- ghcr.io/blakeblackshear/frigate:cache-amd64
|
||||
## Uncomment this block for nvidia gpu support
|
||||
# deploy:
|
||||
# resources:
|
||||
|
||||
@@ -52,6 +52,14 @@ RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/cache/apt \
|
||||
--mount=type=cache,target=/root/.ccache \
|
||||
/deps/build_sqlite_vec.sh
|
||||
|
||||
# Build intel-media-driver from source against bookworm's system libva so it
|
||||
# works with Debian 12's glibc/libstdc++ (pre-built noble/trixie packages
|
||||
# require glibc 2.38 which is not available on bookworm).
|
||||
FROM base AS intel-media-driver
|
||||
ARG DEBIAN_FRONTEND
|
||||
RUN --mount=type=bind,source=docker/main/build_intel_media_driver.sh,target=/deps/build_intel_media_driver.sh \
|
||||
/deps/build_intel_media_driver.sh
|
||||
|
||||
FROM scratch AS go2rtc
|
||||
ARG TARGETARCH
|
||||
WORKDIR /rootfs/usr/local/go2rtc/bin
|
||||
@@ -200,6 +208,7 @@ RUN --mount=type=bind,source=docker/main/install_hailort.sh,target=/deps/install
|
||||
FROM scratch AS deps-rootfs
|
||||
COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/
|
||||
COPY --from=sqlite-vec /usr/local/lib/ /usr/local/lib/
|
||||
COPY --from=intel-media-driver /rootfs/ /
|
||||
COPY --from=go2rtc /rootfs/ /
|
||||
COPY --from=libusb-build /usr/local/lib /usr/local/lib
|
||||
COPY --from=tempio /rootfs/ /
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# Intel media driver is x86_64-only. Create empty rootfs on other arches so
|
||||
# the downstream COPY --from has a valid source.
|
||||
if [ "$(uname -m)" != "x86_64" ]; then
|
||||
mkdir -p /rootfs
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MEDIA_DRIVER_VERSION="intel-media-25.2.6"
|
||||
GMMLIB_VERSION="intel-gmmlib-22.7.2"
|
||||
|
||||
apt-get -qq update
|
||||
apt-get -qq install -y wget gnupg ca-certificates cmake g++ make pkg-config
|
||||
|
||||
# Use Intel's jammy repo for newer libva-dev (2.22) which provides the
|
||||
# VVC/VVC-decode headers required by media-driver 25.x
|
||||
wget -qO - https://repositories.intel.com/gpu/intel-graphics.key | gpg --yes --dearmor --output /usr/share/keyrings/intel-graphics.gpg
|
||||
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/gpu/ubuntu jammy client" > /etc/apt/sources.list.d/intel-gpu-jammy.list
|
||||
apt-get -qq update
|
||||
apt-get -qq install -y libva-dev
|
||||
|
||||
# Build gmmlib (required by media-driver)
|
||||
wget -qO gmmlib.tar.gz "https://github.com/intel/gmmlib/archive/refs/tags/${GMMLIB_VERSION}.tar.gz"
|
||||
mkdir /tmp/gmmlib
|
||||
tar -xf gmmlib.tar.gz -C /tmp/gmmlib --strip-components 1
|
||||
cmake -S /tmp/gmmlib -B /tmp/gmmlib/build -DCMAKE_BUILD_TYPE=Release
|
||||
make -C /tmp/gmmlib/build -j"$(nproc)"
|
||||
make -C /tmp/gmmlib/build install
|
||||
|
||||
# Build intel-media-driver
|
||||
wget -qO media-driver.tar.gz "https://github.com/intel/media-driver/archive/refs/tags/${MEDIA_DRIVER_VERSION}.tar.gz"
|
||||
mkdir /tmp/media-driver
|
||||
tar -xf media-driver.tar.gz -C /tmp/media-driver --strip-components 1
|
||||
cmake -S /tmp/media-driver -B /tmp/media-driver/build \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DENABLE_KERNELS=ON \
|
||||
-DENABLE_NONFREE_KERNELS=ON \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DCMAKE_INSTALL_LIBDIR=/usr/lib/x86_64-linux-gnu \
|
||||
-DCMAKE_C_FLAGS="-Wno-error" \
|
||||
-DCMAKE_CXX_FLAGS="-Wno-error"
|
||||
make -C /tmp/media-driver/build -j"$(nproc)"
|
||||
|
||||
# Install driver to rootfs for COPY --from
|
||||
make -C /tmp/media-driver/build install DESTDIR=/rootfs
|
||||
+29
-16
@@ -87,38 +87,47 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||
# intel packages use zst compression so we need to update dpkg
|
||||
apt-get install -y dpkg
|
||||
|
||||
# use intel apt intel packages
|
||||
# use intel apt repo for libmfx1 (legacy QSV, pre-Gen12)
|
||||
wget -qO - https://repositories.intel.com/gpu/intel-graphics.key | gpg --yes --dearmor --output /usr/share/keyrings/intel-graphics.gpg
|
||||
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/gpu/ubuntu jammy client" | tee /etc/apt/sources.list.d/intel-gpu-jammy.list
|
||||
apt-get -qq update
|
||||
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
||||
intel-media-va-driver-non-free libmfx1 libmfxgen1 libvpl2
|
||||
|
||||
# intel-media-va-driver-non-free is built from source in the
|
||||
# intel-media-driver Dockerfile stage for Battlemage (Xe2) support
|
||||
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
||||
libmfx1
|
||||
rm -f /usr/share/keyrings/intel-graphics.gpg
|
||||
rm -f /etc/apt/sources.list.d/intel-gpu-jammy.list
|
||||
|
||||
# upgrade libva2, oneVPL runtime, and libvpl2 from trixie for Battlemage support
|
||||
echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list
|
||||
apt-get -qq update
|
||||
apt-get -qq install -y -t trixie libva2 libva-drm2 libzstd1
|
||||
apt-get -qq install -y -t trixie libmfx-gen1.2 libvpl2
|
||||
rm -f /etc/apt/sources.list.d/trixie.list
|
||||
apt-get -qq update
|
||||
apt-get -qq install -y ocl-icd-libopencl1
|
||||
|
||||
# install libtbb12 for NPU support
|
||||
apt-get -qq install -y libtbb12
|
||||
|
||||
rm -f /usr/share/keyrings/intel-graphics.gpg
|
||||
rm -f /etc/apt/sources.list.d/intel-gpu-jammy.list
|
||||
|
||||
# install legacy and standard intel icd and level-zero-gpu
|
||||
# install legacy and standard intel compute packages
|
||||
# see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info
|
||||
# needed core package
|
||||
wget https://github.com/intel/compute-runtime/releases/download/25.13.33276.19/libigdgmm12_22.7.0_amd64.deb
|
||||
dpkg -i libigdgmm12_22.7.0_amd64.deb
|
||||
rm libigdgmm12_22.7.0_amd64.deb
|
||||
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb
|
||||
dpkg -i libigdgmm12_22.9.0_amd64.deb
|
||||
rm libigdgmm12_22.9.0_amd64.deb
|
||||
|
||||
# legacy packages
|
||||
# legacy compute-runtime packages
|
||||
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb
|
||||
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb
|
||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb
|
||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb
|
||||
# standard packages
|
||||
wget https://github.com/intel/compute-runtime/releases/download/25.13.33276.19/intel-opencl-icd_25.13.33276.19_amd64.deb
|
||||
wget https://github.com/intel/compute-runtime/releases/download/25.13.33276.19/intel-level-zero-gpu_1.6.33276.19_amd64.deb
|
||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.10.10/intel-igc-opencl-2_2.10.10+18926_amd64.deb
|
||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.10.10/intel-igc-core-2_2.10.10+18926_amd64.deb
|
||||
# standard compute-runtime packages
|
||||
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb
|
||||
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb
|
||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb
|
||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb
|
||||
# npu packages
|
||||
wget https://github.com/oneapi-src/level-zero/releases/download/v1.28.2/level-zero_1.28.2+u22.04_amd64.deb
|
||||
wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-driver-compiler-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
|
||||
@@ -128,6 +137,10 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||
dpkg -i *.deb
|
||||
rm *.deb
|
||||
apt-get -qq install -f -y
|
||||
|
||||
# Battlemage uses the xe kernel driver, but the VA-API driver is still iHD.
|
||||
# The oneVPL runtime may look for a driver named after the kernel module.
|
||||
ln -sf /usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so /usr/lib/x86_64-linux-gnu/dri/xe_drv_video.so
|
||||
fi
|
||||
|
||||
if [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||
|
||||
@@ -11,7 +11,7 @@ joserfc == 1.2.*
|
||||
cryptography == 44.0.*
|
||||
pathvalidate == 3.3.*
|
||||
markupsafe == 3.0.*
|
||||
python-multipart == 0.0.20
|
||||
python-multipart == 0.0.26
|
||||
# Classification Model Training
|
||||
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
||||
tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
|
||||
@@ -42,7 +42,7 @@ opencv-python-headless == 4.11.0.*
|
||||
opencv-contrib-python == 4.11.0.*
|
||||
scipy == 1.16.*
|
||||
# OpenVino & ONNX
|
||||
openvino == 2025.3.*
|
||||
openvino == 2025.4.*
|
||||
onnxruntime == 1.22.*
|
||||
# Embeddings
|
||||
transformers == 4.45.*
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
sys.path.insert(0, "/opt/frigate")
|
||||
from frigate.config.env import substitute_frigate_vars
|
||||
from frigate.const import (
|
||||
BIRDSEYE_PIPE,
|
||||
DEFAULT_FFMPEG_VERSION,
|
||||
@@ -47,14 +48,6 @@ ALLOW_ARBITRARY_EXEC = allow_arbitrary_exec is not None and str(
|
||||
allow_arbitrary_exec
|
||||
).lower() in ("true", "1", "yes")
|
||||
|
||||
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
|
||||
# read docker secret files as env vars too
|
||||
if os.path.isdir("/run/secrets"):
|
||||
for secret_file in os.listdir("/run/secrets"):
|
||||
if secret_file.startswith("FRIGATE_"):
|
||||
FRIGATE_ENV_VARS[secret_file] = (
|
||||
Path(os.path.join("/run/secrets", secret_file)).read_text().strip()
|
||||
)
|
||||
|
||||
config_file = find_config_file()
|
||||
|
||||
@@ -103,13 +96,13 @@ if go2rtc_config["webrtc"].get("candidates") is None:
|
||||
go2rtc_config["webrtc"]["candidates"] = default_candidates
|
||||
|
||||
if go2rtc_config.get("rtsp", {}).get("username") is not None:
|
||||
go2rtc_config["rtsp"]["username"] = go2rtc_config["rtsp"]["username"].format(
|
||||
**FRIGATE_ENV_VARS
|
||||
go2rtc_config["rtsp"]["username"] = substitute_frigate_vars(
|
||||
go2rtc_config["rtsp"]["username"]
|
||||
)
|
||||
|
||||
if go2rtc_config.get("rtsp", {}).get("password") is not None:
|
||||
go2rtc_config["rtsp"]["password"] = go2rtc_config["rtsp"]["password"].format(
|
||||
**FRIGATE_ENV_VARS
|
||||
go2rtc_config["rtsp"]["password"] = substitute_frigate_vars(
|
||||
go2rtc_config["rtsp"]["password"]
|
||||
)
|
||||
|
||||
# ensure ffmpeg path is set correctly
|
||||
@@ -145,7 +138,7 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
|
||||
if isinstance(stream, str):
|
||||
try:
|
||||
formatted_stream = stream.format(**FRIGATE_ENV_VARS)
|
||||
formatted_stream = substitute_frigate_vars(stream)
|
||||
if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream):
|
||||
print(
|
||||
f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. "
|
||||
@@ -164,7 +157,7 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
filtered_streams = []
|
||||
for i, stream_item in enumerate(stream):
|
||||
try:
|
||||
formatted_stream = stream_item.format(**FRIGATE_ENV_VARS)
|
||||
formatted_stream = substitute_frigate_vars(stream_item)
|
||||
if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream):
|
||||
print(
|
||||
f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. "
|
||||
|
||||
@@ -227,16 +227,6 @@ http {
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
# frontend uses this to fetch the version
|
||||
location /api/go2rtc/api {
|
||||
include auth_request.conf;
|
||||
limit_except GET {
|
||||
deny all;
|
||||
}
|
||||
proxy_pass http://go2rtc/api;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
# integration uses this to add webrtc candidate
|
||||
location /api/go2rtc/webrtc {
|
||||
include auth_request.conf;
|
||||
|
||||
+11
-4
@@ -13,7 +13,7 @@ ARG ROCM
|
||||
|
||||
RUN apt update -qq && \
|
||||
apt install -y wget gpg && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2/ubuntu/jammy/amdgpu-install_7.2.70200-1_all.deb && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2.3/ubuntu/jammy/amdgpu-install_7.2.3.70203-1_all.deb && \
|
||||
apt install -y ./rocm.deb && \
|
||||
apt update && \
|
||||
apt install -qq -y rocm
|
||||
@@ -32,11 +32,14 @@ RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf
|
||||
FROM deps AS deps-prelim
|
||||
|
||||
COPY docker/rocm/debian-backports.sources /etc/apt/sources.list.d/debian-backports.sources
|
||||
RUN apt-get update && \
|
||||
# install_deps.sh upgraded libstdc++6 from trixie for Battlemage; the matching
|
||||
# -dev package must also come from trixie or apt refuses to satisfy it.
|
||||
RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y libnuma1 && \
|
||||
apt-get install -qq -y -t bookworm-backports mesa-va-drivers mesa-vulkan-drivers && \
|
||||
# Install C++ standard library headers for HIPRTC kernel compilation fallback
|
||||
apt-get install -qq -y libstdc++-12-dev && \
|
||||
apt-get install -qq -y -t trixie libstdc++-14-dev && \
|
||||
rm -f /etc/apt/sources.list.d/trixie.list && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/frigate
|
||||
@@ -75,6 +78,10 @@ ENV MIGRAPHX_DISABLE_MIOPEN_FUSION=1
|
||||
ENV MIGRAPHX_DISABLE_SCHEDULE_PASS=1
|
||||
ENV MIGRAPHX_DISABLE_REDUCE_FUSION=1
|
||||
ENV MIGRAPHX_ENABLE_HIPRTC_WORKAROUNDS=1
|
||||
ENV MIOPEN_CUSTOM_CACHE_DIR=/config/model_cache/migraphx
|
||||
ENV MIOPEN_USER_DB_PATH=/config/model_cache/migraphx
|
||||
ENV AMD_COMGR_CACHE=1
|
||||
ENV AMD_COMGR_CACHE_DIR=/config/model_cache/migraphx
|
||||
|
||||
COPY --from=rocm-dist / /
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.2.0/onnxruntime_migraphx-1.23.1-cp311-cp311-linux_x86_64.whl
|
||||
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.2.3-1/onnxruntime_migraphx-1.24.4-cp311-cp311-linux_x86_64.whl
|
||||
@@ -1,5 +1,5 @@
|
||||
variable "ROCM" {
|
||||
default = "7.2.0"
|
||||
default = "7.2.3"
|
||||
}
|
||||
variable "HSA_OVERRIDE_GFX_VERSION" {
|
||||
default = ""
|
||||
|
||||
@@ -119,6 +119,12 @@ audio:
|
||||
|
||||
Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAI's open-source Whisper models via `faster-whisper`. The goal of this feature is to support Semantic Search for `speech` audio events. Frigate is not intended to act as a continuous, fully-automatic speech transcription service — automatically transcribing all speech (or queuing many audio events for transcription) requires substantial CPU (or GPU) resources and is impractical on most systems. For this reason, transcriptions for events are initiated manually from the UI or the API rather than being run continuously in the background.
|
||||
|
||||
:::info
|
||||
|
||||
Audio transcription requires a one-time internet connection to download the Whisper or Sherpa-ONNX model on first use. Once cached, transcription runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
Transcription accuracy also depends heavily on the quality of your camera's microphone and recording conditions. Many cameras use inexpensive microphones, and distance to the speaker, low audio bitrate, or background noise can significantly reduce transcription quality. If you need higher accuracy, more robust long-running queues, or large-scale automatic transcription, consider using the HTTP API in combination with an automation platform and a cloud transcription service.
|
||||
|
||||
#### Configuration
|
||||
|
||||
@@ -9,6 +9,12 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Bird classification identifies known birds using a quantized Tensorflow model. When a known bird is recognized, its common name will be added as a `sub_label`. This information is included in the UI, filters, as well as in notifications.
|
||||
|
||||
:::info
|
||||
|
||||
Bird classification requires a one-time internet connection to download the classification model and label map from GitHub. Once cached, models work fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
## Minimum System Requirements
|
||||
|
||||
Bird classification runs a lightweight tflite model on the CPU, there are no significantly different system requirements than running Frigate itself.
|
||||
|
||||
@@ -9,6 +9,12 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Object classification allows you to train a custom MobileNetV2 classification model to run on tracked objects (persons, cars, animals, etc.) to identify a finer category or attribute for that object. Classification results are visible in the Tracked Object Details pane in Explore, through the `frigate/tracked_object_details` MQTT topic, in Home Assistant sensors via the official Frigate integration, or through the event endpoints in the HTTP API.
|
||||
|
||||
:::info
|
||||
|
||||
Training a custom object classification model requires a one-time internet connection to download MobileNetV2 base weights. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
## Minimum System Requirements
|
||||
|
||||
Object classification models are lightweight and run very fast on CPU.
|
||||
@@ -158,7 +164,7 @@ Enable debug logs for classification models by adding `frigate.data_processing.r
|
||||
Navigate to <NavPath path="Settings > System > Logging" />.
|
||||
|
||||
- Set **Logging level** to `debug`
|
||||
- Set **Per-process log level > Frigate.Data Processing.Real Time.Custom Classification** to `debug` for verbose classification logging
|
||||
- Set **Per-process log level > `frigate.data_processing.real_time.custom_classification`** to `debug` for verbose classification logging
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -9,6 +9,12 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
State classification allows you to train a custom MobileNetV2 classification model on a fixed region of your camera frame(s) to determine a current state. The model can be configured to run on a schedule and/or when motion is detected in that region. Classification results are available through the `frigate/<camera_name>/classification/<model_name>` MQTT topic and in Home Assistant sensors via the official Frigate integration.
|
||||
|
||||
:::info
|
||||
|
||||
Training a custom state classification model requires a one-time internet connection to download MobileNetV2 base weights. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
## Minimum System Requirements
|
||||
|
||||
State classification models are lightweight and run very fast on CPU.
|
||||
|
||||
@@ -9,11 +9,17 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Face recognition identifies known individuals by matching detected faces with previously learned facial data. When a known `person` is recognized, their name will be added as a `sub_label`. This information is included in the UI, filters, as well as in notifications.
|
||||
|
||||
:::info
|
||||
|
||||
Face recognition requires a one-time internet connection to download detection and embedding models from GitHub. Once cached, models work fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
## Model Requirements
|
||||
|
||||
### Face Detection
|
||||
|
||||
When running a Frigate+ model (or any custom model that natively detects faces) should ensure that `face` is added to the [list of objects to track](../plus/#available-label-types) either globally or for a specific camera. This will allow face detection to run at the same time as object detection and be more efficient.
|
||||
When running a Frigate+ model (or any custom model that natively detects faces) should ensure that `face` is added to the [list of objects to track](../plus/index.md#available-label-types) either globally or for a specific camera. This will allow face detection to run at the same time as object detection and be more efficient.
|
||||
|
||||
When running a default COCO model or another model that does not include `face` as a detectable label, face detection will run via CV2 using a lightweight DNN model that runs on the CPU. In this case, you should _not_ define `face` in your list of objects to track.
|
||||
|
||||
@@ -165,7 +171,7 @@ When choosing images to include in the face training set it is recommended to al
|
||||
- If it is difficult to make out details in a persons face it will not be helpful in training.
|
||||
- Avoid images with extreme under/over-exposure.
|
||||
- Avoid blurry / pixelated images.
|
||||
- Avoid training on infrared (gray-scale). The models are trained on color images and will be able to extract features from gray-scale images.
|
||||
- Avoid training on infrared (gray-scale). The models are trained on color images and will not be able to extract features from gray-scale images.
|
||||
- Using images of people wearing hats / sunglasses may confuse the model.
|
||||
- Do not upload too many similar images at the same time, it is recommended to train no more than 4-6 similar images for each person to avoid over-fitting.
|
||||
|
||||
|
||||
@@ -29,11 +29,11 @@ You must use a vision-capable model with Frigate. The following models are recom
|
||||
|
||||
| Model | Notes |
|
||||
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `qwen3-vl` | Strong visual and situational understanding, strong ability to identify smaller objects and interactions with object. |
|
||||
| `qwen3-vl` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. |
|
||||
| `qwen3.5` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
|
||||
| `gemma4` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. |
|
||||
| `Intern3.5VL` | Relatively fast with good vision comprehension |
|
||||
| `gemma3` | Slower model with good vision and temporal understanding |
|
||||
| `qwen2.5-vl` | Fast but capable model with good vision comprehension |
|
||||
|
||||
:::info
|
||||
|
||||
@@ -193,9 +193,15 @@ To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` env
|
||||
|
||||
Cloud providers run on remote infrastructure and require an API key for authentication. These services handle all model inference on their servers.
|
||||
|
||||
:::info
|
||||
|
||||
Cloud Generative AI providers require an active internet connection to send images and prompts for processing. Local providers like llama.cpp and Ollama (with local models) do not require internet. See [Network Requirements](/frigate/network_requirements#generative-ai) for details.
|
||||
|
||||
:::
|
||||
|
||||
### Ollama Cloud
|
||||
|
||||
Ollama also supports [cloud models](https://ollama.com/cloud), where your local Ollama instance handles requests from Frigate, but model inference is performed in the cloud. Set up Ollama locally, sign in with your Ollama account, and specify the cloud model name in your Frigate config. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud).
|
||||
Ollama also supports [cloud models](https://ollama.com/cloud), where model inference is performed in the cloud. You can connect directly to Ollama Cloud by setting `base_url` to `https://ollama.com` and providing an API key. Alternatively, you can run Ollama locally and use a cloud model name so your local instance forwards requests to the cloud. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud).
|
||||
|
||||
#### Configuration
|
||||
|
||||
@@ -204,7 +210,8 @@ Ollama also supports [cloud models](https://ollama.com/cloud), where your local
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Enrichments > Generative AI" />.
|
||||
- Set **Provider** to `ollama`
|
||||
- Set **Base URL** to your local Ollama address (e.g., `http://localhost:11434`)
|
||||
- Set **Base URL** to your local Ollama address (e.g., `http://localhost:11434`) or `https://ollama.com` for direct cloud inference
|
||||
- Set **API key** if required by your endpoint (e.g., when using `https://ollama.com`)
|
||||
- Set **Model** to the cloud model name
|
||||
|
||||
</TabItem>
|
||||
@@ -217,6 +224,16 @@ genai:
|
||||
model: cloud-model-name
|
||||
```
|
||||
|
||||
or when using Ollama Cloud directly
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: ollama
|
||||
base_url: https://ollama.com
|
||||
model: cloud-model-name
|
||||
api_key: your-api-key
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
|
||||
@@ -59,13 +59,14 @@ Frigate can utilize most Intel integrated GPUs and Arc GPUs to accelerate video
|
||||
|
||||
**Recommended hwaccel Preset**
|
||||
|
||||
| CPU Generation | Intel Driver | Recommended Preset | Notes |
|
||||
| -------------- | ------------ | ------------------- | ------------------------------------------- |
|
||||
| gen1 - gen5 | i965 | preset-vaapi | qsv is not supported, may not support H.265 |
|
||||
| gen6 - gen7 | iHD | preset-vaapi | qsv is not supported |
|
||||
| gen8 - gen12 | iHD | preset-vaapi | preset-intel-qsv-\* can also be used |
|
||||
| gen13+ | iHD / Xe | preset-intel-qsv-\* | |
|
||||
| Intel Arc GPU | iHD / Xe | preset-intel-qsv-\* | |
|
||||
| CPU Generation | Intel Driver | Recommended Preset | Notes |
|
||||
| ------------------ | ------------ | ------------------- | ------------------------------------------- |
|
||||
| gen1 - gen5 | i965 | preset-vaapi | qsv is not supported, may not support H.265 |
|
||||
| gen6 - gen7 | iHD | preset-vaapi | qsv is not supported |
|
||||
| gen8 - gen12 | iHD | preset-vaapi | preset-intel-qsv-\* can also be used |
|
||||
| gen13+ | iHD / Xe | preset-intel-qsv-\* | |
|
||||
| Intel Arc A-series | iHD / Xe | preset-intel-qsv-\* | |
|
||||
| Intel Arc B-series | iHD / Xe | preset-intel-qsv-\* | Requires host kernel 6.12+ |
|
||||
|
||||
:::
|
||||
|
||||
@@ -135,90 +136,32 @@ ffmpeg:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### Configuring Intel GPU Stats in Docker
|
||||
### Configuring Intel GPU Stats
|
||||
|
||||
Additional configuration is needed for the Docker container to be able to access the `intel_gpu_top` command for GPU stats. There are two options:
|
||||
Frigate reads Intel GPU utilization directly from the kernel's per-client DRM usage counters exposed at `/proc/<pid>/fdinfo/<fd>`. This requires:
|
||||
|
||||
1. Run the container as privileged.
|
||||
2. Add the `CAP_PERFMON` capability (note: you might need to set the `perf_event_paranoid` low enough to allow access to the performance event system.)
|
||||
- Linux kernel **5.19 or newer** for the `i915` driver, or any release of the `xe` driver.
|
||||
- Frigate running with permission to read other processes' fdinfo. Running as root inside the container (the default) satisfies this; non-root setups may need `CAP_SYS_PTRACE`.
|
||||
|
||||
#### Run as privileged
|
||||
No `intel_gpu_top` binary, `CAP_PERFMON`, privileged mode, or `perf_event_paranoid` tuning is required.
|
||||
|
||||
This method works, but it gives more permissions to the container than are actually needed.
|
||||
#### Stats for SR-IOV or specific devices
|
||||
|
||||
##### Docker Compose - Privileged
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
image: ghcr.io/blakeblackshear/frigate:stable
|
||||
# highlight-next-line
|
||||
privileged: true
|
||||
```
|
||||
|
||||
##### Docker Run CLI - Privileged
|
||||
|
||||
```bash {4}
|
||||
docker run -d \
|
||||
--name frigate \
|
||||
...
|
||||
--privileged \
|
||||
ghcr.io/blakeblackshear/frigate:stable
|
||||
```
|
||||
|
||||
#### CAP_PERFMON
|
||||
|
||||
Only recent versions of Docker support the `CAP_PERFMON` capability. You can test to see if yours supports it by running: `docker run --cap-add=CAP_PERFMON hello-world`
|
||||
|
||||
##### Docker Compose - CAP_PERFMON
|
||||
|
||||
```yaml {5,6}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
image: ghcr.io/blakeblackshear/frigate:stable
|
||||
cap_add:
|
||||
- CAP_PERFMON
|
||||
```
|
||||
|
||||
##### Docker Run CLI - CAP_PERFMON
|
||||
|
||||
```bash {4}
|
||||
docker run -d \
|
||||
--name frigate \
|
||||
...
|
||||
--cap-add=CAP_PERFMON \
|
||||
ghcr.io/blakeblackshear/frigate:stable
|
||||
```
|
||||
|
||||
#### perf_event_paranoid
|
||||
|
||||
_Note: This setting must be changed for the entire system._
|
||||
|
||||
For more information on the various values across different distributions, see https://askubuntu.com/questions/1400874/what-does-perf-paranoia-level-four-do.
|
||||
|
||||
Depending on your OS and kernel configuration, you may need to change the `/proc/sys/kernel/perf_event_paranoid` kernel tunable. You can test the change by running `sudo sh -c 'echo 2 >/proc/sys/kernel/perf_event_paranoid'` which will persist until a reboot. Make it permanent by running `sudo sh -c 'echo kernel.perf_event_paranoid=2 >> /etc/sysctl.d/local.conf'`
|
||||
|
||||
#### Stats for SR-IOV or other devices
|
||||
|
||||
When using virtualized GPUs via SR-IOV, you need to specify the device path to use to gather stats from `intel_gpu_top`. This example may work for some systems using SR-IOV:
|
||||
If the host has more than one Intel GPU (e.g. an iGPU plus a discrete GPU, or SR-IOV virtual functions), pin stats collection to a specific device by setting `intel_gpu_device` to either its PCI bus address or a DRM card/render-node path:
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
stats:
|
||||
intel_gpu_device: "sriov"
|
||||
intel_gpu_device: "0000:00:02.0"
|
||||
```
|
||||
|
||||
For other virtualized GPUs, try specifying the direct path to the device instead:
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
stats:
|
||||
intel_gpu_device: "drm:/dev/dri/card0"
|
||||
intel_gpu_device: "/dev/dri/card1"
|
||||
```
|
||||
|
||||
If you are passing in a device path, make sure you've passed the device through to the container.
|
||||
When passing a device path, make sure the device is also passed through to the container.
|
||||
|
||||
## AMD-based CPUs
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ Frigate can recognize license plates on vehicles and automatically add the detec
|
||||
|
||||
LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. When a vehicle becomes stationary, LPR continues to run for a short time after to attempt recognition.
|
||||
|
||||
:::info
|
||||
|
||||
License plate recognition requires a one-time internet connection to download OCR and detection models from GitHub. Once cached, models work fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
When a plate is recognized, the details are:
|
||||
|
||||
- Added as a `sub_label` (if [known](#matching)) or the `recognized_license_plate` field (if unknown) to a tracked object.
|
||||
|
||||
@@ -21,6 +21,12 @@ The jsmpeg live view will use more browser and client GPU resources. Using go2rt
|
||||
| mse | native | native | yes (depends on audio codec) | yes | iPhone requires iOS 17.1+, Firefox is h.264 only. This is Frigate's default when go2rtc is configured. |
|
||||
| webrtc | native | native | yes (depends on audio codec) | yes | Requires extra configuration. Frigate attempts to use WebRTC when MSE fails or when using a camera's two-way talk feature. |
|
||||
|
||||
:::info
|
||||
|
||||
WebRTC may use an external STUN server for NAT traversal. MSE and HLS streaming do not require any internet access. See [Network Requirements](/frigate/network_requirements#webrtc-stun) for details.
|
||||
|
||||
:::
|
||||
|
||||
### Camera Settings Recommendations
|
||||
|
||||
If you are using go2rtc, you should adjust the following settings in your camera's firmware for the best experience with Live view:
|
||||
|
||||
@@ -11,6 +11,12 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Frigate offers native notifications using the [WebPush Protocol](https://web.dev/articles/push-notifications-web-push-protocol) which uses the [VAPID spec](https://tools.ietf.org/html/draft-thomson-webpush-vapid) to deliver notifications to web apps using encryption.
|
||||
|
||||
:::info
|
||||
|
||||
Push notifications require internet access from the Frigate server to the browser vendor's push service (e.g., Google FCM, Mozilla autopush). See [Network Requirements](/frigate/network_requirements#push-notifications) for details.
|
||||
|
||||
:::
|
||||
|
||||
## Setting up Notifications
|
||||
|
||||
In order to use notifications the following requirements must be met:
|
||||
|
||||
@@ -91,7 +91,7 @@ See [common Edge TPU troubleshooting steps](/troubleshooting/edgetpu) if the Edg
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **EdgeTPU** detector type with device set to `usb`.
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -111,7 +111,7 @@ detectors:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and add multiple Edge TPU detectors, specifying `usb:0` and `usb:1` as the device for each.
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `usb:0` and `usb:1` as the device for each.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -136,7 +136,7 @@ _warning: may have [compatibility issues](https://github.com/blakeblackshear/fri
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **EdgeTPU** detector type with the device field left empty.
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then leave the device field empty.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -156,7 +156,7 @@ detectors:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **EdgeTPU** detector type with device set to `pci`.
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `pci`.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -176,7 +176,7 @@ detectors:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and add multiple Edge TPU detectors, specifying `pci:0` and `pci:1` as the device for each.
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `pci:0` and `pci:1` as the device for each.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -199,7 +199,7 @@ detectors:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and add multiple Edge TPU detectors with different device types (e.g., `usb` and `pci`).
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors with different device types (e.g., `usb` and `pci`).
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -246,7 +246,7 @@ After placing the downloaded files for the tflite model and labels in your confi
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **EdgeTPU** detector type with device set to `usb`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------- |
|
||||
@@ -288,6 +288,12 @@ This detector is available for use with both Hailo-8 and Hailo-8L AI Acceleratio
|
||||
|
||||
See the [installation docs](../frigate/installation.md#hailo-8) for information on configuring the Hailo hardware.
|
||||
|
||||
:::info
|
||||
|
||||
If no custom model is provided, the Hailo detector downloads a default model from the Hailo Model Zoo on first startup. Once cached, the model works fully offline. See [Network Requirements](/frigate/network_requirements#hardware-specific-detector-models) for details.
|
||||
|
||||
:::
|
||||
|
||||
### Configuration
|
||||
|
||||
When configuring the Hailo detector, you have two options to specify the model: a local **path** or a **URL**.
|
||||
@@ -303,7 +309,7 @@ Use this configuration for YOLO-based models. When no custom model path or URL i
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **Hailo-8/Hailo-8L** detector type with device set to `PCIe`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------- |
|
||||
@@ -359,7 +365,7 @@ For SSD-based models, provide either a model path or URL to your compiled SSD mo
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **Hailo-8/Hailo-8L** detector type with device set to `PCIe`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings:
|
||||
|
||||
| Field | Value |
|
||||
| --------------------------------------- | ------ |
|
||||
@@ -404,7 +410,7 @@ The Hailo detector supports all YOLO models compiled for Hailo hardware that inc
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **Hailo-8/Hailo-8L** detector type with device set to `PCIe`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings to match your custom model dimensions and format.
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings to match your custom model dimensions and format.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -459,7 +465,7 @@ When using many cameras one detector may not be enough to keep up. Multiple dete
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and add multiple **OpenVINO** detectors, each targeting `GPU` or `NPU`.
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add** to add multiple detectors, each targeting `GPU` or `NPU`.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -488,7 +494,7 @@ detectors:
|
||||
| [YOLO-NAS](#yolo-nas) | ✅ | ✅ | |
|
||||
| [MobileNet v2](#ssdlite-mobilenet-v2) | ✅ | ✅ | Fast and lightweight model, less accurate than larger models |
|
||||
| [YOLOX](#yolox) | ✅ | ? | |
|
||||
| [D-FINE](#d-fine) | ❌ | ❌ | |
|
||||
| [D-FINE / DEIMv2](#d-fine--deimv2) | ❌ | ❌ | |
|
||||
|
||||
#### SSDLite MobileNet v2
|
||||
|
||||
@@ -502,7 +508,7 @@ Use the model configuration shown below when using the OpenVINO detector with th
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **OpenVINO** detector type with device set to `GPU` (or `NPU`). Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------ |
|
||||
@@ -552,7 +558,7 @@ After placing the downloaded onnx model in your config folder, use the following
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **OpenVINO** detector type with device set to `GPU`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------- |
|
||||
@@ -614,7 +620,7 @@ After placing the downloaded onnx model in your config folder, use the following
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **OpenVINO** detector type with device set to `GPU` (or `NPU`). Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
@@ -670,7 +676,7 @@ After placing the downloaded onnx model in your `config/model_cache` folder, use
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **OpenVINO** detector type with device set to `GPU`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| --------------------------------------- | --------------------------------- |
|
||||
@@ -704,13 +710,13 @@ model:
|
||||
|
||||
</details>
|
||||
|
||||
#### D-FINE
|
||||
#### D-FINE / DEIMv2
|
||||
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate.
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) and [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) are DETR based models that share the same ONNX input/output format. The ONNX exported models are supported, but not included by default. See the models section for downloading [D-FINE](#downloading-d-fine-model) or [DEIMv2](#downloading-deimv2-model) for use in Frigate.
|
||||
|
||||
:::warning
|
||||
|
||||
Currently D-FINE models only run on OpenVINO in CPU mode, GPUs currently fail to compile the model
|
||||
Currently D-FINE / DEIMv2 models only run on OpenVINO in CPU mode, GPUs currently fail to compile the model
|
||||
|
||||
:::
|
||||
|
||||
@@ -722,7 +728,7 @@ After placing the downloaded onnx model in your config/model_cache folder, use t
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **OpenVINO** detector type with device set to `CPU`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `CPU`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ---------------------------------- |
|
||||
@@ -760,6 +766,31 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>DEIMv2 Setup & Config</summary>
|
||||
|
||||
After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration:
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
ov:
|
||||
type: openvino
|
||||
device: CPU
|
||||
|
||||
model:
|
||||
model_type: dfine
|
||||
width: 640
|
||||
height: 640
|
||||
input_tensor: nchw
|
||||
input_dtype: float
|
||||
path: /config/model_cache/deimv2_hgnetv2_n.onnx
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
```
|
||||
|
||||
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
|
||||
|
||||
</details>
|
||||
|
||||
## Apple Silicon detector
|
||||
|
||||
The NPU in Apple Silicon can't be accessed from within a container, so the [Apple Silicon detector client](https://github.com/frigate-nvr/apple-silicon-detector) must first be setup. It is recommended to use the Frigate docker image with `-standard-arm64` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-standard-arm64`.
|
||||
@@ -776,7 +807,7 @@ Using the detector config below will connect to the client:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **ZMQ IPC** detector type with the endpoint set to `tcp://host.docker.internal:5555`.
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -810,7 +841,7 @@ When Frigate is started with the following config it will connect to the detecto
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **ZMQ IPC** detector type with the endpoint set to `tcp://host.docker.internal:5555`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
@@ -941,7 +972,7 @@ The AMD GPU kernel is known problematic especially when converting models to mxr
|
||||
|
||||
See [ONNX supported models](#supported-models) for supported models, there are some caveats:
|
||||
|
||||
- D-FINE models are not supported
|
||||
- D-FINE / DEIMv2 models are not supported
|
||||
- YOLO-NAS models are known to not run well on integrated GPUs
|
||||
|
||||
## ONNX
|
||||
@@ -971,7 +1002,7 @@ When using many cameras one detector may not be enough to keep up. Multiple dete
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and add multiple **ONNX** detectors.
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add** to add multiple detectors.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -991,13 +1022,13 @@ detectors:
|
||||
|
||||
### ONNX Supported Models
|
||||
|
||||
| Model | Nvidia GPU | AMD GPU | Notes |
|
||||
| ----------------------------- | ---------- | ------- | --------------------------------------------------- |
|
||||
| [YOLOv9](#yolo-v3-v4-v7-v9-2) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance |
|
||||
| [RF-DETR](#rf-detr) | ✅ | ❌ | Supports CUDA Graphs for optimal Nvidia performance |
|
||||
| [YOLO-NAS](#yolo-nas-1) | ⚠️ | ⚠️ | Not supported by CUDA Graphs |
|
||||
| [YOLOX](#yolox-1) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance |
|
||||
| [D-FINE](#d-fine) | ⚠️ | ❌ | Not supported by CUDA Graphs |
|
||||
| Model | Nvidia GPU | AMD GPU | Notes |
|
||||
| ------------------------------------ | ---------- | ------- | --------------------------------------------------- |
|
||||
| [YOLOv9](#yolo-v3-v4-v7-v9-2) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance |
|
||||
| [RF-DETR](#rf-detr) | ✅ | ⚠️ | Supports CUDA Graphs for optimal Nvidia performance |
|
||||
| [YOLO-NAS](#yolo-nas-1) | ⚠️ | ⚠️ | Not supported by CUDA Graphs |
|
||||
| [YOLOX](#yolox-1) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance |
|
||||
| [D-FINE / DEIMv2](#d-fine--deimv2-1) | ⚠️ | ❌ | Not supported by CUDA Graphs |
|
||||
|
||||
There is no default model provided, the following formats are supported:
|
||||
|
||||
@@ -1019,7 +1050,7 @@ After placing the downloaded onnx model in your config folder, use the following
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **ONNX** detector type. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------- |
|
||||
@@ -1078,7 +1109,7 @@ After placing the downloaded onnx model in your config folder, use the following
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **ONNX** detector type. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
@@ -1127,7 +1158,7 @@ After placing the downloaded onnx model in your config folder, use the following
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **ONNX** detector type. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
@@ -1176,7 +1207,7 @@ After placing the downloaded onnx model in your `config/model_cache` folder, use
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **ONNX** detector type. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| --------------------------------------- | --------------------------------- |
|
||||
@@ -1209,9 +1240,9 @@ model:
|
||||
|
||||
</details>
|
||||
|
||||
#### D-FINE
|
||||
#### D-FINE / DEIMv2
|
||||
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate.
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) and [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) are DETR based models that share the same ONNX input/output format. The ONNX exported models are supported, but not included by default. See the models section for downloading [D-FINE](#downloading-d-fine-model) or [DEIMv2](#downloading-deimv2-model) for use in Frigate.
|
||||
|
||||
<details>
|
||||
<summary>D-FINE Setup & Config</summary>
|
||||
@@ -1221,7 +1252,7 @@ After placing the downloaded onnx model in your `config/model_cache` folder, use
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **ONNX** detector type. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------- |
|
||||
@@ -1256,6 +1287,28 @@ model:
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>DEIMv2 Setup & Config</summary>
|
||||
|
||||
After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration:
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
onnx:
|
||||
type: onnx
|
||||
|
||||
model:
|
||||
model_type: dfine
|
||||
width: 640
|
||||
height: 640
|
||||
input_tensor: nchw
|
||||
input_dtype: float
|
||||
path: /config/model_cache/deimv2_hgnetv2_n.onnx
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
|
||||
|
||||
## CPU Detector (not recommended)
|
||||
@@ -1275,7 +1328,7 @@ A TensorFlow Lite model is provided in the container at `/cpu_model.tflite` and
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **CPU** detector type. Configure the number of threads and add additional CPU detectors as needed (one per camera is recommended).
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **CPU** from the detector type dropdown and click **Add**. Configure the number of threads and click **Add** again to add additional CPU detectors as needed (one per camera is recommended).
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -1311,7 +1364,7 @@ To integrate CodeProject.AI into Frigate, configure the detector as follows:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **DeepStack** detector type. Set the API URL to point to your CodeProject.AI server (e.g., `http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection`).
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **DeepStack** from the detector type dropdown and click **Add**. Set the API URL to point to your CodeProject.AI server (e.g., `http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection`).
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -1350,7 +1403,7 @@ To configure the MemryX detector, use the following example configuration:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **MemryX** detector type with device set to `PCIe:0`.
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -1370,7 +1423,7 @@ detectors:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and add multiple **MemryX** detectors, specifying `PCIe:0`, `PCIe:1`, `PCIe:2`, etc. as the device for each.
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add** to add multiple detectors, specifying `PCIe:0`, `PCIe:1`, `PCIe:2`, etc. as the device for each.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -1399,7 +1452,7 @@ MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the
|
||||
|
||||
#### YOLO-NAS
|
||||
|
||||
The [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) model included in this detector is downloaded from the [Models Section](#downloading-yolo-nas-model) and compiled to DFP with [mx_nc](https://developer.memryx.com/tools/neural_compiler.html#usage).
|
||||
The [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) model included in this detector is downloaded from the [Models Section](#downloading-yolo-nas-model) and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage).
|
||||
|
||||
**Note:** The default model for the MemryX detector is YOLO-NAS 320x320.
|
||||
|
||||
@@ -1414,7 +1467,7 @@ Below is the recommended configuration for using the **YOLO-NAS** (small) model
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **MemryX** detector type with device set to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------- |
|
||||
@@ -1453,7 +1506,7 @@ model:
|
||||
|
||||
#### YOLOv9
|
||||
|
||||
The YOLOv9s model included in this detector is downloaded from [the original GitHub](https://github.com/WongKinYiu/yolov9) like in the [Models Section](#yolov9-1) and compiled to DFP with [mx_nc](https://developer.memryx.com/tools/neural_compiler.html#usage).
|
||||
The YOLOv9s model included in this detector is downloaded from [the original GitHub](https://github.com/WongKinYiu/yolov9) like in the [Models Section](#yolov9-1) and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage).
|
||||
|
||||
##### Configuration
|
||||
|
||||
@@ -1462,7 +1515,7 @@ Below is the recommended configuration for using the **YOLOv9** (small) model wi
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **MemryX** detector type with device set to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------- |
|
||||
@@ -1509,7 +1562,7 @@ Below is the recommended configuration for using the **YOLOX** (small) model wit
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **MemryX** detector type with device set to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------- |
|
||||
@@ -1556,7 +1609,7 @@ Below is the recommended configuration for using the **SSDLite MobileNet v2** mo
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **MemryX** detector type with device set to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------- |
|
||||
@@ -1595,19 +1648,39 @@ model:
|
||||
|
||||
#### Using a Custom Model
|
||||
|
||||
To use your own model:
|
||||
To use your own custom model, first compile it into a [.dfp](https://developer.memryx.com/2p1/specs/files.html#dataflow-program) file, which is the format used by MemryX.
|
||||
|
||||
1. Package your compiled model into a `.zip` file.
|
||||
#### Compile the Model
|
||||
|
||||
2. The `.zip` must contain the compiled `.dfp` file.
|
||||
Custom models must be compiled using **MemryX SDK 2.1**.
|
||||
|
||||
3. Depending on the model, the compiler may also generate a cropped post-processing network. If present, it will be named with the suffix `_post.onnx`.
|
||||
Before compiling your model, install the MemryX Neural Compiler tools from the
|
||||
[Install Tools](https://developer.memryx.com/2p1/get_started/install_tools.html) page on the **host**.
|
||||
|
||||
4. Bind-mount the `.zip` file into the container and specify its path using `model.path` in your config.
|
||||
> **Note:** It is recommended to compile the model on the host machine, or on another separate machine, rather than inside the Frigate Docker container. Installing the compiler inside Docker may conflict with container packages. It is recommended to create a Python virtual environment and install the compiler there.
|
||||
|
||||
5. Update the `labelmap_path` to match your custom model's labels.
|
||||
Once the SDK 2.1 environment is set up, follow the
|
||||
[MemryX Compiler](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage) documentation to compile your model.
|
||||
|
||||
For detailed instructions on compiling models, refer to the [MemryX Compiler](https://developer.memryx.com/tools/neural_compiler.html#usage) docs and [Tutorials](https://developer.memryx.com/tutorials/tutorials.html).
|
||||
Example:
|
||||
|
||||
```bash
|
||||
mx_nc -m yolonas.onnx -c 4 --autocrop -v --dfp_fname yolonas.dfp
|
||||
```
|
||||
|
||||
For detailed instructions on compiling models, refer to the [MemryX Compiler](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage) docs and [Tutorials](https://developer.memryx.com/2p1/tutorials/tutorials.html).
|
||||
|
||||
#### Package the Compiled Model
|
||||
|
||||
1. Package your compiled model into a `.zip` file.
|
||||
|
||||
2. The `.zip` file must contain the compiled `.dfp` file.
|
||||
|
||||
3. Depending on the model, the compiler may also generate a cropped post-processing network. If present, it will be named with the suffix `_post.onnx`.
|
||||
|
||||
4. Bind-mount the `.zip` file into the container and specify its path using `model.path` in your config.
|
||||
|
||||
5. Update `labelmap_path` to match your custom model's labels.
|
||||
|
||||
```yaml
|
||||
# The detector automatically selects the default model if nothing is provided in the config.
|
||||
@@ -1695,7 +1768,7 @@ Use the config below to work with generated TRT models:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **TensorRT** detector type with the device set to `0` (the default GPU index). Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **TensorRT** from the detector type dropdown and click **Add**, then set the device to `0` (the default GPU index). Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------------------ |
|
||||
@@ -1752,14 +1825,14 @@ Use the model configuration shown below when using the synaptics detector with t
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **Synaptics** detector type. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **Synaptics** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ---------------------------- |
|
||||
| **Custom object detector model path** | `/synaptics/mobilenet.synap` |
|
||||
| **Object detection model input width** | `224` |
|
||||
| **Object detection model input height** | `224` |
|
||||
| **Tensor format** | `nhwc` |
|
||||
| **Model Input Tensor Shape** | `nhwc` |
|
||||
| **Label map for custom object detector** | `/labelmap/coco-80.txt` |
|
||||
|
||||
</TabItem>
|
||||
@@ -1774,7 +1847,7 @@ model: # required
|
||||
path: /synaptics/mobilenet.synap # required
|
||||
width: 224 # required
|
||||
height: 224 # required
|
||||
tensor_format: nhwc # default value (optional. If you change the model, it is required)
|
||||
input_tensor: nhwc # default value (optional. If you change the model, it is required)
|
||||
labelmap_path: /labelmap/coco-80.txt # required
|
||||
```
|
||||
|
||||
@@ -1793,6 +1866,12 @@ Hardware accelerated object detection is supported on the following SoCs:
|
||||
|
||||
This implementation uses the [Rockchip's RKNN-Toolkit2](https://github.com/airockchip/rknn-toolkit2/), version v2.3.2.
|
||||
|
||||
:::info
|
||||
|
||||
If no custom model is provided, the RKNN detector downloads a default model from GitHub on first startup. Once cached, the model works fully offline. See [Network Requirements](/frigate/network_requirements#hardware-specific-detector-models) for details.
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
When using many cameras one detector may not be enough to keep up. Multiple detectors can be defined assuming NPU resources are available. An example configuration would be:
|
||||
@@ -1800,7 +1879,7 @@ When using many cameras one detector may not be enough to keep up. Multiple dete
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and add multiple **RKNN** detectors, each with `num_cores` set to `0` for automatic selection.
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **RKNN** from the detector type dropdown and click **Add** to add multiple detectors, each with `num_cores` set to `0` for automatic selection.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -1842,7 +1921,7 @@ This `config.yml` shows all relevant options to configure the detector and expla
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **RKNN** detector type. Set `num_cores` to `0` for automatic selection (increase for better performance on multicore NPUs, e.g., set to `3` on rk3588).
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **RKNN** from the detector type dropdown and click **Add**. Set `num_cores` to `0` for automatic selection (increase for better performance on multicore NPUs, e.g., set to `3` on rk3588).
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -2059,7 +2138,7 @@ Once completed, configure the detector as follows:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **DeGirum** detector type. Set the location to your AI server (e.g., service name, container name, or `host:port`), the zoo to `degirum/public`, and provide your authentication token if needed.
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **DeGirum** from the detector type dropdown and click **Add**. Set the location to your AI server (e.g., service name, container name, or `host:port`), the zoo to `degirum/public`, and provide your authentication token if needed.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -2102,7 +2181,7 @@ It is also possible to eliminate the need for an AI server and run the hardware
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **DeGirum** detector type. Set the location to `@local`, the zoo to `degirum/public`, and provide your authentication token.
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **DeGirum** from the detector type dropdown and click **Add**. Set the location to `@local`, the zoo to `degirum/public`, and provide your authentication token.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -2139,7 +2218,7 @@ If you do not possess whatever hardware you want to run, there's also the option
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **DeGirum** detector type. Set the location to `@cloud`, the zoo to `degirum/public`, and provide your authentication token.
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **DeGirum** from the detector type dropdown and click **Add**. Set the location to `@cloud`, the zoo to `degirum/public`, and provide your authentication token.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -2176,6 +2255,12 @@ This implementation uses the [AXera Pulsar2 Toolchain](https://huggingface.co/AX
|
||||
|
||||
See the [installation docs](../frigate/installation.md#axera) for information on configuring the AXEngine hardware.
|
||||
|
||||
:::info
|
||||
|
||||
The AXEngine detector downloads its default model from HuggingFace on first startup. Once cached, the model works fully offline. See [Network Requirements](/frigate/network_requirements#hardware-specific-detector-models) for details.
|
||||
|
||||
:::
|
||||
|
||||
### Configuration
|
||||
|
||||
When configuring the AXEngine detector, you have to specify the model name.
|
||||
@@ -2189,7 +2274,7 @@ Use the model configuration shown below when using the axengine detector with th
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select the **AXEngine NPU** detector type. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **AXEngine NPU** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------- |
|
||||
@@ -2256,6 +2341,49 @@ COPY --from=build /dfine/output/dfine_${MODEL_SIZE}_obj2coco.onnx /dfine-${MODEL
|
||||
EOF
|
||||
```
|
||||
|
||||
### Downloading DEIMv2 Model
|
||||
|
||||
[DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) can be exported as ONNX by running the command below. Pretrained weights are available on Hugging Face for two backbone families:
|
||||
|
||||
- **HGNetv2** (smaller/faster): `atto`, `femto`, `pico`, `n`
|
||||
- **DINOv3** (larger/more accurate): `s`, `m`, `l`, `x`
|
||||
|
||||
Set `BACKBONE` and `MODEL_SIZE` in the first line to match your desired variant. Hugging Face model names use uppercase (e.g. `HGNetv2_N`, `DINOv3_S`), while config files use lowercase (e.g. `hgnetv2_n`, `dinov3_s`).
|
||||
|
||||
```sh
|
||||
docker build . --rm --build-arg BACKBONE=hgnetv2 --build-arg MODEL_SIZE=n --output . -f- <<'EOF'
|
||||
FROM python:3.11-slim AS build
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y git libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/
|
||||
WORKDIR /deimv2
|
||||
RUN git clone https://github.com/Intellindust-AI-Lab/DEIMv2.git .
|
||||
# Install CPU-only PyTorch first to avoid pulling CUDA variant
|
||||
RUN uv pip install --no-cache --system torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
||||
RUN uv pip install --no-cache --system -r requirements.txt
|
||||
RUN uv pip install --no-cache --system onnx safetensors huggingface_hub
|
||||
RUN mkdir -p output
|
||||
ARG BACKBONE
|
||||
ARG MODEL_SIZE
|
||||
# Download from Hugging Face and convert safetensors to pth
|
||||
RUN python3 -c "\
|
||||
from huggingface_hub import hf_hub_download; \
|
||||
from safetensors.torch import load_file; \
|
||||
import torch; \
|
||||
backbone = '${BACKBONE}'.replace('hgnetv2','HGNetv2').replace('dinov3','DINOv3'); \
|
||||
size = '${MODEL_SIZE}'.upper(); \
|
||||
st = load_file(hf_hub_download('Intellindust/DEIMv2_' + backbone + '_' + size + '_COCO', 'model.safetensors')); \
|
||||
torch.save({'model': st}, 'output/deimv2.pth')"
|
||||
RUN sed -i "s/data = torch.rand(2/data = torch.rand(1/" tools/deployment/export_onnx.py
|
||||
# HuggingFace safetensors omits frozen constants that the model constructor initializes
|
||||
RUN sed -i "s/cfg.model.load_state_dict(state)/cfg.model.load_state_dict(state, strict=False)/" tools/deployment/export_onnx.py
|
||||
RUN python3 tools/deployment/export_onnx.py -c configs/deimv2/deimv2_${BACKBONE}_${MODEL_SIZE}_coco.yml -r output/deimv2.pth
|
||||
FROM scratch
|
||||
ARG BACKBONE
|
||||
ARG MODEL_SIZE
|
||||
COPY --from=build /deimv2/output/deimv2.onnx /deimv2_${BACKBONE}_${MODEL_SIZE}.onnx
|
||||
EOF
|
||||
```
|
||||
|
||||
### Downloading RF-DETR Model
|
||||
|
||||
RF-DETR can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=Nano` in the first line to `Nano`, `Small`, or `Medium` size.
|
||||
|
||||
@@ -24,6 +24,12 @@ For object filters, any single detection below `min_score` will be ignored as a
|
||||
|
||||
In frame 2, the score is below the `min_score` value, so Frigate ignores it and it becomes a 0.0. The computed score is the median of the score history (padding to at least 3 values), and only when that computed score crosses the `threshold` is the object marked as a true positive. That happens in frame 4 in the example.
|
||||
|
||||
The **top score** is the highest computed score the tracked object has ever reached during its lifetime. Because the computed score rises and falls as new frames come in, the top score can be thought of as the peak confidence Frigate had in the object. In Frigate's UI (such as the Tracking Details pane in Explore), you may see all three values:
|
||||
|
||||
- **Score** — the raw detector score for that single frame.
|
||||
- **Computed Score** — the median of the most recent score history at that moment. This is the value compared against `threshold`.
|
||||
- **Top Score** — the highest computed score reached so far for the tracked object.
|
||||
|
||||
### Minimum Score
|
||||
|
||||
Any detection below `min_score` will be immediately thrown out and never tracked because it is considered a false positive. If `min_score` is too low then false positives may be detected and tracked which can confuse the object tracker and may lead to wasted resources. If `min_score` is too high then lower scoring true positives like objects that are further away or partially occluded may be thrown out which can also confuse the tracker and cause valid tracked objects to be lost or disjointed.
|
||||
|
||||
@@ -20,7 +20,7 @@ When a profile is activated, Frigate merges each camera's profile overrides on t
|
||||
|
||||
:::info
|
||||
|
||||
Profile changes are applied in-memory and take effect immediately — no restart is required. The active profile is persisted across Frigate restarts (stored in the `/config/.active_profile` file).
|
||||
Profile changes are applied in-memory and take effect immediately — no restart is required. The active profile is persisted across Frigate restarts (stored in the `/config/.profiles` file).
|
||||
|
||||
:::
|
||||
|
||||
@@ -120,7 +120,7 @@ The following camera configuration sections can be overridden in a profile:
|
||||
|
||||
:::note
|
||||
|
||||
Only the fields you explicitly set in a profile override are applied. All other fields retain their base configuration values. For zones, profile zones are merged with the camera's base zones — any zone defined in the profile will override or add to the base zones.
|
||||
Only the fields you explicitly set in a profile override are applied. All other fields retain their base configuration values. For masks and zones, profile zones **override** the camera's base masks and zones. If configuring profiles via YAML, you should not define masks or zones in profiles that are not defined in the base config.
|
||||
|
||||
:::
|
||||
|
||||
@@ -130,14 +130,14 @@ Profiles can be activated and deactivated from the Frigate UI. Open the Settings
|
||||
|
||||
## Example: Home / Away Setup
|
||||
|
||||
A common use case is having different detection and notification settings based on whether you are home or away.
|
||||
A common use case is having different detection and notification settings based on whether you are home or away. This example below is for a system with two cameras, `front_door` and `indoor_cam`.
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Profiles" /> and create two profiles: **Home** and **Away**.
|
||||
2. For the **front_door** camera, configure the **Away** profile to enable notifications and set alert labels to `person` and `car`. Configure the **Home** profile to disable notifications.
|
||||
3. For the **indoor_cam** camera, configure the **Away** profile to enable the camera, detection, and recording. Configure the **Home** profile to disable the camera entirely for privacy.
|
||||
2. From to the Camera configuration section in Settings, choose the **front_door** camera, and select the **Away** profile from the profile dropdown. Then, enable notifications from the Notifications pane, and set alert labels to `person` and `car` from the Review pane. Then, from the profile dropdown choose **Home** profile, then navigate to Notifications to disable notifications.
|
||||
3. For the **indoor_cam** camera, perform similar steps - configure the **Away** profile to enable the camera, detection, and recording. Configure the **Home** profile to disable the camera entirely for privacy.
|
||||
4. Activate the desired profile from <NavPath path="Settings > Camera configuration > Profiles" /> or from the **Profiles** option in Frigate's main menu.
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -123,9 +123,79 @@ record:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
## Pre-capture and Post-capture
|
||||
|
||||
The `pre_capture` and `post_capture` settings control how many seconds of video are included before and after an alert or detection. These can be configured independently for alerts and detections, and can be set globally or overridden per camera.
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Recording" /> for global defaults, or <NavPath path="Settings > Camera configuration > (select camera) > Recording" /> to override for a specific camera.
|
||||
|
||||
| Field | Description |
|
||||
| ---------------------------------------------- | ---------------------------------------------------- |
|
||||
| **Alert retention > Pre-capture seconds** | Seconds of video to include before an alert event |
|
||||
| **Alert retention > Post-capture seconds** | Seconds of video to include after an alert event |
|
||||
| **Detection retention > Pre-capture seconds** | Seconds of video to include before a detection event |
|
||||
| **Detection retention > Post-capture seconds** | Seconds of video to include after a detection event |
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
```yaml
|
||||
record:
|
||||
enabled: True
|
||||
alerts:
|
||||
pre_capture: 5 # seconds before the alert to include
|
||||
post_capture: 5 # seconds after the alert to include
|
||||
detections:
|
||||
pre_capture: 5 # seconds before the detection to include
|
||||
post_capture: 5 # seconds after the detection to include
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
- **Default**: 5 seconds for both pre and post capture.
|
||||
- **Pre-capture maximum**: 60 seconds.
|
||||
- These settings apply per review category (alerts and detections), not per object type.
|
||||
|
||||
### How pre/post capture interacts with retention mode
|
||||
|
||||
The `pre_capture` and `post_capture` values define the **time window** around a review item, but only recording segments that also match the configured **retention mode** are actually kept on disk.
|
||||
|
||||
- **`mode: all`** — Retains every segment within the capture window, regardless of whether motion was detected.
|
||||
- **`mode: motion`** (default) — Only retains segments within the capture window that contain motion. This includes segments with active tracked objects, since object motion implies motion. Segments without any motion are discarded even if they fall within the pre/post capture range.
|
||||
- **`mode: active_objects`** — Only retains segments within the capture window where tracked objects were actively moving. Segments with general motion but no active objects are discarded.
|
||||
|
||||
This means that with the default `motion` mode, you may see less footage than the configured pre/post capture duration if parts of the capture window had no motion.
|
||||
|
||||
To guarantee the full pre/post capture duration is always retained:
|
||||
|
||||
```yaml
|
||||
record:
|
||||
enabled: True
|
||||
alerts:
|
||||
pre_capture: 10
|
||||
post_capture: 10
|
||||
retain:
|
||||
days: 30
|
||||
mode: all # retains all segments within the capture window
|
||||
```
|
||||
|
||||
:::note
|
||||
|
||||
Because recording segments are written in 10 second chunks, pre-capture timing depends on segment boundaries. The actual pre-capture footage may be slightly shorter or longer than the exact configured value.
|
||||
|
||||
:::
|
||||
|
||||
### Where to view pre/post capture footage
|
||||
|
||||
Pre and post capture footage is included in the **recording timeline**, visible in the History view. Note that pre/post capture settings only affect which recording segments are **retained on disk** — they do not change the start and end points shown in the UI. The History view will still center on the review item's actual time range, but you can scrub backward and forward through the retained pre/post capture footage on the timeline. The Explore view shows object-specific clips that are trimmed to when the tracked object was actually visible, so pre/post capture time will not be reflected there.
|
||||
|
||||
## Will Frigate delete old recordings if my storage runs out?
|
||||
|
||||
As of Frigate 0.12 if there is less than an hour left of storage, the oldest 2 hours of recordings will be deleted.
|
||||
If there is less than an hour left of storage, the oldest hour of recordings will be deleted and a message will be printed in the Frigate logs. This emergency cleanup deletes the oldest recordings first regardless of retention settings to reclaim space as quickly as possible.
|
||||
|
||||
## Configuring Recording Retention
|
||||
|
||||
@@ -211,31 +281,52 @@ Using Frigate UI, Home Assistant, or MQTT, cameras can be automated to only reco
|
||||
|
||||
Footage can be exported from Frigate by right-clicking (desktop) or long pressing (mobile) on a review item in the Review pane or by clicking the Export button in the History view. Exported footage is then organized and searchable through the Export view, accessible from the main navigation bar.
|
||||
|
||||
### Time-lapse export
|
||||
### Custom export with FFmpeg arguments
|
||||
|
||||
Time lapse exporting is available only via the [HTTP API](../integrations/api/export-recording-export-camera-name-start-start-time-end-end-time-post.api.mdx).
|
||||
For advanced use cases, the [custom export HTTP API](../integrations/api/export-recording-custom-export-custom-camera-name-start-start-time-end-end-time-post.api.mdx) lets you pass custom FFmpeg arguments when exporting a recording:
|
||||
|
||||
When exporting a time-lapse the default speed-up is 25x with 30 FPS. This means that every 25 seconds of (real-time) recording is condensed into 1 second of time-lapse video (always without audio) with a smoothness of 30 FPS.
|
||||
|
||||
To configure the speed-up factor, the frame rate and further custom settings, use the `timelapse_args` parameter. The below configuration example would change the time-lapse speed to 60x (for fitting 1 hour of recording into 1 minute of time-lapse) with 25 FPS:
|
||||
|
||||
```yaml {3-4}
|
||||
record:
|
||||
enabled: True
|
||||
export:
|
||||
timelapse_args: "-vf setpts=PTS/60 -r 25"
|
||||
```
|
||||
POST /export/custom/{camera_name}/start/{start_time}/end/{end_time}
|
||||
```
|
||||
|
||||
:::tip
|
||||
The request body accepts `ffmpeg_input_args` and `ffmpeg_output_args` to control encoding, frame rate, filters, and other FFmpeg options. If neither is provided, Frigate defaults to time-lapse output settings (25x speed, 30 FPS).
|
||||
|
||||
When using `hwaccel_args`, hardware encoding is used for timelapse generation. This setting can be overridden for a specific camera (e.g., when camera resolution exceeds hardware encoder limits); set the camera-level export hwaccel_args with the appropriate settings. Using an unrecognized value or empty string will fall back to software encoding (libx264).
|
||||
The following example exports a time-lapse at 60x speed with 25 FPS:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Front Door Time-lapse",
|
||||
"ffmpeg_output_args": "-vf setpts=PTS/60 -r 25"
|
||||
}
|
||||
```
|
||||
|
||||
#### CPU fallback
|
||||
|
||||
If hardware acceleration is configured and the export fails (e.g., the GPU is unavailable), set `cpu_fallback: true` in the request body to automatically retry using software encoding.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Export",
|
||||
"ffmpeg_output_args": "-c:v libx264 -crf 23",
|
||||
"cpu_fallback": true
|
||||
}
|
||||
```
|
||||
|
||||
:::note
|
||||
|
||||
Non-admin users are restricted from using FFmpeg arguments that can access the filesystem (e.g., `-filter_complex`, file paths, and protocol references). Admin users have full control over FFmpeg arguments.
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
The encoder determines its own behavior so the resulting file size may be undesirably large.
|
||||
To reduce the output file size the ffmpeg parameter `-qp n` can be utilized (where `n` stands for the value of the quantisation parameter). The value can be adjusted to get an acceptable tradeoff between quality and file size for the given scenario.
|
||||
When `hwaccel_args` is configured, hardware encoding is used for exports. This can be overridden per camera (e.g., when camera resolution exceeds hardware encoder limits) by setting a camera-level `hwaccel_args`. Using an unrecognized value or empty string falls back to software encoding (libx264).
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
To reduce output file size, add the FFmpeg parameter `-qp n` to `ffmpeg_output_args` (where `n` is the quantization parameter). Adjust the value to balance quality and file size for your scenario.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ Enabling arbitrary exec sources allows execution of arbitrary commands through g
|
||||
|
||||
## Advanced Restream Configurations
|
||||
|
||||
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
|
||||
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#source-exec) source in go2rtc can be used for custom ffmpeg commands and other applications. An example is below:
|
||||
|
||||
:::warning
|
||||
|
||||
@@ -244,16 +244,11 @@ The `exec:`, `echo:`, and `expr:` sources are disabled by default for security.
|
||||
|
||||
:::
|
||||
|
||||
:::warning
|
||||
|
||||
The `exec:`, `echo:`, and `expr:` sources are disabled by default for security. You must set `GO2RTC_ALLOW_ARBITRARY_EXEC=true` to use them. See [Security: Restricted Stream Sources](#security-restricted-stream-sources) for more information.
|
||||
|
||||
:::
|
||||
|
||||
NOTE: The output will need to be passed with two curly braces `{{output}}`
|
||||
NOTE: RTSP output will need to be passed with two curly braces `{{output}}`, whereas pipe output must be passed without curly braces.
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {{output}}
|
||||
stream2: exec:rpicam-vid -t 0 --libav-format h264 -o -
|
||||
```
|
||||
|
||||
@@ -13,6 +13,12 @@ Frigate uses models from [Jina AI](https://huggingface.co/jinaai) to create and
|
||||
|
||||
Semantic Search is accessed via the _Explore_ view in the Frigate UI.
|
||||
|
||||
:::info
|
||||
|
||||
Semantic search requires a one-time internet connection to download embedding models from HuggingFace. Once cached, models work fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
|
||||
|
||||
:::
|
||||
|
||||
## Minimum System Requirements
|
||||
|
||||
Semantic Search works by running a large AI model locally on your system. Small or underpowered systems like a Raspberry Pi will not run Semantic Search reliably or at all.
|
||||
|
||||
@@ -146,17 +146,11 @@ A single Coral can handle many cameras using the default model and will be suffi
|
||||
The OpenVINO detector type is able to run on:
|
||||
|
||||
- 6th Gen Intel Platforms and newer that have an iGPU
|
||||
- x86 hosts with an Intel Arc GPU
|
||||
- x86 hosts with an Intel Arc GPU (including Arc A-series and B-series Battlemage)
|
||||
- Intel NPUs
|
||||
- Most modern AMD CPUs (though this is officially not supported by Intel)
|
||||
- x86 & Arm64 hosts via CPU (generally not recommended)
|
||||
|
||||
:::note
|
||||
|
||||
Intel B-series (Battlemage) GPUs are not officially supported with Frigate 0.17, though a user has [provided steps to rebuild the Frigate container](https://github.com/blakeblackshear/frigate/discussions/21257) with support for them.
|
||||
|
||||
:::
|
||||
|
||||
More information is available [in the detector docs](/configuration/object_detectors#openvino-detector)
|
||||
|
||||
Inference speeds vary greatly depending on the CPU or GPU used, some known examples of GPU inference times are below:
|
||||
@@ -229,10 +223,11 @@ Apple Silicon can not run within a container, so a ZMQ proxy is utilized to comm
|
||||
|
||||
With the [ROCm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many discrete AMD GPUs.
|
||||
|
||||
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time |
|
||||
| --------- | --------------------------- | ------------------------- |
|
||||
| AMD 780M | t-320: ~ 14 ms s-320: 20 ms | 320: ~ 25 ms 640: ~ 50 ms |
|
||||
| AMD 8700G | | 320: ~ 20 ms 640: ~ 40 ms |
|
||||
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
|
||||
| -------------- | --------------------------- | ------------------------- | ---------------------- |
|
||||
| AMD 780M | t-320: ~ 14 ms s-320: 20 ms | 320: ~ 25 ms 640: ~ 50 ms | |
|
||||
| AMD 8700G | | 320: ~ 20 ms 640: ~ 40 ms | |
|
||||
| AMD 9060XT 16G | t-320: ~ 4 ms s-320: 5 ms | 320: ~ 6 ms | Nano-320: ~ 90 ms |
|
||||
|
||||
## Community Supported Detectors
|
||||
|
||||
|
||||
@@ -4,12 +4,15 @@ title: Installation
|
||||
---
|
||||
|
||||
import ShmCalculator from '@site/src/components/ShmCalculator'
|
||||
import DockerComposeGenerator from '@site/src/components/DockerComposeGenerator'
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App.
|
||||
|
||||
:::tip
|
||||
|
||||
If you already have Frigate installed as a Home Assistant App, check out the [getting started guide](../guides/getting_started#configuring-frigate) to configure Frigate.
|
||||
If you already have Frigate installed as a Home Assistant App, check out the [getting started guide](../guides/getting_started.md#configuring-frigate) to configure Frigate.
|
||||
|
||||
:::
|
||||
|
||||
@@ -286,7 +289,7 @@ The MemryX MX3 Accelerator is available in the M.2 2280 form factor (like an NVM
|
||||
|
||||
#### Installation
|
||||
|
||||
To get started with MX3 hardware setup for your system, refer to the [Hardware Setup Guide](https://developer.memryx.com/get_started/hardware_setup.html).
|
||||
To get started with MX3 hardware setup for your system, refer to the [Hardware Setup Guide](https://developer.memryx.com/2p1/get_started/install_hardware.html).
|
||||
|
||||
Then follow these steps for installing the correct driver/runtime configuration:
|
||||
|
||||
@@ -295,6 +298,12 @@ Then follow these steps for installing the correct driver/runtime configuration:
|
||||
3. Run the script with `./user_installation.sh`
|
||||
4. **Restart your computer** to complete driver installation.
|
||||
|
||||
:::warning
|
||||
|
||||
For manual setup, use **MemryX SDK 2.1** only. Other SDK versions are not supported for this setup. See the [SDK 2.1 documentation](https://developer.memryx.com/2p1/index.html)
|
||||
|
||||
:::
|
||||
|
||||
#### Setup
|
||||
|
||||
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
|
||||
@@ -468,6 +477,16 @@ Finally, configure [hardware object detection](/configuration/object_detectors#a
|
||||
|
||||
Running through Docker with Docker Compose is the recommended install method.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="domestic" label="Docker Compose Generator" default>
|
||||
|
||||
Generate a Frigate Docker Compose configuration based on your hardware and requirements.
|
||||
|
||||
<DockerComposeGenerator/>
|
||||
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="original" label="Example Docker Compose File">
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
@@ -482,7 +501,8 @@ services:
|
||||
- /dev/apex_0:/dev/apex_0 # Passes a PCIe Coral, follow driver instructions here https://github.com/jnicolson/gasket-builder
|
||||
- /dev/video11:/dev/video11 # For Raspberry Pi 4B
|
||||
- /dev/dri/renderD128:/dev/dri/renderD128 # AMD / Intel GPU, needs to be updated for your hardware
|
||||
- /dev/accel:/dev/accel # Intel NPU
|
||||
- /dev/kfd:/dev/kfd # AMD Kernel Fusion Driver for ROCm
|
||||
- /dev/accel:/dev/accel # AMD / Intel NPU
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /path/to/your/config:/config
|
||||
@@ -500,6 +520,10 @@ services:
|
||||
environment:
|
||||
FRIGATE_RTSP_PASSWORD: "password"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
**Docker CLI**
|
||||
|
||||
If you can't use Docker Compose, you can run the container with something similar to this:
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
id: network_requirements
|
||||
title: Network Requirements
|
||||
---
|
||||
|
||||
# Network Requirements
|
||||
|
||||
Frigate is designed to run locally and does not require a persistent internet connection for core functionality. However, certain features need internet access for initial setup or ongoing operation. This page describes what connects to the internet, when, and how to control it.
|
||||
|
||||
## How Frigate Uses the Internet
|
||||
|
||||
Frigate's internet usage falls into three categories:
|
||||
|
||||
1. **One-time model downloads** — ML models are downloaded the first time a feature is enabled, then cached locally. No internet is needed on subsequent startups.
|
||||
2. **Optional cloud services** — Features like Frigate+ and Generative AI connect to external APIs only when explicitly configured.
|
||||
3. **Build-time dependencies** — Components bundled into the Docker image during the build process. These require no internet at runtime.
|
||||
|
||||
:::tip
|
||||
|
||||
After initial setup, Frigate can run fully offline as long as all required models have been downloaded and no cloud-dependent features are enabled.
|
||||
|
||||
:::
|
||||
|
||||
## One-Time Model Downloads
|
||||
|
||||
The following models are downloaded automatically the first time their associated feature is enabled. Once cached in `/config/model_cache/`, they do not require internet again.
|
||||
|
||||
| Feature | Models Downloaded | Source |
|
||||
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------- |
|
||||
| [Semantic search](/configuration/semantic_search) | Jina CLIP v1 or v2 (ONNX) + tokenizer | HuggingFace |
|
||||
| [Face recognition](/configuration/face_recognition) | FaceNet, ArcFace, face detection model | GitHub |
|
||||
| [License plate recognition](/configuration/license_plate_recognition) | PaddleOCR (detection, classification, recognition) + YOLOv9 plate detector | GitHub |
|
||||
| [Bird classification](/configuration/bird_classification) | MobileNetV2 bird model + label map | GitHub |
|
||||
| [Custom classification](/configuration/custom_classification/state_classification) (training) | MobileNetV2 ImageNet base weights (via Keras) | Google storage |
|
||||
| [Audio transcription](/configuration/advanced) | Whisper or Sherpa-ONNX streaming model | HuggingFace / OpenAI |
|
||||
|
||||
### Hardware-Specific Detector Models
|
||||
|
||||
If you are using one of the following hardware detectors and have not provided your own model file, a default model will be downloaded on first startup:
|
||||
|
||||
| Detector | Model Downloaded | Source |
|
||||
| ------------------------------------------------------------------ | -------------------- | ------------------------ |
|
||||
| [Rockchip RKNN](/configuration/object_detectors#rockchip-platform) | RKNN detection model | GitHub |
|
||||
| [Hailo 8 / 8L](/configuration/object_detectors#hailo-8) | YOLOv6n (.hef) | Hailo Model Zoo (AWS S3) |
|
||||
| [AXERA AXEngine](/configuration/object_detectors) | Detection model | HuggingFace |
|
||||
|
||||
:::note
|
||||
|
||||
The default CPU, EdgeTPU, and OpenVINO object detection models are bundled into the Docker image and do not require any download at runtime.
|
||||
|
||||
:::
|
||||
|
||||
### Preventing Model Downloads
|
||||
|
||||
If you have already downloaded all required models and want to prevent Frigate from attempting any outbound connections to HuggingFace or the Transformers library, set the following environment variables on your Frigate container:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
TRANSFORMERS_OFFLINE: "1"
|
||||
```
|
||||
|
||||
:::warning
|
||||
|
||||
Setting these variables without having the correct model files already cached in `/config/model_cache/` will cause failures. Only use these after a successful initial setup with internet access.
|
||||
|
||||
:::
|
||||
|
||||
### Mirror Support
|
||||
|
||||
If your Frigate instance has restricted internet access, you can point model downloads at internal mirrors using environment variables:
|
||||
|
||||
| Environment Variable | Default | Used By |
|
||||
| ----------------------------------- | ----------------------------------- | --------------------------------------------- |
|
||||
| `HF_ENDPOINT` | `https://huggingface.co` | Semantic search, Sherpa-ONNX, AXEngine models |
|
||||
| `GITHUB_ENDPOINT` | `https://github.com` | Face recognition, LPR, RKNN models |
|
||||
| `GITHUB_RAW_ENDPOINT` | `https://raw.githubusercontent.com` | Bird classification |
|
||||
| `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` | Google storage (Keras default) | Custom classification training |
|
||||
|
||||
## Optional Cloud Services
|
||||
|
||||
These features connect to external services during normal operation and require internet whenever they are active.
|
||||
|
||||
### Frigate+
|
||||
|
||||
When a Frigate+ API key is configured, Frigate communicates with `https://api.frigate.video` to download models, upload snapshots for training, submit annotations, and report false positives. Remove the API key to disable all Frigate+ network activity.
|
||||
|
||||
See [Frigate+](/integrations/plus) for details.
|
||||
|
||||
### Generative AI
|
||||
|
||||
When a Generative AI provider is configured, Frigate sends images and prompts to the configured provider for event descriptions, chat, and camera monitoring. Available providers:
|
||||
|
||||
| Provider | Internet Required |
|
||||
| ------------- | ---------------------------------------------------------------- |
|
||||
| OpenAI | Yes — connects to OpenAI API (or custom base URL) |
|
||||
| Google Gemini | Yes — connects to Google Generative AI API |
|
||||
| Azure OpenAI | Yes — connects to your Azure endpoint |
|
||||
| Ollama | Depends — typically local (`localhost:11434`), but can be remote |
|
||||
| llama.cpp | No — runs entirely locally |
|
||||
|
||||
Disable Generative AI by removing the `genai` configuration from your cameras. See [Generative AI](/configuration/genai/genai_config) for details.
|
||||
|
||||
### Version Check
|
||||
|
||||
Frigate checks GitHub for the latest release version on startup by querying `https://api.github.com`. This can be disabled:
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
version_check: false
|
||||
```
|
||||
|
||||
### Push Notifications
|
||||
|
||||
When [notifications](/configuration/notifications) are enabled and users have registered for push notifications in the web UI, Frigate sends push messages through the browser vendor's push service (e.g., Google FCM, Mozilla autopush). This requires internet access from the Frigate server to these push endpoints.
|
||||
|
||||
### MQTT
|
||||
|
||||
If an [MQTT broker](/integrations/mqtt) is configured, Frigate maintains a connection to the broker's host and port. This is typically a local network connection, but will require internet if you use a cloud-hosted MQTT broker.
|
||||
|
||||
### DeepStack / CodeProject.AI
|
||||
|
||||
When using the [DeepStack detector plugin](/configuration/object_detectors), Frigate sends images to the configured API endpoint for inference. This is typically local but depends on where the service is hosted.
|
||||
|
||||
## WebRTC (STUN)
|
||||
|
||||
For [WebRTC live streaming](/configuration/live), Frigate uses STUN for NAT traversal:
|
||||
|
||||
- **go2rtc** defaults to a local STUN listener (`stun:8555`) — no internet required.
|
||||
- **The web UI's WebRTC player** includes a fallback to Google's public STUN server (`stun:stun.l.google.com:19302`), which requires internet.
|
||||
|
||||
## Home Assistant Supervisor
|
||||
|
||||
When running as a Home Assistant add-on, the go2rtc startup script queries the local Supervisor API (`http://supervisor/`) to discover the host IP address and WebRTC port. This is a local network call to the Home Assistant host, not an internet connection.
|
||||
|
||||
## What Does NOT Require Internet
|
||||
|
||||
- **Object detection** — CPU, EdgeTPU, OpenVINO, and other bundled detector models are included in the Docker image.
|
||||
- **Recording and playback** — All video is stored and served locally.
|
||||
- **Live streaming** — Camera streams are pulled over your local network. MSE and HLS streaming work without any external connections.
|
||||
- **The web interface** — Fully self-contained with no external fonts, scripts, analytics, or CDN dependencies. All translations are bundled locally.
|
||||
- **Custom classification inference** — After training, custom models run entirely locally.
|
||||
- **Audio detection** — The YAMNet audio classification model is bundled in the Docker image.
|
||||
|
||||
## Running Frigate Offline
|
||||
|
||||
To run Frigate in an air-gapped or offline environment:
|
||||
|
||||
1. **Pre-download models** — Start Frigate with internet access once with all desired features enabled. Models will be cached in `/config/model_cache/`.
|
||||
2. **Disable version check** — Set `telemetry.version_check: false` in your configuration.
|
||||
3. **Block outbound model requests** — Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests.
|
||||
4. **Avoid cloud features** — Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers.
|
||||
5. **Use local model mirrors** — If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, and `GITHUB_RAW_ENDPOINT` environment variables to point to local mirrors.
|
||||
|
||||
After these steps, Frigate will operate with no outbound internet connections.
|
||||
@@ -5,6 +5,12 @@ title: MQTT
|
||||
|
||||
These are the MQTT messages generated by Frigate. The default topic_prefix is `frigate`, but can be changed in the config file.
|
||||
|
||||
:::info
|
||||
|
||||
MQTT requires a network connection to your broker. This is typically local, but will require internet if using a cloud-hosted MQTT broker. See [Network Requirements](/frigate/network_requirements#mqtt) for details.
|
||||
|
||||
:::
|
||||
|
||||
## General Frigate Topics
|
||||
|
||||
### `frigate/available`
|
||||
|
||||
@@ -5,6 +5,12 @@ title: Frigate+
|
||||
|
||||
For more information about how to use Frigate+ to improve your model, see the [Frigate+ docs](/plus/).
|
||||
|
||||
:::info
|
||||
|
||||
Frigate+ requires an active internet connection to communicate with `https://api.frigate.video` for model downloads, image uploads, and annotations. See [Network Requirements](/frigate/network_requirements#frigate) for details.
|
||||
|
||||
:::
|
||||
|
||||
## Setup
|
||||
|
||||
### Create an account
|
||||
|
||||
@@ -17,6 +17,10 @@ Please use your own knowledge to assess and vet them before you install anything
|
||||
|
||||
The [Advanced Camera Card](https://card.camera/#/README) is a Home Assistant dashboard card with deep Frigate integration.
|
||||
|
||||
## [cctvQL](https://github.com/arunrajiah/cctvql)
|
||||
|
||||
[cctvQL](https://github.com/arunrajiah/cctvql) is a natural language query layer for Frigate and other CCTV systems. It connects to Frigate's REST API and MQTT broker to let you ask conversational questions about cameras and events (e.g. "Was there motion at the front door last night?"), with support for real-time event streaming, anomaly detection, PTZ control, alert rules, and a Home Assistant custom component.
|
||||
|
||||
## [Double Take](https://github.com/skrashevich/double-take)
|
||||
|
||||
[Double Take](https://github.com/skrashevich/double-take) provides an unified UI and API for processing and training images for facial recognition.
|
||||
@@ -35,6 +39,10 @@ This is a fork (with fixed errors and new features) of [original Double Take](ht
|
||||
|
||||
[Frigate telegram](https://github.com/OldTyT/frigate-telegram) makes it possible to send events from Frigate to Telegram. Events are sent as a message with a text description, video, and thumbnail.
|
||||
|
||||
## [kiosk-monitor](https://github.com/extremeshok/kiosk-monitor)
|
||||
|
||||
[kiosk-monitor](https://github.com/extremeshok/kiosk-monitor) is a Raspberry Pi watchdog that runs Chromium fullscreen on a Frigate dashboard (optionally with VLC on a second monitor for an RTSP camera stream), auto-restarts on frozen screens or unreachable URLs, and ships a Birdseye-aware Chromium helper that auto-sizes the grid to the display.
|
||||
|
||||
## [Periscope](https://github.com/maksz42/periscope)
|
||||
|
||||
[Periscope](https://github.com/maksz42/periscope) is a lightweight Android app that turns old devices into live viewers for Frigate. It works on Android 2.2 and above, including Android TV. It supports authentication and HTTPS.
|
||||
|
||||
@@ -110,3 +110,17 @@ No. Frigate uses the TCP protocol to connect to your camera's RTSP URL. VLC auto
|
||||
TCP ensures that all data packets arrive in the correct order. This is crucial for video recording, decoding, and stream processing, which is why Frigate enforces a TCP connection. UDP is faster but less reliable, as it does not guarantee packet delivery or order, and VLC does not have the same requirements as Frigate.
|
||||
|
||||
You can still configure Frigate to use UDP by using ffmpeg input args or the preset `preset-rtsp-udp`. See the [ffmpeg presets](/configuration/ffmpeg_presets) documentation.
|
||||
|
||||
### Frigate is slow to start up with a "probing detect stream" message in the logs
|
||||
|
||||
When `detect.width` and `detect.height` are not set, Frigate probes each camera's detect stream on startup (and when saving the config) to auto-detect its resolution. For RTSP streams Frigate probes with ffprobe and automatically retries over TCP if UDP doesn't respond, with a 5 second timeout per attempt. A camera that cannot be reached over either transport will add up to ~10 seconds to startup before Frigate falls through with default dimensions, which may show up as width `0` and height `0` in Camera Probe Info under System Metrics.
|
||||
|
||||
To skip the probe entirely and make startup instant, set `detect.width` and `detect.height` explicitly in your camera config:
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
my_camera:
|
||||
detect:
|
||||
width: 1280
|
||||
height: 720
|
||||
```
|
||||
|
||||
@@ -80,3 +80,85 @@ Some users found that mounting a drive via `fstab` with the `sync` option caused
|
||||
#### Copy Times < 1 second
|
||||
|
||||
If the storage is working quickly then this error may be caused by CPU load on the machine being too high for Frigate to have the resources to keep up. Try temporarily shutting down other services to see if the issue improves.
|
||||
|
||||
## I see the message: WARNING : Too many unprocessed recording segments in cache for camera. This likely indicates an issue with the detect stream...
|
||||
|
||||
This warning means that the detect stream for the affected camera has fallen behind or stopped processing frames. Frigate's recording cache holds segments waiting to be analyzed by the detector — when more than 6 segments pile up without being processed, Frigate discards the oldest ones to prevent the cache from filling up.
|
||||
|
||||
:::warning
|
||||
|
||||
This error is a **symptom**, not the root cause. The actual cause is always logged **before** these messages start appearing. You must review the full logs from Frigate startup through the first occurrence of this warning to identify the real issue.
|
||||
|
||||
:::
|
||||
|
||||
### Step 1: Get the full logs
|
||||
|
||||
Collect complete Frigate logs from startup through the first occurrence of the error. Look for errors or warnings that appear **before** the "Too many unprocessed" messages begin — that is where the root cause will be found.
|
||||
|
||||
### Step 2: Check the cache directory
|
||||
|
||||
Exec into the Frigate container and inspect the recording cache:
|
||||
|
||||
```
|
||||
docker exec -it frigate ls -la /tmp/cache
|
||||
```
|
||||
|
||||
Each camera should have a small number of `.mp4` segment files. If one camera has significantly more files than others, that camera is the source of the problem. A problem with a single camera can cascade and cause all cameras to show this error.
|
||||
|
||||
### Step 3: Verify segment duration
|
||||
|
||||
Recording segments should be approximately 10 seconds long. Run `ffprobe` on segments in the cache to check:
|
||||
|
||||
```
|
||||
docker exec -it frigate ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 /tmp/cache/<camera>@<segment>.mp4
|
||||
```
|
||||
|
||||
If segments are only ~1 second instead of ~10 seconds, the camera is sending corrupt timestamp data, causing segments to be split too frequently and filling the cache 10x faster than expected.
|
||||
|
||||
**Common causes of short segments:**
|
||||
|
||||
- **"Smart Codec" or "Smart+" enabled on the camera** — These features dynamically change encoding parameters mid-stream, which corrupts timestamps. Disable them in your camera's settings.
|
||||
- **Changing codec, bitrate, or resolution mid-stream** — Any encoding changes during an active stream can cause unpredictable segment splitting.
|
||||
- **Camera firmware bugs** — Check for firmware updates from your camera manufacturer.
|
||||
|
||||
### Step 4: Check for a stuck detector
|
||||
|
||||
If the detect stream is not processing frames, segments will accumulate. Common causes:
|
||||
|
||||
- **Detection resolution too high** — Use a substream for detection, not the full resolution main stream.
|
||||
- **Detection FPS too high** — 5 fps is the recommended maximum for detection.
|
||||
- **Model too large** — Use smaller model variants (e.g., YOLO `s` or `t` size, not `e` or `x`). Use 320x320 input size rather than 640x640 unless you have a powerful dedicated detector.
|
||||
- **Virtualization** — Running Frigate in a VM (especially Proxmox) can cause the detector to hang or stall. This is a known issue with GPU/TPU passthrough in virtualized environments and is not something Frigate can fix. Running Frigate in Docker on bare metal is recommended.
|
||||
|
||||
### Step 5: Check for GPU hangs
|
||||
|
||||
On the host machine, check `dmesg` for GPU-related errors:
|
||||
|
||||
```
|
||||
dmesg | grep -i -E "gpu|drm|reset|hang"
|
||||
```
|
||||
|
||||
Messages like `trying reset from guc_exec_queue_timedout_job` or similar GPU reset/hang messages indicate a driver or hardware issue. Ensure your kernel and GPU drivers (especially Intel) are up to date.
|
||||
|
||||
### Step 6: Verify hardware acceleration configuration
|
||||
|
||||
An incorrect `hwaccel_args` preset can cause ffmpeg to fail silently or consume excessive CPU, starving the detector of resources.
|
||||
|
||||
- After upgrading Frigate, verify your preset matches your hardware (e.g., `preset-intel-qsv-h264` instead of the deprecated `preset-vaapi`).
|
||||
- For h265 cameras, use the corresponding h265 preset (e.g., `preset-intel-qsv-h265`).
|
||||
- Note that `hwaccel_args` are only relevant for the detect stream — Frigate does not decode the record stream.
|
||||
|
||||
### Step 7: Verify go2rtc stream configuration
|
||||
|
||||
Ensure that the ffmpeg source names in your go2rtc configuration match the correct camera stream. A misconfigured stream name (e.g., copying a config from one camera to another without updating the stream reference) will cause the wrong stream to be used or the stream to fail entirely.
|
||||
|
||||
### Step 8: Check system resources
|
||||
|
||||
If none of the above apply, the issue may be a general resource constraint. Monitor the following on your host:
|
||||
|
||||
- **CPU usage** — An overloaded CPU can prevent the detector from keeping up.
|
||||
- **RAM and swap** — Excessive swapping dramatically slows all I/O operations.
|
||||
- **Disk I/O** — Use `iotop` or `iostat` to check for saturation.
|
||||
- **Storage space** — Verify you have free space on the Frigate storage volume (check the Storage page in the Frigate UI).
|
||||
|
||||
Try temporarily disabling resource-intensive features like `genai` and `face_recognition` to see if the issue resolves. This can help isolate whether the detector is being starved of resources.
|
||||
|
||||
Generated
+11
-4
@@ -14,9 +14,11 @@
|
||||
"@docusaurus/theme-mermaid": "^3.7.0",
|
||||
"@inkeep/docusaurus": "^2.0.16",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"clsx": "^2.1.1",
|
||||
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
||||
"docusaurus-theme-openapi-docs": "^4.5.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^18.3.1",
|
||||
@@ -5747,6 +5749,11 @@
|
||||
"@types/istanbul-lib-report": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/js-yaml": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -10897,9 +10904,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express/node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express/node_modules/range-parser": {
|
||||
@@ -12883,7 +12890,7 @@
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"resolved": "https://mirrors.tencent.com/npm/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+5
-2
@@ -3,9 +3,10 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:config": "node scripts/build-config.mjs",
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "npm run regen-docs && docusaurus start --host 0.0.0.0",
|
||||
"build": "npm run regen-docs && docusaurus build",
|
||||
"start": "npm run build:config && npm run regen-docs && docusaurus start --host 0.0.0.0",
|
||||
"build": "npm run build:config && npm run regen-docs && docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
@@ -23,9 +24,11 @@
|
||||
"@docusaurus/theme-mermaid": "^3.7.0",
|
||||
"@inkeep/docusaurus": "^2.0.16",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"clsx": "^2.1.1",
|
||||
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
||||
"docusaurus-theme-openapi-docs": "^4.5.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script: reads config.yaml and generates TypeScript files
|
||||
* for the Docker Compose Generator.
|
||||
*
|
||||
* Usage: node scripts/build-config.mjs
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_DIR = path.resolve(__dirname, "../src/components/DockerComposeGenerator/config");
|
||||
const YAML_PATH = path.join(CONFIG_DIR, "config.yaml");
|
||||
|
||||
// Read & parse YAML
|
||||
const raw = fs.readFileSync(YAML_PATH, "utf8");
|
||||
const config = yaml.load(raw);
|
||||
|
||||
if (!config.devices || !config.hardware || !config.ports) {
|
||||
console.error("config.yaml must contain 'devices', 'hardware', and 'ports' sections.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a .ts file from a section of the YAML config.
|
||||
*/
|
||||
function generateTsFile(sectionName, items, typeName, varName, mapVarName, yamlFilename) {
|
||||
const jsonItems = JSON.stringify(items, null, 2);
|
||||
// Indent JSON to fit inside the array literal
|
||||
const indented = jsonItems
|
||||
.split("\n")
|
||||
.map((line, i) => (i === 0 ? line : " " + line))
|
||||
.join("\n");
|
||||
|
||||
const content = `/**
|
||||
* AUTO-GENERATED FILE — do not edit directly.
|
||||
* Source: ${yamlFilename}
|
||||
* To update, edit the YAML file and run: npm run build:config
|
||||
*/
|
||||
|
||||
import type { ${typeName} } from "./types";
|
||||
|
||||
export const ${varName}: ${typeName}[] = ${indented};
|
||||
|
||||
/** Lookup map for quick access by ID */
|
||||
export const ${mapVarName}: Map<string, ${typeName}> = new Map(${varName}.map((item) => [item.id, item]));
|
||||
`;
|
||||
|
||||
const outPath = path.join(CONFIG_DIR, `${sectionName}.ts`);
|
||||
fs.writeFileSync(outPath, content, "utf8");
|
||||
console.log(` ✓ Generated ${sectionName}.ts (${items.length} items)`);
|
||||
}
|
||||
|
||||
console.log("Building config from config.yaml...");
|
||||
|
||||
generateTsFile("devices", config.devices, "DeviceConfig", "devices", "deviceMap", "config.yaml");
|
||||
generateTsFile("hardware", config.hardware, "HardwareOption", "hardwareOptions", "hardwareMap", "config.yaml");
|
||||
generateTsFile("ports", config.ports, "PortConfig", "ports", "portMap", "config.yaml");
|
||||
|
||||
console.log("Done!");
|
||||
@@ -12,6 +12,7 @@ const sidebars: SidebarsConfig = {
|
||||
"frigate/updating",
|
||||
"frigate/camera_setup",
|
||||
"frigate/video_pipeline",
|
||||
"frigate/network_requirements",
|
||||
"frigate/glossary",
|
||||
],
|
||||
Guides: [
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from "react";
|
||||
import Admonition from "@theme/Admonition";
|
||||
import DeviceSelector from "./components/DeviceSelector";
|
||||
import HardwareOptions from "./components/HardwareOptions";
|
||||
import PortConfigSection from "./components/PortConfig";
|
||||
import StoragePaths from "./components/StoragePaths";
|
||||
import NvidiaGpuConfig from "./components/NvidiaGpuConfig";
|
||||
import OtherOptions from "./components/OtherOptions";
|
||||
import GeneratedOutput from "./components/GeneratedOutput";
|
||||
import { useConfigGenerator } from "./hooks/useConfigGenerator";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
/**
|
||||
* Simple markdown-link-to-React renderer for help text.
|
||||
* Only supports [text](url) syntax — no nested brackets.
|
||||
*/
|
||||
function renderHelpText(text: string): React.ReactNode {
|
||||
const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g);
|
||||
return parts.map((part, i) => {
|
||||
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||
if (match) {
|
||||
return (
|
||||
<a key={i} href={match[2]}>
|
||||
{match[1]}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <React.Fragment key={i}>{part}</React.Fragment>;
|
||||
});
|
||||
}
|
||||
|
||||
export default function DockerComposeGenerator() {
|
||||
const {
|
||||
deviceId, device, hardwareEnabled,
|
||||
portEnabled,
|
||||
nvidiaGpuCount, nvidiaGpuDeviceId,
|
||||
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
||||
shmSizeError, gpuDeviceIdError, configPathError, mediaPathError,
|
||||
hasAnyHardware, generatedYaml,
|
||||
selectDevice, toggleHardware, togglePort,
|
||||
handleShmSizeChange, handleConfigPathChange, handleMediaPathChange,
|
||||
handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange,
|
||||
setRtspPassword, setTimezone, isHardwareDisabled,
|
||||
} = useConfigGenerator();
|
||||
|
||||
return (
|
||||
<div className={styles.generator}>
|
||||
<div className={styles.card}>
|
||||
<DeviceSelector selectedId={deviceId} onSelect={selectDevice} />
|
||||
|
||||
{device.helpText && (
|
||||
<Admonition type={device.helpType || "info"}>
|
||||
{renderHelpText(device.helpText)}
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
{device.needsNvidiaConfig && (
|
||||
<NvidiaGpuConfig
|
||||
gpuCount={nvidiaGpuCount}
|
||||
gpuDeviceId={nvidiaGpuDeviceId}
|
||||
gpuDeviceIdError={gpuDeviceIdError}
|
||||
onGpuCountChange={handleNvidiaGpuCountChange}
|
||||
onGpuDeviceIdChange={handleNvidiaGpuDeviceIdChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<HardwareOptions
|
||||
deviceId={deviceId}
|
||||
hardwareEnabled={hardwareEnabled}
|
||||
onToggle={toggleHardware}
|
||||
isDisabled={isHardwareDisabled}
|
||||
/>
|
||||
|
||||
<StoragePaths
|
||||
configPath={configPath}
|
||||
mediaPath={mediaPath}
|
||||
configPathError={configPathError}
|
||||
mediaPathError={mediaPathError}
|
||||
onConfigPathChange={handleConfigPathChange}
|
||||
onMediaPathChange={handleMediaPathChange}
|
||||
/>
|
||||
|
||||
<PortConfigSection
|
||||
portEnabled={portEnabled}
|
||||
onTogglePort={togglePort}
|
||||
/>
|
||||
|
||||
<OtherOptions
|
||||
rtspPassword={rtspPassword}
|
||||
timezone={timezone}
|
||||
shmSize={shmSize}
|
||||
shmSizeError={shmSizeError}
|
||||
onRtspPasswordChange={setRtspPassword}
|
||||
onTimezoneChange={setTimezone}
|
||||
onShmSizeChange={handleShmSizeChange}
|
||||
/>
|
||||
|
||||
<GeneratedOutput
|
||||
yaml={generatedYaml}
|
||||
configPath={configPath}
|
||||
mediaPath={mediaPath}
|
||||
hasAnyHardware={hasAnyHardware}
|
||||
deviceId={deviceId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import React from "react";
|
||||
import { useColorMode } from "@docusaurus/theme-common";
|
||||
import { devices } from "../config";
|
||||
import type { DeviceConfig } from "../config";
|
||||
import styles from "../styles.module.css";
|
||||
|
||||
interface Props {
|
||||
selectedId: string;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the icon type from the icon string:
|
||||
* - Starts with "<svg" → inline SVG
|
||||
* - Starts with "/" or "http" → image URL/path
|
||||
* - Otherwise → emoji text
|
||||
*/
|
||||
function getIconType(icon: string): "svg" | "image" | "emoji" {
|
||||
const trimmed = icon.trim();
|
||||
if (trimmed.startsWith("<svg")) return "svg";
|
||||
if (trimmed.startsWith("/") || trimmed.startsWith("http://") || trimmed.startsWith("https://")) return "image";
|
||||
return "emoji";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the style object contains background-* properties,
|
||||
* indicating the image should be rendered as a CSS background-image
|
||||
* rather than an <img> tag.
|
||||
*/
|
||||
function hasBackgroundProps(style: React.CSSProperties | undefined): boolean {
|
||||
if (!style) return false;
|
||||
return Object.keys(style).some((key) => {
|
||||
const k = key.toLowerCase().replace(/-/g, "");
|
||||
return k === "backgroundsize" || k === "backgroundposition" || k === "backgroundrepeat" || k === "backgroundimage";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a style object to CSS custom properties (e.g. { width: "24px" } → { "--svg-width": "24px" })
|
||||
* so they can be consumed by CSS rules targeting child elements like <svg>.
|
||||
*/
|
||||
function toCssVars(style: React.CSSProperties | undefined, prefix: string): React.CSSProperties {
|
||||
if (!style) return {};
|
||||
const vars: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(style)) {
|
||||
const cssKey = key.replace(/([A-Z])/g, "-$1").toLowerCase();
|
||||
vars[`--${prefix}-${cssKey}`] = value;
|
||||
}
|
||||
return vars as React.CSSProperties;
|
||||
}
|
||||
|
||||
function DeviceIcon({ device }: { device: DeviceConfig }) {
|
||||
const { isDarkTheme } = useColorMode();
|
||||
const iconStr = isDarkTheme && device.iconDark ? device.iconDark : device.icon;
|
||||
const iconStyle = (isDarkTheme && device.iconDarkStyle
|
||||
? device.iconDarkStyle
|
||||
: device.iconStyle) as React.CSSProperties | undefined;
|
||||
const svgStyle = (isDarkTheme && device.svgDarkStyle
|
||||
? device.svgDarkStyle
|
||||
: device.svgStyle) as React.CSSProperties | undefined;
|
||||
|
||||
const iconType = getIconType(iconStr);
|
||||
|
||||
if (iconType === "svg") {
|
||||
return (
|
||||
<div
|
||||
className={styles.deviceIconSvg}
|
||||
style={{ ...iconStyle, ...toCssVars(svgStyle, "svg") }}
|
||||
dangerouslySetInnerHTML={{ __html: iconStr }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (iconType === "image") {
|
||||
// When iconStyle contains background-* properties, render as background-image
|
||||
// on the container div instead of an <img> tag, enabling background-size/position control.
|
||||
if (hasBackgroundProps(iconStyle)) {
|
||||
return (
|
||||
<div
|
||||
className={styles.deviceIconImage}
|
||||
style={{
|
||||
backgroundImage: `url(${iconStr})`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center",
|
||||
backgroundSize: "contain",
|
||||
...iconStyle,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.deviceIconImage}>
|
||||
<img src={iconStr} alt={device.name} style={iconStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.deviceIcon} style={iconStyle}>
|
||||
{iconStr}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceCard({
|
||||
device,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
device: DeviceConfig;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.deviceCard} ${active ? styles.deviceCardActive : ""}`}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") onClick();
|
||||
}}
|
||||
>
|
||||
<DeviceIcon device={device} />
|
||||
<div className={styles.deviceName}>{device.name}</div>
|
||||
<div className={styles.deviceDesc}>{device.description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceSelector({ selectedId, onSelect }: Props) {
|
||||
return (
|
||||
<div className={styles.formSection}>
|
||||
<h4>Device Type</h4>
|
||||
<div className={styles.deviceGrid}>
|
||||
{devices.map((d) => (
|
||||
<DeviceCard
|
||||
key={d.id}
|
||||
device={d}
|
||||
active={selectedId === d.id}
|
||||
onClick={() => onSelect(d.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import CodeBlock from "@theme/CodeBlock";
|
||||
import Admonition from "@theme/Admonition";
|
||||
import styles from "../styles.module.css";
|
||||
|
||||
interface Props {
|
||||
yaml: string;
|
||||
configPath: string;
|
||||
mediaPath: string;
|
||||
hasAnyHardware: boolean;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export default function GeneratedOutput({
|
||||
yaml,
|
||||
configPath,
|
||||
mediaPath,
|
||||
hasAnyHardware,
|
||||
deviceId,
|
||||
}: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(yaml).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}, [yaml]);
|
||||
|
||||
return (
|
||||
<div className={styles.resultSection}>
|
||||
<div className={styles.resultHeader}>
|
||||
<h4>Generated Configuration</h4>
|
||||
<button className="button button--primary button--sm" onClick={handleCopy}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!configPath && (
|
||||
<Admonition type="tip">
|
||||
<p>You haven't specified a config file directory. You may want to modify the default path.</p>
|
||||
</Admonition>
|
||||
)}
|
||||
{!mediaPath && (
|
||||
<Admonition type="tip">
|
||||
<p>You haven't specified a recording storage directory. You may want to modify the default path.</p>
|
||||
</Admonition>
|
||||
)}
|
||||
{deviceId === "stable" && !hasAnyHardware && (
|
||||
<Admonition type="warning">
|
||||
<p>You haven't selected any hardware acceleration. Please check if you have supported hardware available.</p>
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
<CodeBlock language="yaml" title="docker-compose.yml">
|
||||
{yaml}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
import { hardwareOptions } from "../config";
|
||||
import type { HardwareOption } from "../config";
|
||||
import styles from "../styles.module.css";
|
||||
|
||||
interface Props {
|
||||
deviceId: string;
|
||||
hardwareEnabled: Record<string, boolean>;
|
||||
onToggle: (hwId: string) => void;
|
||||
isDisabled: (hwId: string) => boolean;
|
||||
}
|
||||
|
||||
function renderDescription(text: string): React.ReactNode {
|
||||
const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g);
|
||||
return parts.map((part, i) => {
|
||||
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||
if (match) {
|
||||
return <a key={i} href={match[2]}>{match[1]}</a>;
|
||||
}
|
||||
return <React.Fragment key={i}>{part}</React.Fragment>;
|
||||
});
|
||||
}
|
||||
|
||||
function HardwareCheckbox({
|
||||
hw, disabled, checked, onToggle,
|
||||
}: {
|
||||
hw: HardwareOption; disabled: boolean; checked: boolean; onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.hardwareItem}>
|
||||
<label className={`${styles.checkboxLabel} ${disabled ? styles.checkboxDisabled : ""}`}>
|
||||
<input type="checkbox" checked={checked} onChange={onToggle} disabled={disabled} />
|
||||
<span>{hw.label}</span>
|
||||
</label>
|
||||
{checked && hw.description && (
|
||||
<div className={styles.hardwareDescription}>{renderDescription(hw.description)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HardwareOptions({ deviceId, hardwareEnabled, onToggle, isDisabled }: Props) {
|
||||
return (
|
||||
<div className={styles.formSection}>
|
||||
<h4>Generic Hardware Devices</h4>
|
||||
{deviceId !== "stable" && (
|
||||
<p className={styles.helpText}>
|
||||
Some options have been auto-configured based on your device type.
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.checkboxGrid}>
|
||||
{hardwareOptions.map((hw) => {
|
||||
const disabled = isDisabled(hw.id);
|
||||
const checked = disabled ? false : !!hardwareEnabled[hw.id];
|
||||
return (
|
||||
<HardwareCheckbox key={hw.id} hw={hw} disabled={disabled} checked={checked} onToggle={() => onToggle(hw.id)} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
import styles from "../styles.module.css";
|
||||
|
||||
interface Props {
|
||||
gpuCount: string;
|
||||
gpuDeviceId: string;
|
||||
gpuDeviceIdError: boolean;
|
||||
onGpuCountChange: (value: string) => void;
|
||||
onGpuDeviceIdChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function NvidiaGpuConfig({
|
||||
gpuCount,
|
||||
gpuDeviceId,
|
||||
gpuDeviceIdError,
|
||||
onGpuCountChange,
|
||||
onGpuDeviceIdChange,
|
||||
}: Props) {
|
||||
const showDeviceId = gpuCount !== "";
|
||||
|
||||
return (
|
||||
<div className={styles.nvidiaConfig}>
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="dcg-gpu-count" className={styles.label}>
|
||||
GPU count:
|
||||
</label>
|
||||
<input
|
||||
id="dcg-gpu-count"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
className={styles.input}
|
||||
value={gpuCount}
|
||||
placeholder="all"
|
||||
onChange={(e) => onGpuCountChange(e.target.value.replace(/\D/g, ""))}
|
||||
/>
|
||||
</div>
|
||||
{showDeviceId && (
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="dcg-gpu-device-id" className={styles.label}>
|
||||
GPU device IDs (required, comma-separated):
|
||||
</label>
|
||||
<input
|
||||
id="dcg-gpu-device-id"
|
||||
type="text"
|
||||
className={`${styles.input} ${gpuDeviceIdError ? styles.inputError : ""}`}
|
||||
value={gpuDeviceId}
|
||||
placeholder="0"
|
||||
onChange={(e) => onGpuDeviceIdChange(e.target.value)}
|
||||
/>
|
||||
{gpuDeviceIdError ? (
|
||||
<p className={styles.helpText}>
|
||||
⚠️ GPU device IDs are required when GPU count is a number
|
||||
</p>
|
||||
) : (
|
||||
<p className={styles.helpText}>
|
||||
Single GPU: 0 | Multiple GPUs: 0,1,2
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useMemo } from "react";
|
||||
import CodeInline from "@theme/CodeInline";
|
||||
import styles from "../styles.module.css";
|
||||
|
||||
const AUTO_TIMEZONE_VALUE = "__auto__";
|
||||
|
||||
function getTimezoneList(): string[] {
|
||||
if (typeof Intl !== "undefined") {
|
||||
const intl = Intl as typeof Intl & {
|
||||
supportedValuesOf?: (key: string) => string[];
|
||||
};
|
||||
const supported = intl.supportedValuesOf?.("timeZone");
|
||||
if (supported && supported.length > 0) {
|
||||
return [...supported].sort();
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return fallback ? [fallback] : ["UTC"];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rtspPassword: string;
|
||||
timezone: string;
|
||||
shmSize: string;
|
||||
shmSizeError: boolean;
|
||||
onRtspPasswordChange: (value: string) => void;
|
||||
onTimezoneChange: (value: string) => void;
|
||||
onShmSizeChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function OtherOptions({
|
||||
rtspPassword,
|
||||
timezone,
|
||||
shmSize,
|
||||
shmSizeError,
|
||||
onRtspPasswordChange,
|
||||
onTimezoneChange,
|
||||
onShmSizeChange,
|
||||
}: Props) {
|
||||
const timezones = useMemo(() => getTimezoneList(), []);
|
||||
const systemTimezone =
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC";
|
||||
const selectedValue = timezone || AUTO_TIMEZONE_VALUE;
|
||||
|
||||
return (
|
||||
<div className={styles.formSection}>
|
||||
<h4>Other Options</h4>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="dcg-timezone" className={styles.label}>
|
||||
Timezone:
|
||||
</label>
|
||||
<select
|
||||
id="dcg-timezone"
|
||||
className={`${styles.input} ${styles.select}`}
|
||||
value={selectedValue}
|
||||
onChange={(e) =>
|
||||
onTimezoneChange(
|
||||
e.target.value === AUTO_TIMEZONE_VALUE ? "" : e.target.value
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value={AUTO_TIMEZONE_VALUE}>
|
||||
Use browser timezone ({systemTimezone})
|
||||
</option>
|
||||
{timezones.map((tz) => (
|
||||
<option key={tz} value={tz}>
|
||||
{tz}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="dcg-shm-size" className={styles.label}>
|
||||
Shared memory (SHM):
|
||||
</label>
|
||||
<input
|
||||
id="dcg-shm-size"
|
||||
type="text"
|
||||
className={`${styles.input} ${shmSizeError ? styles.inputError : ""}`}
|
||||
value={shmSize}
|
||||
placeholder="512mb"
|
||||
onChange={(e) => onShmSizeChange(e.target.value)}
|
||||
/>
|
||||
{shmSizeError ? (
|
||||
<p className={styles.helpText}>
|
||||
⚠️ Invalid format. Use a number followed by a unit (e.g. 512mb, 1gb)
|
||||
</p>
|
||||
) : (
|
||||
<p className={styles.helpText}>
|
||||
See{" "}
|
||||
<a href="/frigate/installation#calculating-required-shm-size">
|
||||
calculating required SHM size
|
||||
</a>{" "}
|
||||
for the correct value.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="dcg-rtsp-password" className={styles.label}>
|
||||
RTSP password:
|
||||
</label>
|
||||
<input
|
||||
id="dcg-rtsp-password"
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={rtspPassword}
|
||||
placeholder="password"
|
||||
onChange={(e) => onRtspPasswordChange(e.target.value)}
|
||||
/>
|
||||
<p className={styles.helpText}>
|
||||
Optional. You can specify{" "}
|
||||
<CodeInline>{"{FRIGATE_RTSP_PASSWORD}"}</CodeInline>{" "}
|
||||
in the config file to reference camera stream passwords. This is NOT
|
||||
the Frigate login password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
import Admonition from "@theme/Admonition";
|
||||
import { ports } from "../config";
|
||||
import styles from "../styles.module.css";
|
||||
|
||||
interface Props {
|
||||
portEnabled: Record<string, boolean>;
|
||||
onTogglePort: (portId: string) => void;
|
||||
}
|
||||
|
||||
function PortItem({
|
||||
port,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: {
|
||||
port: typeof ports[number];
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const showWarning = port.warningContent && (
|
||||
port.warningWhen === "checked" ? enabled :
|
||||
port.warningWhen === "unchecked" ? !enabled : enabled
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.hardwareItem}>
|
||||
<label className={`${styles.checkboxLabel} ${port.locked ? styles.checkboxDisabled : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={onToggle}
|
||||
disabled={port.locked}
|
||||
/>
|
||||
<span>
|
||||
{port.locked && "🔒 "}
|
||||
Port {port.host}
|
||||
{port.protocol !== "tcp" && `/${port.protocol}`}
|
||||
</span>
|
||||
</label>
|
||||
{port.description && (
|
||||
<div className={styles.hardwareDescription}>{port.description}</div>
|
||||
)}
|
||||
{showWarning && (
|
||||
<Admonition type={port.warningType || "warning"}>
|
||||
{port.warningContent}
|
||||
</Admonition>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PortConfigSection({
|
||||
portEnabled,
|
||||
onTogglePort,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={styles.formSection}>
|
||||
<h4>Port Configuration</h4>
|
||||
<div className={styles.checkboxGrid}>
|
||||
{ports.map((port) => (
|
||||
<PortItem
|
||||
key={port.id}
|
||||
port={port}
|
||||
enabled={!!portEnabled[port.id]}
|
||||
onToggle={() => onTogglePort(port.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import styles from "../styles.module.css";
|
||||
|
||||
interface Props {
|
||||
configPath: string;
|
||||
mediaPath: string;
|
||||
configPathError: boolean;
|
||||
mediaPathError: boolean;
|
||||
onConfigPathChange: (value: string) => void;
|
||||
onMediaPathChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function StoragePaths({
|
||||
configPath,
|
||||
mediaPath,
|
||||
configPathError,
|
||||
mediaPathError,
|
||||
onConfigPathChange,
|
||||
onMediaPathChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={styles.formSection}>
|
||||
<h4>Storage Paths</h4>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="dcg-config-path" className={styles.label}>
|
||||
Config / DB / model cache directory (on your host):
|
||||
</label>
|
||||
<input
|
||||
id="dcg-config-path"
|
||||
type="text"
|
||||
className={`${styles.input} ${configPathError ? styles.inputError : ""}`}
|
||||
value={configPath}
|
||||
placeholder="/path/to/your/config"
|
||||
onChange={(e) => onConfigPathChange(e.target.value)}
|
||||
/>
|
||||
{configPathError && (
|
||||
<p className={styles.helpText}>
|
||||
⚠️ Path contains invalid characters. Only letters, numbers,
|
||||
underscores, hyphens, slashes, and dots are allowed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="dcg-media-path" className={styles.label}>
|
||||
Recording storage directory (on your host):
|
||||
</label>
|
||||
<input
|
||||
id="dcg-media-path"
|
||||
type="text"
|
||||
className={`${styles.input} ${mediaPathError ? styles.inputError : ""}`}
|
||||
value={mediaPath}
|
||||
placeholder="/path/to/your/storage"
|
||||
onChange={(e) => onMediaPathChange(e.target.value)}
|
||||
/>
|
||||
{mediaPathError && (
|
||||
<p className={styles.helpText}>
|
||||
⚠️ Path contains invalid characters. Only letters, numbers,
|
||||
underscores, hyphens, slashes, and dots are allowed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
||||
export { devices, deviceMap } from "./devices";
|
||||
export { hardwareOptions, hardwareMap } from "./hardware";
|
||||
export { ports, portMap } from "./ports";
|
||||
|
||||
export type {
|
||||
DeviceConfig,
|
||||
DeviceMapping,
|
||||
VolumeMapping,
|
||||
HardwareOption,
|
||||
PortConfig,
|
||||
NvidiaDeployConfig,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Type definitions for the Docker Compose Generator configuration.
|
||||
* All device, hardware, and port options are declaratively defined
|
||||
* so that adding a new device only requires editing config files.
|
||||
*/
|
||||
|
||||
/** A single device mapping entry (e.g. /dev/dri:/dev/dri) */
|
||||
export interface DeviceMapping {
|
||||
/** Host device path */
|
||||
host: string;
|
||||
/** Container device path (defaults to host if omitted) */
|
||||
container?: string;
|
||||
/** Inline comment for this device line */
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
/** A single volume mapping entry */
|
||||
export interface VolumeMapping {
|
||||
/** Host path */
|
||||
host: string;
|
||||
/** Container path */
|
||||
container: string;
|
||||
/** Whether the mount is read-only */
|
||||
readOnly?: boolean;
|
||||
/** Inline comment */
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
/** NVIDIA deploy configuration for docker-compose */
|
||||
export interface NvidiaDeployConfig {
|
||||
/** "all" or a specific number */
|
||||
count: string;
|
||||
/** Specific GPU device IDs (when count is a number) */
|
||||
deviceIds?: string[];
|
||||
}
|
||||
|
||||
/** Full device type definition */
|
||||
export interface DeviceConfig {
|
||||
/** Unique identifier, e.g. "intel" */
|
||||
id: string;
|
||||
/** Display name, e.g. "Intel GPU" */
|
||||
name: string;
|
||||
/** Short description */
|
||||
description: string;
|
||||
/**
|
||||
* Icon for the device card. Supports:
|
||||
* - Emoji string (e.g. "🖥️")
|
||||
* - Image URL or static path (e.g. "/img/intel.svg", "https://example.com/icon.png")
|
||||
* - Inline SVG markup (e.g. "<svg>...</svg>")
|
||||
*/
|
||||
icon: string;
|
||||
/**
|
||||
* Additional CSS properties applied to the icon element.
|
||||
* - For image-type icons: if any `background-*` property (e.g. `background-size`,
|
||||
* `background-position`) is present, the image is rendered as a CSS `background-image`
|
||||
* on the container div, enabling full background positioning control.
|
||||
* Otherwise the image is rendered as an `<img>` tag and styles apply to it.
|
||||
* - For emoji/SVG icons: styles apply to the container div.
|
||||
*/
|
||||
iconStyle?: Record<string, string>;
|
||||
/**
|
||||
* Additional CSS properties applied directly to the inner `<svg>` element
|
||||
* when the icon is an inline SVG. Use this to override the default
|
||||
* `width: 100%; height: 100%` or set `fill`, `transform`, etc.
|
||||
* Ignored for emoji and image-type icons.
|
||||
*/
|
||||
svgStyle?: Record<string, string>;
|
||||
/**
|
||||
* Icon for dark mode. Same format as `icon`. When provided, this icon
|
||||
* replaces `icon` when the user is in dark mode.
|
||||
*/
|
||||
iconDark?: string;
|
||||
/** Additional CSS properties for the dark mode icon container */
|
||||
iconDarkStyle?: Record<string, string>;
|
||||
/**
|
||||
* SVG-specific styles for dark mode. Same as `svgStyle` but applied
|
||||
* when dark mode is active. Merged over `svgStyle` in dark mode.
|
||||
*/
|
||||
svgDarkStyle?: Record<string, string>;
|
||||
/** Docker image tag, e.g. "stable" */
|
||||
imageTag: string;
|
||||
/**
|
||||
* Image tag suffix appended to the base tag.
|
||||
* e.g. "-standard-arm64" produces "stable-standard-arm64"
|
||||
*/
|
||||
imageTagSuffix?: string;
|
||||
/** Hardware option IDs to auto-enable when this device is selected */
|
||||
autoHardware: string[];
|
||||
/** Help text shown as an admonition when this device is selected */
|
||||
helpText?: string;
|
||||
/** Admonition type for help text */
|
||||
helpType?: "info" | "warning" | "danger";
|
||||
/** Device mappings always added for this device type */
|
||||
devices?: DeviceMapping[];
|
||||
/** Volume mappings always added for this device type */
|
||||
volumes?: VolumeMapping[];
|
||||
/** Extra environment variables for this device type */
|
||||
env?: Record<string, string>;
|
||||
/** NVIDIA deploy config (only for tensorrt) */
|
||||
nvidiaDeploy?: NvidiaDeployConfig;
|
||||
/** Runtime setting, e.g. "nvidia" for Jetson */
|
||||
runtime?: string;
|
||||
/** Extra hosts entries, e.g. "host.docker.internal:host-gateway" */
|
||||
extraHosts?: string[];
|
||||
/** Security options, e.g. ["apparmor=unconfined"] */
|
||||
securityOpt?: string[];
|
||||
/** Whether this device type needs the NVIDIA GPU config UI */
|
||||
needsNvidiaConfig?: boolean;
|
||||
}
|
||||
|
||||
/** Generic hardware acceleration option definition */
|
||||
export interface HardwareOption {
|
||||
/** Unique identifier, e.g. "usbCoral" */
|
||||
id: string;
|
||||
/** Display label */
|
||||
label: string;
|
||||
/**
|
||||
* Description shown below the checkbox when this option is enabled.
|
||||
* Supports markdown link syntax: [text](url)
|
||||
*/
|
||||
description?: string;
|
||||
/** Device IDs that disable this option */
|
||||
disabledWhen?: string[];
|
||||
/** Device mappings added when this option is enabled */
|
||||
devices?: DeviceMapping[];
|
||||
/** Volume mappings added when this option is enabled */
|
||||
volumes?: VolumeMapping[];
|
||||
/** Extra environment variables */
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Port definition */
|
||||
export interface PortConfig {
|
||||
/** Unique identifier (also the default host port as string) */
|
||||
id: string;
|
||||
/** Host port number */
|
||||
host: number;
|
||||
/** Container port number */
|
||||
container: number;
|
||||
/** Protocol */
|
||||
protocol?: "tcp" | "udp";
|
||||
/** Description of the port's purpose */
|
||||
description: string;
|
||||
/** Whether enabled by default */
|
||||
defaultEnabled: boolean;
|
||||
/** Whether this port is locked (always enabled, cannot be toggled off) */
|
||||
locked?: boolean;
|
||||
/** Admonition type for the warning */
|
||||
warningType?: "warning" | "danger";
|
||||
/** Warning content (markdown) */
|
||||
warningContent?: string;
|
||||
/** When to show the warning: when the port is checked or unchecked */
|
||||
warningWhen?: "checked" | "unchecked";
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type {
|
||||
DeviceConfig,
|
||||
DeviceMapping,
|
||||
VolumeMapping,
|
||||
} from "../config/types";
|
||||
import { hardwareMap } from "../config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GeneratorInput {
|
||||
device: DeviceConfig;
|
||||
selectedHardware: string[];
|
||||
enabledPorts: string[];
|
||||
configPath: string;
|
||||
mediaPath: string;
|
||||
rtspPassword?: string;
|
||||
timezone: string;
|
||||
shmSize: string;
|
||||
nvidiaGpuCount?: string;
|
||||
nvidiaGpuDeviceId?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function deviceLine(dm: DeviceMapping): string {
|
||||
const host = dm.host;
|
||||
const container = dm.container ?? dm.host;
|
||||
const mapping = host === container ? host : `${host}:${container}`;
|
||||
const comment = dm.comment ? ` # ${dm.comment}` : "";
|
||||
return ` - ${mapping}${comment}`;
|
||||
}
|
||||
|
||||
function volumeLine(vm: VolumeMapping): string {
|
||||
const ro = vm.readOnly ? ":ro" : "";
|
||||
const comment = vm.comment ? ` # ${vm.comment}` : "";
|
||||
return ` - ${vm.host}:${vm.container}${ro}${comment}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML builder — each section returns an array of lines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildImage(device: DeviceConfig): string[] {
|
||||
const tag = device.imageTagSuffix
|
||||
? `${device.imageTag}${device.imageTagSuffix}`
|
||||
: device.imageTag;
|
||||
return [` image: ghcr.io/blakeblackshear/frigate:${tag}`];
|
||||
}
|
||||
|
||||
function buildDevices(
|
||||
device: DeviceConfig,
|
||||
hwDevices: DeviceMapping[]
|
||||
): string[] {
|
||||
const all: DeviceMapping[] = [
|
||||
...(device.devices ?? []),
|
||||
...hwDevices,
|
||||
];
|
||||
if (all.length === 0) return [];
|
||||
return [
|
||||
" devices:",
|
||||
...all.map(deviceLine),
|
||||
];
|
||||
}
|
||||
|
||||
function buildVolumes(
|
||||
device: DeviceConfig,
|
||||
hwVolumes: VolumeMapping[],
|
||||
configPath: string,
|
||||
mediaPath: string
|
||||
): string[] {
|
||||
const all: VolumeMapping[] = [
|
||||
...(device.volumes ?? []),
|
||||
...hwVolumes,
|
||||
];
|
||||
return [
|
||||
" volumes:",
|
||||
" - /etc/localtime:/etc/localtime:ro # Sync host time",
|
||||
` - ${configPath}:/config # Config file directory`,
|
||||
` - ${mediaPath}:/media/frigate # Recording storage directory`,
|
||||
" - type: tmpfs # 1GB in-memory filesystem for recording segment storage",
|
||||
" target: /tmp/cache",
|
||||
" tmpfs:",
|
||||
" size: 1000000000",
|
||||
...all.map(volumeLine),
|
||||
];
|
||||
}
|
||||
|
||||
function buildPorts(enabledPorts: string[]): string[] {
|
||||
return [
|
||||
" ports:",
|
||||
...enabledPorts,
|
||||
];
|
||||
}
|
||||
|
||||
function buildEnvironment(
|
||||
device: DeviceConfig,
|
||||
hwEnv: Record<string, string>,
|
||||
rtspPassword: string | undefined,
|
||||
timezone: string
|
||||
): string[] {
|
||||
const allEnv: Record<string, string> = {
|
||||
...hwEnv,
|
||||
...(device.env ?? {}),
|
||||
};
|
||||
|
||||
const lines: string[] = [" environment:"];
|
||||
|
||||
if (rtspPassword) {
|
||||
lines.push(
|
||||
` FRIGATE_RTSP_PASSWORD: "${rtspPassword}" # RTSP password — change to your own`
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(` TZ: "${timezone}" # Timezone`);
|
||||
|
||||
for (const [key, value] of Object.entries(allEnv)) {
|
||||
lines.push(` ${key}: "${value}"`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildDeploy(device: DeviceConfig, input: GeneratorInput): string[] {
|
||||
if (device.id === "stable-tensorrt") {
|
||||
const count = input.nvidiaGpuCount || "all";
|
||||
const isAll = count === "all";
|
||||
const deviceId = input.nvidiaGpuDeviceId?.trim();
|
||||
|
||||
if (isAll) {
|
||||
return [
|
||||
" deploy:",
|
||||
" resources:",
|
||||
" reservations:",
|
||||
" devices:",
|
||||
" - driver: nvidia",
|
||||
" count: all # Use all GPUs",
|
||||
" capabilities: [gpu]",
|
||||
];
|
||||
}
|
||||
|
||||
if (deviceId) {
|
||||
const ids = deviceId
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => `'${s}'`)
|
||||
.join(", ");
|
||||
return [
|
||||
" deploy:",
|
||||
" resources:",
|
||||
" reservations:",
|
||||
" devices:",
|
||||
" - driver: nvidia",
|
||||
` device_ids: [${ids}] # GPU device IDs`,
|
||||
` count: ${count} # GPU count`,
|
||||
" capabilities: [gpu]",
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
" deploy:",
|
||||
" resources:",
|
||||
" reservations:",
|
||||
" devices:",
|
||||
" - driver: nvidia",
|
||||
` count: ${count} # GPU count`,
|
||||
" capabilities: [gpu]",
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildRuntime(device: DeviceConfig): string[] {
|
||||
if (device.runtime) {
|
||||
return [` runtime: ${device.runtime}`];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildExtraHosts(device: DeviceConfig): string[] {
|
||||
if (!device.extraHosts?.length) return [];
|
||||
return [
|
||||
" extra_hosts:",
|
||||
...device.extraHosts.map(
|
||||
(h, i) =>
|
||||
` - "${h}"${i === 0 ? " # Required to talk to the NPU detector" : ""}`
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function buildSecurityOpt(device: DeviceConfig): string[] {
|
||||
if (!device.securityOpt?.length) return [];
|
||||
return [
|
||||
" security_opt:",
|
||||
...device.securityOpt.map((s) => ` - ${s}`),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a docker-compose YAML string from the given input.
|
||||
* The output is pure YAML with inline comments (no Shiki annotations).
|
||||
*/
|
||||
export function generateDockerCompose(input: GeneratorInput): string {
|
||||
const { device } = input;
|
||||
|
||||
// Collect hardware-level devices, volumes, and env
|
||||
const hwDevices: DeviceMapping[] = [];
|
||||
const hwVolumes: VolumeMapping[] = [];
|
||||
const hwEnv: Record<string, string> = {};
|
||||
|
||||
for (const hwId of input.selectedHardware) {
|
||||
const hw = hardwareMap.get(hwId);
|
||||
if (!hw) continue;
|
||||
// Skip GPU device mapping for tensorrt images (it uses deploy instead)
|
||||
if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue;
|
||||
hwDevices.push(...(hw.devices ?? []));
|
||||
hwVolumes.push(...(hw.volumes ?? []));
|
||||
Object.assign(hwEnv, hw.env ?? {});
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
"services:",
|
||||
" frigate:",
|
||||
" container_name: frigate",
|
||||
" privileged: true # This may not be necessary for all setups",
|
||||
" restart: unless-stopped",
|
||||
" stop_grace_period: 30s # Allow enough time to shut down the various services",
|
||||
...buildImage(device),
|
||||
` shm_size: "${input.shmSize || "512mb"}" # Update for your cameras based on SHM calculation`,
|
||||
...buildRuntime(device),
|
||||
...buildDeploy(device, input),
|
||||
...buildExtraHosts(device),
|
||||
...buildSecurityOpt(device),
|
||||
...buildDevices(device, hwDevices),
|
||||
...buildVolumes(device, hwVolumes, input.configPath, input.mediaPath),
|
||||
...buildPorts(input.enabledPorts),
|
||||
...buildEnvironment(device, hwEnv, input.rtspPassword, input.timezone),
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { deviceMap, hardwareMap, portMap } from "../config";
|
||||
import { generateDockerCompose } from "../generator";
|
||||
import type { GeneratorInput } from "../generator";
|
||||
|
||||
/**
|
||||
* Main hook that holds all form state and generates the Docker Compose output.
|
||||
* Configuration is loaded synchronously from build-time generated .ts files.
|
||||
*/
|
||||
export function useConfigGenerator() {
|
||||
const [deviceId, setDeviceId] = useState("stable");
|
||||
|
||||
const [hardwareEnabled, setHardwareEnabled] = useState<Record<string, boolean>>(() => {
|
||||
const defaultDevice = deviceMap.get("stable");
|
||||
const initial: Record<string, boolean> = {};
|
||||
if (defaultDevice) {
|
||||
for (const hwId of defaultDevice.autoHardware) {
|
||||
initial[hwId] = true;
|
||||
}
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const [portEnabled, setPortEnabled] = useState<Record<string, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {};
|
||||
for (const p of portMap.values()) {
|
||||
initial[p.id] = p.defaultEnabled;
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const [nvidiaGpuCount, setNvidiaGpuCount] = useState("");
|
||||
const [nvidiaGpuDeviceId, setNvidiaGpuDeviceId] = useState("");
|
||||
const [configPath, setConfigPath] = useState("");
|
||||
const [mediaPath, setMediaPath] = useState("");
|
||||
const [rtspPassword, setRtspPassword] = useState("");
|
||||
const [timezone, setTimezone] = useState("");
|
||||
const [shmSize, setShmSize] = useState("512mb");
|
||||
const [shmSizeError, setShmSizeError] = useState(false);
|
||||
const [gpuDeviceIdError, setGpuDeviceIdError] = useState(false);
|
||||
const [configPathError, setConfigPathError] = useState(false);
|
||||
const [mediaPathError, setMediaPathError] = useState(false);
|
||||
|
||||
const device = useMemo(() => deviceMap.get(deviceId)!, [deviceId]);
|
||||
|
||||
const selectDevice = useCallback((id: string) => {
|
||||
const newDevice = deviceMap.get(id);
|
||||
if (!newDevice) return;
|
||||
setDeviceId(id);
|
||||
setHardwareEnabled(() => {
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const hwId of newDevice.autoHardware) {
|
||||
next[hwId] = true;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setNvidiaGpuCount("");
|
||||
setNvidiaGpuDeviceId("");
|
||||
setGpuDeviceIdError(false);
|
||||
}, []);
|
||||
|
||||
const toggleHardware = useCallback((hwId: string) => {
|
||||
setHardwareEnabled((prev) => ({ ...prev, [hwId]: !prev[hwId] }));
|
||||
}, []);
|
||||
|
||||
const togglePort = useCallback((portId: string) => {
|
||||
const port = portMap.get(portId);
|
||||
if (port?.locked) return;
|
||||
setPortEnabled((prev) => ({ ...prev, [portId]: !prev[portId] }));
|
||||
}, []);
|
||||
|
||||
const isHardwareDisabled = useCallback(
|
||||
(hwId: string): boolean => {
|
||||
const hw = hardwareMap.get(hwId);
|
||||
if (!hw) return false;
|
||||
return hw.disabledWhen?.includes(deviceId) ?? false;
|
||||
},
|
||||
[deviceId]
|
||||
);
|
||||
|
||||
const validateShmSize = useCallback((value: string): boolean => {
|
||||
if (!value) return true;
|
||||
return /^\d+(\.\d+)?[bkmgBKMG]{1,2}$/.test(value);
|
||||
}, []);
|
||||
|
||||
const validatePath = useCallback((value: string): boolean => {
|
||||
if (!value) return true;
|
||||
return /^[a-zA-Z0-9_\-/./]+$/.test(value);
|
||||
}, []);
|
||||
|
||||
const handleShmSizeChange = useCallback(
|
||||
(value: string) => {
|
||||
const filtered = value.replace(/[^0-9.bkmgBKMG]/g, "");
|
||||
const valid = validateShmSize(filtered);
|
||||
setShmSize(filtered);
|
||||
setShmSizeError(!valid && filtered !== "");
|
||||
},
|
||||
[validateShmSize]
|
||||
);
|
||||
|
||||
const handleConfigPathChange = useCallback(
|
||||
(value: string) => {
|
||||
const filtered = value.replace(/[^a-zA-Z0-9_\-/./]/g, "");
|
||||
const valid = validatePath(filtered);
|
||||
setConfigPath(filtered);
|
||||
setConfigPathError(!valid && filtered !== "");
|
||||
},
|
||||
[validatePath]
|
||||
);
|
||||
|
||||
const handleMediaPathChange = useCallback(
|
||||
(value: string) => {
|
||||
const filtered = value.replace(/[^a-zA-Z0-9_\-/./]/g, "");
|
||||
const valid = validatePath(filtered);
|
||||
setMediaPath(filtered);
|
||||
setMediaPathError(!valid && filtered !== "");
|
||||
},
|
||||
[validatePath]
|
||||
);
|
||||
|
||||
const handleNvidiaGpuCountChange = useCallback((value: string) => {
|
||||
// Only allow digits
|
||||
setNvidiaGpuCount(value);
|
||||
if (value === "") {
|
||||
setNvidiaGpuDeviceId("");
|
||||
setGpuDeviceIdError(false);
|
||||
} else {
|
||||
setGpuDeviceIdError(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNvidiaGpuDeviceIdChange = useCallback((value: string) => {
|
||||
setNvidiaGpuDeviceId(value.trim());
|
||||
setGpuDeviceIdError(false);
|
||||
}, []);
|
||||
|
||||
const enabledPortLines = useMemo(() => {
|
||||
const lines: string[] = [];
|
||||
for (const [id, enabled] of Object.entries(portEnabled)) {
|
||||
if (!enabled) continue;
|
||||
const p = portMap.get(id);
|
||||
if (!p) continue;
|
||||
const proto = p.protocol && p.protocol !== "tcp" ? `/${p.protocol}` : "";
|
||||
const comment = p.description ? ` # ${p.description}` : "";
|
||||
lines.push(` - "${p.host}:${p.container}${proto}"${comment}`);
|
||||
}
|
||||
return lines;
|
||||
}, [portEnabled]);
|
||||
|
||||
const selectedHardwareIds = useMemo(() => {
|
||||
return Object.entries(hardwareEnabled)
|
||||
.filter(([id, enabled]) => {
|
||||
if (!enabled) return false;
|
||||
const hw = hardwareMap.get(id);
|
||||
if (!hw) return false;
|
||||
if (hw.disabledWhen?.includes(deviceId)) return false;
|
||||
return true;
|
||||
})
|
||||
.map(([id]) => id);
|
||||
}, [hardwareEnabled, deviceId]);
|
||||
|
||||
const generatedYaml = useMemo(() => {
|
||||
const input: GeneratorInput = {
|
||||
device,
|
||||
selectedHardware: selectedHardwareIds,
|
||||
enabledPorts: enabledPortLines,
|
||||
configPath: configPath || "/path/to/your/config",
|
||||
mediaPath: mediaPath || "/path/to/your/storage",
|
||||
rtspPassword,
|
||||
timezone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC",
|
||||
shmSize: shmSize || "512mb",
|
||||
nvidiaGpuCount,
|
||||
nvidiaGpuDeviceId,
|
||||
};
|
||||
return generateDockerCompose(input);
|
||||
}, [
|
||||
device, selectedHardwareIds, enabledPortLines,
|
||||
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
||||
nvidiaGpuCount, nvidiaGpuDeviceId,
|
||||
]);
|
||||
|
||||
const hasAnyHardware = selectedHardwareIds.length > 0 || !!device?.devices?.length;
|
||||
|
||||
return {
|
||||
deviceId, device, hardwareEnabled, portEnabled,
|
||||
nvidiaGpuCount, nvidiaGpuDeviceId,
|
||||
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
||||
shmSizeError, gpuDeviceIdError, configPathError, mediaPathError,
|
||||
hasAnyHardware, generatedYaml,
|
||||
selectDevice, toggleHardware, togglePort,
|
||||
handleShmSizeChange, handleConfigPathChange, handleMediaPathChange,
|
||||
handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange,
|
||||
setRtspPassword, setTimezone, isHardwareDisabled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./DockerComposeGenerator";
|
||||
@@ -0,0 +1,381 @@
|
||||
/* ===================================================================
|
||||
Docker Compose Generator — styles
|
||||
Uses Docusaurus / Infima CSS variables for theme compatibility.
|
||||
=================================================================== */
|
||||
|
||||
.generator {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--ifm-background-surface-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-400);
|
||||
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);
|
||||
}
|
||||
|
||||
/* --- Form sections --- */
|
||||
|
||||
.formSection {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-400);
|
||||
}
|
||||
|
||||
.formSection:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.formSection h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--ifm-font-color-base);
|
||||
font-size: 1.1rem;
|
||||
font-weight: var(--ifm-font-weight-semibold);
|
||||
}
|
||||
|
||||
/* --- Form controls --- */
|
||||
|
||||
.formGroup {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.formGroup:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.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-color-emphasis-400);
|
||||
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);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .input {
|
||||
border-color: var(--ifm-color-emphasis-300);
|
||||
}
|
||||
|
||||
.inputError {
|
||||
border-color: #e74c3c;
|
||||
animation: shake 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Select dropdown --- */
|
||||
|
||||
.select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: var(--ifm-background-color)
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E")
|
||||
no-repeat right 0.75rem center / 12px 12px;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
[data-theme="light"] .select {
|
||||
background: #fff
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23555' d='M6 8L1 3h10z'/%3E%3C/svg%3E")
|
||||
no-repeat right 0.75rem center / 12px 12px;
|
||||
}
|
||||
|
||||
.helpText {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ifm-font-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.helpText a {
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
/* --- Device grid --- */
|
||||
|
||||
.deviceGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.deviceCard {
|
||||
padding: 0.75rem;
|
||||
border: 2px solid var(--ifm-color-emphasis-400);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
background: var(--ifm-background-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-theme="light"] .deviceCard {
|
||||
border: 2px solid #d0d7de;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.deviceCard:hover {
|
||||
border-color: var(--ifm-color-primary);
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.deviceCardActive {
|
||||
border-color: var(--ifm-color-primary);
|
||||
background: var(--ifm-color-primary-lightest);
|
||||
box-shadow: 0 0 0 1px var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .deviceCardActive {
|
||||
background: color-mix(in srgb, var(--ifm-color-primary) 12%, #fff);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .deviceCardActive {
|
||||
background: color-mix(in srgb, var(--ifm-color-primary) 25%, #1b1b1b);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .deviceCardActive .deviceName {
|
||||
color: var(--ifm-color-primary-light);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .deviceCardActive .deviceDesc {
|
||||
color: var(--ifm-color-primary-light);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.deviceIcon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.25rem;
|
||||
height: 40px;
|
||||
width: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.deviceIconSvg {
|
||||
margin-bottom: 0.25rem;
|
||||
height: 40px;
|
||||
width: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
/* Allow iconStyle width/height to override */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.deviceIconSvg svg {
|
||||
width: var(--svg-width, 100%);
|
||||
height: var(--svg-height, 100%);
|
||||
fill: var(--svg-fill, currentColor);
|
||||
transform: var(--svg-transform, none);
|
||||
}
|
||||
|
||||
.deviceIconImage {
|
||||
margin-bottom: 0.25rem;
|
||||
height: 40px;
|
||||
width: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.deviceIconImage img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.deviceName {
|
||||
font-weight: var(--ifm-font-weight-semibold);
|
||||
color: var(--ifm-font-color-base);
|
||||
margin-bottom: 0.15rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.deviceDesc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ifm-font-color-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* --- Checkbox grid --- */
|
||||
|
||||
.checkboxGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.checkboxGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.hardwareItem {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.hardwareDescription {
|
||||
margin: 0.15rem 0 0.4rem 1.6rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ifm-font-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hardwareDescription a {
|
||||
color: var(--ifm-color-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.checkboxLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.checkboxLabel:hover {
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
}
|
||||
|
||||
.checkboxLabel input[type="checkbox"] {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkboxLabel span {
|
||||
color: var(--ifm-font-color-base);
|
||||
}
|
||||
|
||||
.checkboxDisabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.checkboxDisabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.checkboxDisabled input[type="checkbox"] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* --- Form grid (side-by-side) --- */
|
||||
|
||||
.formGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.formGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.formGrid .formGroup {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* --- Port section --- */
|
||||
|
||||
.portSection {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.warningBadge {
|
||||
margin-left: auto;
|
||||
color: #e67e22;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* --- NVIDIA config --- */
|
||||
|
||||
.nvidiaConfig {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--ifm-background-color);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nvidiaConfig {
|
||||
background: #f6f8fa;
|
||||
border-left: 3px solid var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
/* --- Result section --- */
|
||||
|
||||
.resultSection {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.resultHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.resultHeader h4 {
|
||||
margin: 0;
|
||||
color: var(--ifm-font-color-base);
|
||||
}
|
||||
Vendored
+312
-65
@@ -2724,6 +2724,135 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
/exports/batch:
|
||||
post:
|
||||
tags:
|
||||
- Export
|
||||
summary: Start recording export batch
|
||||
description: >-
|
||||
Starts recording exports for a batch of items, each with its own camera
|
||||
and time range. Optionally assigns them to a new or existing export case.
|
||||
When neither export_case_id nor new_case_name is provided, exports are
|
||||
added as uncategorized. Attaching to an existing case is admin-only.
|
||||
operationId: export_recordings_batch_exports_batch_post
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BatchExportBody"
|
||||
responses:
|
||||
"202":
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BatchExportResponse"
|
||||
"400":
|
||||
description: Bad Request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"403":
|
||||
description: Forbidden
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"404":
|
||||
description: Not Found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"503":
|
||||
description: Service Unavailable
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"422":
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
/exports/delete:
|
||||
post:
|
||||
tags:
|
||||
- Export
|
||||
summary: Bulk delete exports
|
||||
description: >-
|
||||
Deletes one or more exports by ID. All IDs must exist and none can be
|
||||
in-progress. Admin-only.
|
||||
operationId: bulk_delete_exports_exports_delete_post
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ExportBulkDeleteBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"400":
|
||||
description: Bad Request - one or more exports are in-progress
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"404":
|
||||
description: Not Found - one or more export IDs do not exist
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"422":
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
/exports/reassign:
|
||||
post:
|
||||
tags:
|
||||
- Export
|
||||
summary: Bulk reassign exports to a case
|
||||
description: >-
|
||||
Assigns or unassigns one or more exports to/from a case. All IDs must
|
||||
exist. Pass export_case_id as null to unassign (move to uncategorized).
|
||||
Admin-only.
|
||||
operationId: bulk_reassign_exports_exports_reassign_post
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ExportBulkReassignBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"404":
|
||||
description: Not Found - one or more export IDs or the target case do not exist
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"422":
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
/cases:
|
||||
get:
|
||||
tags:
|
||||
@@ -2853,39 +2982,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
"/export/{export_id}/case":
|
||||
patch:
|
||||
tags:
|
||||
- Export
|
||||
summary: Assign export to case
|
||||
description: "Assigns an export to a case, or unassigns it if export_case_id is null."
|
||||
operationId: assign_export_case_export__export_id__case_patch
|
||||
parameters:
|
||||
- name: export_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Export Id
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ExportCaseAssignBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"422":
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
"/export/{camera_name}/start/{start_time}/end/{end_time}":
|
||||
post:
|
||||
tags:
|
||||
@@ -2973,32 +3069,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
"/export/{event_id}":
|
||||
delete:
|
||||
tags:
|
||||
- Export
|
||||
summary: Delete export
|
||||
operationId: export_delete_export__event_id__delete
|
||||
parameters:
|
||||
- name: event_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Event Id
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GenericResponse"
|
||||
"422":
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
"/export/custom/{camera_name}/start/{start_time}/end/{end_time}":
|
||||
post:
|
||||
tags:
|
||||
@@ -5927,7 +5997,10 @@ paths:
|
||||
tags:
|
||||
- App
|
||||
summary: Start debug replay
|
||||
description: Start a debug replay session from camera recordings.
|
||||
description:
|
||||
Start a debug replay session from camera recordings. Returns
|
||||
immediately while clip generation runs as a background job; subscribe
|
||||
to the 'debug_replay' job_state WS topic to track progress.
|
||||
operationId: start_debug_replay_debug_replay_start_post
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -5936,12 +6009,16 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DebugReplayStartBody"
|
||||
responses:
|
||||
"200":
|
||||
"202":
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DebugReplayStartResponse"
|
||||
"400":
|
||||
description: Invalid camera, time range, or no recordings
|
||||
"409":
|
||||
description: A replay session is already active
|
||||
"422":
|
||||
description: Validation Error
|
||||
content:
|
||||
@@ -6202,10 +6279,14 @@ components:
|
||||
replay_camera:
|
||||
type: string
|
||||
title: Replay Camera
|
||||
job_id:
|
||||
type: string
|
||||
title: Job Id
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- replay_camera
|
||||
- job_id
|
||||
title: DebugReplayStartResponse
|
||||
description: Response for starting a debug replay session.
|
||||
DebugReplayStatusResponse:
|
||||
@@ -6501,6 +6582,149 @@ components:
|
||||
required:
|
||||
- recognizedLicensePlate
|
||||
title: EventsLPRBody
|
||||
BatchExportBody:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: "#/components/schemas/BatchExportItem"
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 50
|
||||
title: Items
|
||||
description: List of export items. Each item has its own camera and time range.
|
||||
export_case_id:
|
||||
anyOf:
|
||||
- type: string
|
||||
maxLength: 30
|
||||
- type: "null"
|
||||
title: Export case ID
|
||||
description: Existing export case ID to assign all exports to. Attaching to an existing case is temporarily admin-only until case-level ACLs exist.
|
||||
new_case_name:
|
||||
anyOf:
|
||||
- type: string
|
||||
maxLength: 100
|
||||
- type: "null"
|
||||
title: New case name
|
||||
description: Name of a new export case to create when export_case_id is omitted
|
||||
new_case_description:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: New case description
|
||||
description: Optional description for a newly created export case
|
||||
type: object
|
||||
required:
|
||||
- items
|
||||
title: BatchExportBody
|
||||
BatchExportItem:
|
||||
properties:
|
||||
camera:
|
||||
type: string
|
||||
title: Camera name
|
||||
start_time:
|
||||
type: number
|
||||
title: Start time
|
||||
end_time:
|
||||
type: number
|
||||
title: End time
|
||||
image_path:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: Existing thumbnail path
|
||||
description: Optional existing image to use as the export thumbnail
|
||||
friendly_name:
|
||||
anyOf:
|
||||
- type: string
|
||||
maxLength: 256
|
||||
- type: "null"
|
||||
title: Friendly name
|
||||
description: Optional friendly name for this specific export item
|
||||
client_item_id:
|
||||
anyOf:
|
||||
- type: string
|
||||
maxLength: 128
|
||||
- type: "null"
|
||||
title: Client item ID
|
||||
description: Optional opaque client identifier echoed back in results
|
||||
type: object
|
||||
required:
|
||||
- camera
|
||||
- start_time
|
||||
- end_time
|
||||
title: BatchExportItem
|
||||
BatchExportResponse:
|
||||
properties:
|
||||
export_case_id:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: Export Case Id
|
||||
description: Export case ID associated with the batch
|
||||
export_ids:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
title: Export Ids
|
||||
description: Export IDs successfully queued
|
||||
results:
|
||||
items:
|
||||
$ref: "#/components/schemas/BatchExportResultModel"
|
||||
type: array
|
||||
title: Results
|
||||
description: Per-item batch export results
|
||||
type: object
|
||||
required:
|
||||
- export_ids
|
||||
- results
|
||||
title: BatchExportResponse
|
||||
description: Response model for starting an export batch.
|
||||
BatchExportResultModel:
|
||||
properties:
|
||||
camera:
|
||||
type: string
|
||||
title: Camera
|
||||
description: Camera name for this export attempt
|
||||
export_id:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: Export Id
|
||||
description: The export ID when the export was successfully queued
|
||||
success:
|
||||
type: boolean
|
||||
title: Success
|
||||
description: Whether the export was successfully queued
|
||||
status:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: Status
|
||||
description: Queue status for this camera export
|
||||
error:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: Error
|
||||
description: Validation or queueing error for this item, if any
|
||||
item_index:
|
||||
anyOf:
|
||||
- type: integer
|
||||
- type: "null"
|
||||
title: Item Index
|
||||
description: Zero-based index of this result within the request items list
|
||||
client_item_id:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
title: Client Item Id
|
||||
description: Opaque client-supplied item identifier echoed from the request
|
||||
type: object
|
||||
required:
|
||||
- camera
|
||||
- success
|
||||
title: BatchExportResultModel
|
||||
description: Per-item result for a batch export request.
|
||||
EventsSubLabelBody:
|
||||
properties:
|
||||
subLabel:
|
||||
@@ -6523,18 +6747,41 @@ components:
|
||||
required:
|
||||
- subLabel
|
||||
title: EventsSubLabelBody
|
||||
ExportCaseAssignBody:
|
||||
ExportBulkDeleteBody:
|
||||
properties:
|
||||
ids:
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
type: array
|
||||
minItems: 1
|
||||
title: Ids
|
||||
type: object
|
||||
required:
|
||||
- ids
|
||||
title: ExportBulkDeleteBody
|
||||
description: Request body for bulk deleting exports.
|
||||
ExportBulkReassignBody:
|
||||
properties:
|
||||
ids:
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
type: array
|
||||
minItems: 1
|
||||
title: Ids
|
||||
export_case_id:
|
||||
anyOf:
|
||||
- type: string
|
||||
maxLength: 30
|
||||
- type: "null"
|
||||
title: Export Case Id
|
||||
description: "Case ID to assign to the export, or null to unassign"
|
||||
description: "Case ID to assign to, or null to unassign from current case"
|
||||
type: object
|
||||
title: ExportCaseAssignBody
|
||||
description: Request body for assigning or unassigning an export to a case.
|
||||
required:
|
||||
- ids
|
||||
title: ExportBulkReassignBody
|
||||
description: Request body for bulk reassigning exports to a case.
|
||||
ExportCaseCreateBody:
|
||||
properties:
|
||||
name:
|
||||
|
||||
+19
-1
@@ -125,6 +125,16 @@ def metrics(request: Request):
|
||||
return Response(content=content, media_type=content_type)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/genai/models",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="List available GenAI models",
|
||||
description="Returns available models for each configured GenAI provider.",
|
||||
)
|
||||
def genai_models(request: Request):
|
||||
return JSONResponse(content=request.app.genai_manager.list_models())
|
||||
|
||||
|
||||
@router.get("/config", dependencies=[Depends(allow_any_authenticated())])
|
||||
def config(request: Request):
|
||||
config_obj: FrigateConfig = request.app.frigate_config
|
||||
@@ -136,8 +146,13 @@ def config(request: Request):
|
||||
for name, detector in config_obj.detectors.items()
|
||||
}
|
||||
|
||||
# remove the mqtt password
|
||||
# remove environment_vars for non-admin users
|
||||
if request.headers.get("remote-role") != "admin":
|
||||
config.pop("environment_vars", None)
|
||||
|
||||
# remove mqtt credentials
|
||||
config["mqtt"].pop("password", None)
|
||||
config["mqtt"].pop("user", None)
|
||||
|
||||
# remove the proxy secret
|
||||
config["proxy"].pop("auth_secret", None)
|
||||
@@ -684,6 +699,9 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
if request.app.stats_emitter is not None:
|
||||
request.app.stats_emitter.config = config
|
||||
|
||||
if request.app.dispatcher is not None:
|
||||
request.app.dispatcher.config = config
|
||||
|
||||
if body.update_topic:
|
||||
if body.update_topic.startswith("config/cameras/"):
|
||||
_, _, camera, field = body.update_topic.split("/")
|
||||
|
||||
@@ -64,6 +64,7 @@ def require_admin_by_default():
|
||||
"/logout",
|
||||
# Authenticated user endpoints (allow_any_authenticated)
|
||||
"/profile",
|
||||
"/profiles",
|
||||
# Public info endpoints (allow_public)
|
||||
"/",
|
||||
"/version",
|
||||
@@ -87,7 +88,9 @@ def require_admin_by_default():
|
||||
"/go2rtc/streams",
|
||||
"/event_ids",
|
||||
"/events",
|
||||
"/cases",
|
||||
"/exports",
|
||||
"/jobs/export",
|
||||
}
|
||||
|
||||
# Path prefixes that should be exempt (for paths with parameters)
|
||||
@@ -100,7 +103,9 @@ def require_admin_by_default():
|
||||
"/go2rtc/streams/", # /go2rtc/streams/{camera}
|
||||
"/users/", # /users/{username}/password (has own auth)
|
||||
"/preview/", # /preview/{file}/thumbnail.jpg
|
||||
"/cases/", # /cases/{case_id}
|
||||
"/exports/", # /exports/{export_id}
|
||||
"/jobs/export/", # /jobs/export/{export_id}
|
||||
"/vod/", # /vod/{camera_name}/...
|
||||
"/notifications/", # /notifications/pubkey, /notifications/register
|
||||
)
|
||||
@@ -807,6 +812,11 @@ limiter = Limiter(key_func=get_remote_addr)
|
||||
)
|
||||
@limiter.limit(limit_value=rateLimiter.get_limit)
|
||||
def login(request: Request, body: AppPostLoginBody):
|
||||
if not request.app.frigate_config.auth.enabled:
|
||||
return JSONResponse(
|
||||
content={"message": "Authentication is disabled"}, status_code=404
|
||||
)
|
||||
|
||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||
|
||||
+14
-1
@@ -30,6 +30,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.config.env import substitute_frigate_vars
|
||||
from frigate.util.builtin import clean_camera_user_pass
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
@@ -124,7 +125,10 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
|
||||
try:
|
||||
params = {"name": stream_name}
|
||||
if src:
|
||||
params["src"] = src
|
||||
try:
|
||||
params["src"] = substitute_frigate_vars(src)
|
||||
except KeyError:
|
||||
params["src"] = src
|
||||
|
||||
r = requests.put(
|
||||
"http://127.0.0.1:1984/api/streams",
|
||||
@@ -1220,6 +1224,15 @@ def camera_set(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not sub_command and feature in _SUB_COMMAND_FEATURES:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Feature '{feature}' requires a sub-command (e.g. mask or zone name)",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if camera_name == "*":
|
||||
cameras = list(frigate_config.cameras.keys())
|
||||
elif camera_name not in frigate_config.cameras:
|
||||
|
||||
+332
-106
@@ -3,9 +3,11 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import operator
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Generator, List, Optional
|
||||
from functools import reduce
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import cv2
|
||||
from fastapi import APIRouter, Body, Depends, Request
|
||||
@@ -17,6 +19,14 @@ from frigate.api.auth import (
|
||||
get_allowed_cameras_for_filter,
|
||||
require_camera_access,
|
||||
)
|
||||
from frigate.api.chat_util import (
|
||||
chunk_content,
|
||||
distance_to_score,
|
||||
format_events_with_local_time,
|
||||
fuse_scores,
|
||||
hydrate_event,
|
||||
parse_iso_to_timestamp,
|
||||
)
|
||||
from frigate.api.defs.query.events_query_parameters import EventsQueryParams
|
||||
from frigate.api.defs.request.chat_body import ChatCompletionRequest
|
||||
from frigate.api.defs.response.chat_response import (
|
||||
@@ -26,61 +36,20 @@ from frigate.api.defs.response.chat_response import (
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.api.event import events
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.genai.utils import build_assistant_message_for_conversation
|
||||
from frigate.jobs.vlm_watch import (
|
||||
get_vlm_watch_job,
|
||||
start_vlm_watch_job,
|
||||
stop_vlm_watch_job,
|
||||
)
|
||||
from frigate.models import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.chat])
|
||||
|
||||
|
||||
def _chunk_content(content: str, chunk_size: int = 80) -> Generator[str, None, None]:
|
||||
"""Yield content in word-aware chunks for streaming."""
|
||||
if not content:
|
||||
return
|
||||
words = content.split(" ")
|
||||
current: List[str] = []
|
||||
current_len = 0
|
||||
for w in words:
|
||||
current.append(w)
|
||||
current_len += len(w) + 1
|
||||
if current_len >= chunk_size:
|
||||
yield " ".join(current) + " "
|
||||
current = []
|
||||
current_len = 0
|
||||
if current:
|
||||
yield " ".join(current)
|
||||
|
||||
|
||||
def _format_events_with_local_time(
|
||||
events_list: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Add human-readable local start/end times to each event for the LLM."""
|
||||
result = []
|
||||
for evt in events_list:
|
||||
if not isinstance(evt, dict):
|
||||
result.append(evt)
|
||||
continue
|
||||
copy_evt = dict(evt)
|
||||
try:
|
||||
start_ts = evt.get("start_time")
|
||||
end_ts = evt.get("end_time")
|
||||
if start_ts is not None:
|
||||
dt_start = datetime.fromtimestamp(start_ts)
|
||||
copy_evt["start_time_local"] = dt_start.strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
if end_ts is not None:
|
||||
dt_end = datetime.fromtimestamp(end_ts)
|
||||
copy_evt["end_time_local"] = dt_end.strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
except (TypeError, ValueError, OSError):
|
||||
pass
|
||||
result.append(copy_evt)
|
||||
return result
|
||||
|
||||
|
||||
class ToolExecuteRequest(BaseModel):
|
||||
"""Request model for tool execution."""
|
||||
|
||||
@@ -158,6 +127,76 @@ def get_tool_definitions() -> List[Dict[str, Any]]:
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "find_similar_objects",
|
||||
"description": (
|
||||
"Find tracked objects that are visually and semantically similar "
|
||||
"to a specific past event. Use this when the user references a "
|
||||
"particular object they have seen and wants to find other "
|
||||
"sightings of the same or similar one ('that green car', 'the "
|
||||
"person in the red jacket', 'the package that was delivered'). "
|
||||
"Prefer this over search_objects whenever the user's intent is "
|
||||
"'find more like this specific one.' Use search_objects first "
|
||||
"only if you need to locate the anchor event. Requires semantic "
|
||||
"search to be enabled."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "The id of the anchor event to find similar objects to.",
|
||||
},
|
||||
"after": {
|
||||
"type": "string",
|
||||
"description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').",
|
||||
},
|
||||
"before": {
|
||||
"type": "string",
|
||||
"description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').",
|
||||
},
|
||||
"cameras": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of cameras to restrict to. Defaults to all.",
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of labels to restrict to. Defaults to the anchor event's label.",
|
||||
},
|
||||
"sub_labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of sub_labels (names) to restrict to.",
|
||||
},
|
||||
"zones": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of zones. An event matches if any of its zones overlap.",
|
||||
},
|
||||
"similarity_mode": {
|
||||
"type": "string",
|
||||
"enum": ["visual", "semantic", "fused"],
|
||||
"description": "Which similarity signal(s) to use. 'fused' (default) combines visual and semantic.",
|
||||
"default": "fused",
|
||||
},
|
||||
"min_score": {
|
||||
"type": "number",
|
||||
"description": "Drop matches with a similarity score below this threshold (0.0-1.0).",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of matches to return (default: 10).",
|
||||
"default": 10,
|
||||
},
|
||||
},
|
||||
"required": ["event_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -363,9 +402,38 @@ def get_tools() -> JSONResponse:
|
||||
return JSONResponse(content={"tools": tools})
|
||||
|
||||
|
||||
def _resolve_zones(
|
||||
zones: List[str],
|
||||
config: FrigateConfig,
|
||||
target_cameras: List[str],
|
||||
) -> List[str]:
|
||||
"""Map zone names to their canonical config keys, case-insensitively.
|
||||
|
||||
LLMs frequently echo a user's casing ("Front Yard") instead of the
|
||||
configured key ("front_yard"). The downstream zone filter is a SQLite GLOB
|
||||
over the JSON-encoded zones column, which is case-sensitive — so an
|
||||
unnormalized name silently returns zero matches. Build a lookup over the
|
||||
relevant cameras' configured zones and substitute when we find a match;
|
||||
unknown names pass through so behavior matches what the model asked for.
|
||||
"""
|
||||
if not zones:
|
||||
return zones
|
||||
|
||||
lookup: Dict[str, str] = {}
|
||||
for camera_id in target_cameras:
|
||||
camera_config = config.cameras.get(camera_id)
|
||||
if camera_config is None:
|
||||
continue
|
||||
for zone_name in camera_config.zones.keys():
|
||||
lookup.setdefault(zone_name.lower(), zone_name)
|
||||
|
||||
return [lookup.get(z.lower(), z) for z in zones]
|
||||
|
||||
|
||||
async def _execute_search_objects(
|
||||
arguments: Dict[str, Any],
|
||||
allowed_cameras: List[str],
|
||||
config: FrigateConfig,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Execute the search_objects tool.
|
||||
@@ -399,6 +467,11 @@ async def _execute_search_objects(
|
||||
# Convert zones array to comma-separated string if provided
|
||||
zones = arguments.get("zones")
|
||||
if isinstance(zones, list):
|
||||
camera_arg = arguments.get("camera")
|
||||
target_cameras = (
|
||||
[camera_arg] if camera_arg and camera_arg != "all" else allowed_cameras
|
||||
)
|
||||
zones = _resolve_zones(zones, config, target_cameras)
|
||||
zones = ",".join(zones)
|
||||
elif zones is None:
|
||||
zones = "all"
|
||||
@@ -407,7 +480,7 @@ async def _execute_search_objects(
|
||||
query_params = EventsQueryParams(
|
||||
cameras=arguments.get("camera", "all"),
|
||||
labels=arguments.get("label", "all"),
|
||||
sub_labels=arguments.get("sub_label", "all").lower(),
|
||||
sub_labels=arguments.get("sub_label", "all"), # case-insensitive on the backend
|
||||
zones=zones,
|
||||
zone=zones,
|
||||
after=after,
|
||||
@@ -434,6 +507,171 @@ async def _execute_search_objects(
|
||||
)
|
||||
|
||||
|
||||
async def _execute_find_similar_objects(
|
||||
request: Request,
|
||||
arguments: Dict[str, Any],
|
||||
allowed_cameras: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute the find_similar_objects tool.
|
||||
|
||||
Returns a plain dict (not JSONResponse) so the chat loop can embed it
|
||||
directly in tool-result messages.
|
||||
"""
|
||||
# 1. Semantic search enabled?
|
||||
config = request.app.frigate_config
|
||||
if not getattr(config.semantic_search, "enabled", False):
|
||||
return {
|
||||
"error": "semantic_search_disabled",
|
||||
"message": (
|
||||
"Semantic search must be enabled to find similar objects. "
|
||||
"Enable it in the Frigate config under semantic_search."
|
||||
),
|
||||
}
|
||||
|
||||
context = request.app.embeddings
|
||||
if context is None:
|
||||
return {
|
||||
"error": "semantic_search_disabled",
|
||||
"message": "Embeddings context is not available.",
|
||||
}
|
||||
|
||||
# 2. Anchor lookup.
|
||||
event_id = arguments.get("event_id")
|
||||
if not event_id:
|
||||
return {"error": "missing_event_id", "message": "event_id is required."}
|
||||
|
||||
try:
|
||||
anchor = Event.get(Event.id == event_id)
|
||||
except Event.DoesNotExist:
|
||||
return {
|
||||
"error": "anchor_not_found",
|
||||
"message": f"Could not find event {event_id}.",
|
||||
}
|
||||
|
||||
# 3. Parse params.
|
||||
after = parse_iso_to_timestamp(arguments.get("after"))
|
||||
before = parse_iso_to_timestamp(arguments.get("before"))
|
||||
|
||||
cameras = arguments.get("cameras")
|
||||
if cameras:
|
||||
# Respect RBAC: intersect with the user's allowed cameras.
|
||||
cameras = [c for c in cameras if c in allowed_cameras]
|
||||
else:
|
||||
cameras = list(allowed_cameras) if allowed_cameras else None
|
||||
|
||||
labels = arguments.get("labels") or [anchor.label]
|
||||
sub_labels = arguments.get("sub_labels")
|
||||
zones = arguments.get("zones")
|
||||
|
||||
if zones:
|
||||
zones = _resolve_zones(
|
||||
zones, request.app.frigate_config, cameras or list(allowed_cameras)
|
||||
)
|
||||
|
||||
similarity_mode = arguments.get("similarity_mode", "fused")
|
||||
if similarity_mode not in ("visual", "semantic", "fused"):
|
||||
similarity_mode = "fused"
|
||||
|
||||
min_score = arguments.get("min_score")
|
||||
limit = int(arguments.get("limit", 10))
|
||||
limit = max(1, min(limit, 50))
|
||||
|
||||
# 4. Run similarity searches. We deliberately do NOT pass event_ids into
|
||||
# the vec queries — the IN filter on sqlite-vec is broken in the installed
|
||||
# version (see frigate/embeddings/__init__.py). Mirror the pattern used by
|
||||
# frigate/api/event.py events_search: fetch top-k globally, then intersect
|
||||
# with the structured filters via Peewee.
|
||||
visual_distances: Dict[str, float] = {}
|
||||
description_distances: Dict[str, float] = {}
|
||||
|
||||
try:
|
||||
if similarity_mode in ("visual", "fused"):
|
||||
rows = context.search_thumbnail(anchor)
|
||||
visual_distances = {row[0]: row[1] for row in rows}
|
||||
|
||||
if similarity_mode in ("semantic", "fused"):
|
||||
query_text = (
|
||||
(anchor.data or {}).get("description")
|
||||
or anchor.sub_label
|
||||
or anchor.label
|
||||
)
|
||||
rows = context.search_description(query_text)
|
||||
description_distances = {row[0]: row[1] for row in rows}
|
||||
except Exception:
|
||||
logger.exception("Similarity search failed")
|
||||
return {
|
||||
"error": "similarity_search_failed",
|
||||
"message": "Failed to run similarity search.",
|
||||
}
|
||||
|
||||
vec_ids = set(visual_distances) | set(description_distances)
|
||||
vec_ids.discard(anchor.id)
|
||||
# vec layer returns up to k=100 per modality; flag when we hit that ceiling
|
||||
# so the LLM can mention there may be more matches beyond what we saw.
|
||||
candidate_truncated = (
|
||||
len(visual_distances) >= 100 or len(description_distances) >= 100
|
||||
)
|
||||
|
||||
if not vec_ids:
|
||||
return {
|
||||
"anchor": hydrate_event(anchor),
|
||||
"results": [],
|
||||
"similarity_mode": similarity_mode,
|
||||
"candidate_truncated": candidate_truncated,
|
||||
}
|
||||
|
||||
# 5. Apply structured filters, intersected with vec hits.
|
||||
clauses = [Event.id.in_(list(vec_ids))]
|
||||
if after is not None:
|
||||
clauses.append(Event.start_time >= after)
|
||||
if before is not None:
|
||||
clauses.append(Event.start_time <= before)
|
||||
if cameras:
|
||||
clauses.append(Event.camera.in_(cameras))
|
||||
if labels:
|
||||
clauses.append(Event.label.in_(labels))
|
||||
if sub_labels:
|
||||
clauses.append(Event.sub_label.in_(sub_labels))
|
||||
if zones:
|
||||
# Mirror the pattern used by frigate/api/event.py for JSON-array zone match.
|
||||
zone_clauses = [Event.zones.cast("text") % f'*"{zone}"*' for zone in zones]
|
||||
clauses.append(reduce(operator.or_, zone_clauses))
|
||||
|
||||
eligible = {e.id: e for e in Event.select().where(reduce(operator.and_, clauses))}
|
||||
|
||||
# 6. Fuse and rank.
|
||||
scored: List[tuple[str, float]] = []
|
||||
for eid in eligible:
|
||||
v_score = (
|
||||
distance_to_score(visual_distances[eid], context.thumb_stats)
|
||||
if eid in visual_distances
|
||||
else None
|
||||
)
|
||||
d_score = (
|
||||
distance_to_score(description_distances[eid], context.desc_stats)
|
||||
if eid in description_distances
|
||||
else None
|
||||
)
|
||||
fused = fuse_scores(v_score, d_score)
|
||||
if fused is None:
|
||||
continue
|
||||
if min_score is not None and fused < min_score:
|
||||
continue
|
||||
scored.append((eid, fused))
|
||||
|
||||
scored.sort(key=lambda pair: pair[1], reverse=True)
|
||||
scored = scored[:limit]
|
||||
|
||||
results = [hydrate_event(eligible[eid], score=score) for eid, score in scored]
|
||||
|
||||
return {
|
||||
"anchor": hydrate_event(anchor),
|
||||
"results": results,
|
||||
"similarity_mode": similarity_mode,
|
||||
"candidate_truncated": candidate_truncated,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chat/execute",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
@@ -457,7 +695,16 @@ async def execute_tool(
|
||||
logger.debug(f"Executing tool: {tool_name} with arguments: {arguments}")
|
||||
|
||||
if tool_name == "search_objects":
|
||||
return await _execute_search_objects(arguments, allowed_cameras)
|
||||
return await _execute_search_objects(
|
||||
arguments, allowed_cameras, request.app.frigate_config
|
||||
)
|
||||
|
||||
if tool_name == "find_similar_objects":
|
||||
result = await _execute_find_similar_objects(
|
||||
request, arguments, allowed_cameras
|
||||
)
|
||||
status_code = 200 if "error" not in result else 400
|
||||
return JSONResponse(content=result, status_code=status_code)
|
||||
|
||||
if tool_name == "set_camera_state":
|
||||
result = await _execute_set_camera_state(request, arguments)
|
||||
@@ -520,45 +767,14 @@ async def _execute_get_live_context(
|
||||
"detections": list(tracked_objects_dict.values()),
|
||||
}
|
||||
|
||||
# Grab live frame and handle based on provider configuration
|
||||
# Grab live frame when the chat model supports vision
|
||||
image_url = await _get_live_frame_image_url(request, camera, allowed_cameras)
|
||||
if image_url:
|
||||
genai_manager = request.app.genai_manager
|
||||
if genai_manager.tool_client is genai_manager.vision_client:
|
||||
# Same provider handles both roles — pass image URL so it can
|
||||
# be injected as a user message (images can't be in tool results)
|
||||
chat_client = request.app.genai_manager.chat_client
|
||||
if chat_client is not None and chat_client.supports_vision:
|
||||
# Pass image URL so it can be injected as a user message
|
||||
# (images can't be in tool results)
|
||||
result["_image_url"] = image_url
|
||||
elif genai_manager.vision_client is not None:
|
||||
# Separate vision provider — have it describe the image,
|
||||
# providing detection context so it knows what to focus on
|
||||
frame_bytes = _decode_data_url(image_url)
|
||||
if frame_bytes:
|
||||
detections = result.get("detections", [])
|
||||
if detections:
|
||||
detection_lines = []
|
||||
for d in detections:
|
||||
parts = [d.get("label", "unknown")]
|
||||
if d.get("sub_label"):
|
||||
parts.append(f"({d['sub_label']})")
|
||||
if d.get("zones"):
|
||||
parts.append(f"in {', '.join(d['zones'])}")
|
||||
detection_lines.append(" ".join(parts))
|
||||
context = (
|
||||
"The following objects are currently being tracked: "
|
||||
+ "; ".join(detection_lines)
|
||||
+ "."
|
||||
)
|
||||
else:
|
||||
context = "No objects are currently being tracked."
|
||||
|
||||
description = genai_manager.vision_client._send(
|
||||
f"Describe what you see in this security camera image. "
|
||||
f"{context} Focus on the scene, any visible activity, "
|
||||
f"and details about the tracked objects.",
|
||||
[frame_bytes],
|
||||
)
|
||||
if description:
|
||||
result["image_description"] = description
|
||||
|
||||
return result
|
||||
|
||||
@@ -609,17 +825,6 @@ async def _get_live_frame_image_url(
|
||||
return None
|
||||
|
||||
|
||||
def _decode_data_url(data_url: str) -> Optional[bytes]:
|
||||
"""Decode a base64 data URL to raw bytes."""
|
||||
try:
|
||||
# Format: data:image/jpeg;base64,<data>
|
||||
_, encoded = data_url.split(",", 1)
|
||||
return base64.b64decode(encoded)
|
||||
except (ValueError, Exception) as e:
|
||||
logger.debug("Failed to decode data URL: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
async def _execute_set_camera_state(
|
||||
request: Request,
|
||||
arguments: Dict[str, Any],
|
||||
@@ -672,7 +877,9 @@ async def _execute_tool_internal(
|
||||
This is used by the chat completion endpoint to execute tools.
|
||||
"""
|
||||
if tool_name == "search_objects":
|
||||
response = await _execute_search_objects(arguments, allowed_cameras)
|
||||
response = await _execute_search_objects(
|
||||
arguments, allowed_cameras, request.app.frigate_config
|
||||
)
|
||||
try:
|
||||
if hasattr(response, "body"):
|
||||
body_str = response.body.decode("utf-8")
|
||||
@@ -684,6 +891,8 @@ async def _execute_tool_internal(
|
||||
except (json.JSONDecodeError, AttributeError) as e:
|
||||
logger.warning(f"Failed to extract tool result: {e}")
|
||||
return {"error": "Failed to parse tool result"}
|
||||
elif tool_name == "find_similar_objects":
|
||||
return await _execute_find_similar_objects(request, arguments, allowed_cameras)
|
||||
elif tool_name == "set_camera_state":
|
||||
return await _execute_set_camera_state(request, arguments)
|
||||
elif tool_name == "get_live_context":
|
||||
@@ -706,8 +915,9 @@ async def _execute_tool_internal(
|
||||
return _execute_get_recap(arguments, allowed_cameras)
|
||||
else:
|
||||
logger.error(
|
||||
"Tool call failed: unknown tool %r. Expected one of: search_objects, get_live_context, "
|
||||
"start_camera_watch, stop_camera_watch, get_profile_status, get_recap. Arguments received: %s",
|
||||
"Tool call failed: unknown tool %r. Expected one of: search_objects, find_similar_objects, "
|
||||
"get_live_context, start_camera_watch, stop_camera_watch, get_profile_status, get_recap. "
|
||||
"Arguments received: %s",
|
||||
tool_name,
|
||||
json.dumps(arguments),
|
||||
)
|
||||
@@ -733,10 +943,13 @@ async def _execute_start_camera_watch(
|
||||
|
||||
await require_camera_access(camera, request=request)
|
||||
|
||||
if zones:
|
||||
zones = _resolve_zones(zones, config, [camera])
|
||||
|
||||
genai_manager = request.app.genai_manager
|
||||
vision_client = genai_manager.vision_client or genai_manager.tool_client
|
||||
if vision_client is None:
|
||||
return {"error": "No vision/GenAI provider configured."}
|
||||
chat_client = genai_manager.chat_client
|
||||
if chat_client is None or not chat_client.supports_vision:
|
||||
return {"error": "VLM watch requires a chat model with vision support."}
|
||||
|
||||
try:
|
||||
job_id = start_vlm_watch_job(
|
||||
@@ -969,7 +1182,7 @@ async def _execute_pending_tools(
|
||||
json.dumps(tool_args),
|
||||
)
|
||||
if tool_name == "search_objects" and isinstance(tool_result, list):
|
||||
tool_result = _format_events_with_local_time(tool_result)
|
||||
tool_result = format_events_with_local_time(tool_result)
|
||||
_keys = {
|
||||
"id",
|
||||
"camera",
|
||||
@@ -1070,7 +1283,7 @@ async def chat_completion(
|
||||
6. Repeats until final answer
|
||||
7. Returns response to user
|
||||
"""
|
||||
genai_client = request.app.genai_manager.tool_client
|
||||
genai_client = request.app.genai_manager.chat_client
|
||||
if not genai_client:
|
||||
return JSONResponse(
|
||||
content={
|
||||
@@ -1122,7 +1335,9 @@ Do not start your response with phrases like "I will check...", "Let me see...",
|
||||
Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields.
|
||||
When users ask about "today", "yesterday", "this week", etc., use the current date above as reference.
|
||||
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
|
||||
Always be accurate with time calculations based on the current date provided.{cameras_section}"""
|
||||
Always be accurate with time calculations based on the current date provided.
|
||||
|
||||
When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:<id>], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{cameras_section}"""
|
||||
|
||||
conversation.append(
|
||||
{
|
||||
@@ -1160,6 +1375,9 @@ Always be accurate with time calculations based on the current date provided.{ca
|
||||
async def stream_body_llm():
|
||||
nonlocal conversation, stream_tool_calls, stream_iterations
|
||||
while stream_iterations < max_iterations:
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected, stopping chat stream")
|
||||
return
|
||||
logger.debug(
|
||||
f"Streaming LLM (iteration {stream_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s)"
|
||||
@@ -1169,6 +1387,9 @@ Always be accurate with time calculations based on the current date provided.{ca
|
||||
tools=tools if tools else None,
|
||||
tool_choice="auto",
|
||||
):
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected, stopping chat stream")
|
||||
return
|
||||
kind, value = event
|
||||
if kind == "content_delta":
|
||||
yield (
|
||||
@@ -1198,6 +1419,11 @@ Always be accurate with time calculations based on the current date provided.{ca
|
||||
msg.get("content"), pending
|
||||
)
|
||||
)
|
||||
if await request.is_disconnected():
|
||||
logger.debug(
|
||||
"Client disconnected before tool execution"
|
||||
)
|
||||
return
|
||||
(
|
||||
executed_calls,
|
||||
tool_results,
|
||||
@@ -1282,7 +1508,7 @@ Always be accurate with time calculations based on the current date provided.{ca
|
||||
+ b"\n"
|
||||
)
|
||||
# Stream content in word-sized chunks for smooth UX
|
||||
for part in _chunk_content(final_content):
|
||||
for part in chunk_content(final_content):
|
||||
yield (
|
||||
json.dumps({"type": "content", "delta": part}).encode(
|
||||
"utf-8"
|
||||
@@ -1381,12 +1607,12 @@ async def start_vlm_monitor(
|
||||
|
||||
await require_camera_access(body.camera, request=request)
|
||||
|
||||
vision_client = genai_manager.vision_client or genai_manager.tool_client
|
||||
if vision_client is None:
|
||||
chat_client = genai_manager.chat_client
|
||||
if chat_client is None or not chat_client.supports_vision:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "No vision/GenAI provider configured.",
|
||||
"message": "VLM watch requires a chat model with vision support.",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Pure, stateless helpers used by the chat tool dispatchers.
|
||||
|
||||
These were extracted from frigate/api/chat.py to keep that module focused on
|
||||
route handlers, tool dispatchers, and streaming loop internals. Nothing in
|
||||
this file touches the FastAPI request, the embeddings context, or the chat
|
||||
loop state — all inputs and outputs are plain data.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Generator, List, Optional
|
||||
|
||||
from frigate.embeddings.util import ZScoreNormalization
|
||||
from frigate.models import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Similarity fusion weights for find_similar_objects.
|
||||
# Visual dominates because the feature's primary use case is "same specific object."
|
||||
# If these change, update the test in test_chat_find_similar_objects.py.
|
||||
VISUAL_WEIGHT = 0.65
|
||||
DESCRIPTION_WEIGHT = 0.35
|
||||
|
||||
|
||||
def chunk_content(content: str, chunk_size: int = 80) -> Generator[str, None, None]:
|
||||
"""Yield content in word-aware chunks for streaming."""
|
||||
if not content:
|
||||
return
|
||||
words = content.split(" ")
|
||||
current: List[str] = []
|
||||
current_len = 0
|
||||
for w in words:
|
||||
current.append(w)
|
||||
current_len += len(w) + 1
|
||||
if current_len >= chunk_size:
|
||||
yield " ".join(current) + " "
|
||||
current = []
|
||||
current_len = 0
|
||||
if current:
|
||||
yield " ".join(current)
|
||||
|
||||
|
||||
def format_events_with_local_time(
|
||||
events_list: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Add human-readable local start/end times to each event for the LLM."""
|
||||
result = []
|
||||
for evt in events_list:
|
||||
if not isinstance(evt, dict):
|
||||
result.append(evt)
|
||||
continue
|
||||
copy_evt = dict(evt)
|
||||
try:
|
||||
start_ts = evt.get("start_time")
|
||||
end_ts = evt.get("end_time")
|
||||
if start_ts is not None:
|
||||
dt_start = datetime.fromtimestamp(start_ts)
|
||||
copy_evt["start_time_local"] = dt_start.strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
if end_ts is not None:
|
||||
dt_end = datetime.fromtimestamp(end_ts)
|
||||
copy_evt["end_time_local"] = dt_end.strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
except (TypeError, ValueError, OSError):
|
||||
pass
|
||||
result.append(copy_evt)
|
||||
return result
|
||||
|
||||
|
||||
def distance_to_score(distance: float, stats: ZScoreNormalization) -> float:
|
||||
"""Convert a cosine distance to a [0, 1] similarity score.
|
||||
|
||||
Uses the existing ZScoreNormalization stats maintained by EmbeddingsContext
|
||||
to normalize across deployments, then a bounded sigmoid. Lower distance ->
|
||||
higher score. If stats are uninitialized (stddev == 0), returns a neutral
|
||||
0.5 so the fallback ordering by raw distance still dominates.
|
||||
"""
|
||||
if stats.stddev == 0:
|
||||
return 0.5
|
||||
z = (distance - stats.mean) / stats.stddev
|
||||
# Sigmoid on -z so that small distance (good) -> high score.
|
||||
return 1.0 / (1.0 + math.exp(z))
|
||||
|
||||
|
||||
def fuse_scores(
|
||||
visual_score: Optional[float],
|
||||
description_score: Optional[float],
|
||||
) -> Optional[float]:
|
||||
"""Weighted fusion of visual and description similarity scores.
|
||||
|
||||
If one side is missing (e.g., no description embedding for this event),
|
||||
the other side's score is returned alone with no penalty. If both are
|
||||
missing, returns None and the caller should drop the event.
|
||||
"""
|
||||
if visual_score is None and description_score is None:
|
||||
return None
|
||||
if visual_score is None:
|
||||
return description_score
|
||||
if description_score is None:
|
||||
return visual_score
|
||||
return VISUAL_WEIGHT * visual_score + DESCRIPTION_WEIGHT * description_score
|
||||
|
||||
|
||||
def parse_iso_to_timestamp(value: Optional[str]) -> Optional[float]:
|
||||
"""Parse an ISO-8601 string as server-local time -> unix timestamp.
|
||||
|
||||
Mirrors the parsing _execute_search_objects uses so both tools accept the
|
||||
same format from the LLM.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
s = value.replace("Z", "").strip()[:19]
|
||||
dt = datetime.strptime(s, "%Y-%m-%dT%H:%M:%S")
|
||||
return time.mktime(dt.timetuple())
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
logger.warning("Invalid timestamp format: %s", value)
|
||||
return None
|
||||
|
||||
|
||||
def hydrate_event(event: Event, score: Optional[float] = None) -> Dict[str, Any]:
|
||||
"""Convert an Event row into the dict shape returned by find_similar_objects."""
|
||||
data: Dict[str, Any] = {
|
||||
"id": event.id,
|
||||
"camera": event.camera,
|
||||
"label": event.label,
|
||||
"sub_label": event.sub_label,
|
||||
"start_time": event.start_time,
|
||||
"end_time": event.end_time,
|
||||
"zones": event.zones,
|
||||
}
|
||||
if score is not None:
|
||||
data["score"] = score
|
||||
return data
|
||||
+44
-30
@@ -10,6 +10,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.jobs.debug_replay import start_debug_replay_job
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,10 +30,17 @@ class DebugReplayStartResponse(BaseModel):
|
||||
|
||||
success: bool
|
||||
replay_camera: str
|
||||
job_id: str
|
||||
|
||||
|
||||
class DebugReplayStatusResponse(BaseModel):
|
||||
"""Response for debug replay status."""
|
||||
"""Response for debug replay status.
|
||||
|
||||
Returns only session-presence fields. Startup progress and error
|
||||
details flow through the job_state WebSocket topic via the
|
||||
debug_replay job (see frigate.jobs.debug_replay); the
|
||||
Replay page subscribes there with useJobStatus("debug_replay").
|
||||
"""
|
||||
|
||||
active: bool
|
||||
replay_camera: str | None = None
|
||||
@@ -51,15 +59,32 @@ class DebugReplayStopResponse(BaseModel):
|
||||
@router.post(
|
||||
"/debug_replay/start",
|
||||
response_model=DebugReplayStartResponse,
|
||||
status_code=202,
|
||||
responses={
|
||||
400: {"description": "Invalid camera, time range, or no recordings"},
|
||||
409: {"description": "A replay session is already active"},
|
||||
},
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Start debug replay",
|
||||
description="Start a debug replay session from camera recordings.",
|
||||
description="Start a debug replay session from camera recordings. Returns "
|
||||
"immediately while clip generation runs as a background job; subscribe "
|
||||
"to the 'debug_replay' job_state WS topic to track progress.",
|
||||
)
|
||||
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
||||
"""Start a debug replay session."""
|
||||
"""Start a debug replay session asynchronously."""
|
||||
replay_manager = request.app.replay_manager
|
||||
|
||||
if replay_manager.active:
|
||||
try:
|
||||
job_id = await asyncio.to_thread(
|
||||
start_debug_replay_job,
|
||||
source_camera=body.camera,
|
||||
start_ts=body.start_time,
|
||||
end_ts=body.end_time,
|
||||
frigate_config=request.app.frigate_config,
|
||||
config_publisher=request.app.config_publisher,
|
||||
replay_manager=replay_manager,
|
||||
)
|
||||
except RuntimeError:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
@@ -67,38 +92,23 @@ async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
try:
|
||||
replay_camera = await asyncio.to_thread(
|
||||
replay_manager.start,
|
||||
source_camera=body.camera,
|
||||
start_ts=body.start_time,
|
||||
end_ts=body.end_time,
|
||||
frigate_config=request.app.frigate_config,
|
||||
config_publisher=request.app.config_publisher,
|
||||
)
|
||||
except ValueError:
|
||||
logger.exception("Invalid parameters for debug replay start request")
|
||||
logger.exception("Rejected debug replay start request")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Invalid debug replay request parameters",
|
||||
"message": "Invalid debug replay parameters",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
except RuntimeError:
|
||||
logger.exception("Error while starting debug replay session")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "An internal error occurred while starting debug replay",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
return DebugReplayStartResponse(
|
||||
success=True,
|
||||
replay_camera=replay_camera,
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"replay_camera": replay_manager.replay_camera_name,
|
||||
"job_id": job_id,
|
||||
},
|
||||
status_code=202,
|
||||
)
|
||||
|
||||
|
||||
@@ -118,12 +128,16 @@ def get_debug_replay_status(request: Request):
|
||||
|
||||
if replay_manager.active and replay_camera:
|
||||
frame_processor = request.app.detected_frames_processor
|
||||
frame = frame_processor.get_current_frame(replay_camera)
|
||||
frame = (
|
||||
frame_processor.get_current_frame(replay_camera)
|
||||
if frame_processor is not None
|
||||
else None
|
||||
)
|
||||
|
||||
if frame is not None:
|
||||
frame_time = frame_processor.get_current_frame_time(replay_camera)
|
||||
camera_config = request.app.frigate_config.cameras.get(replay_camera)
|
||||
retry_interval = 10
|
||||
retry_interval = 10.0
|
||||
|
||||
if camera_config is not None:
|
||||
retry_interval = float(camera_config.ffmpeg.retry_interval or 10)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
MAX_BATCH_EXPORT_ITEMS = 50
|
||||
|
||||
|
||||
class BatchExportItem(BaseModel):
|
||||
camera: str = Field(title="Camera name")
|
||||
start_time: float = Field(title="Start time")
|
||||
end_time: float = Field(title="End time")
|
||||
image_path: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Existing thumbnail path",
|
||||
description="Optional existing image to use as the export thumbnail",
|
||||
)
|
||||
friendly_name: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Friendly name",
|
||||
max_length=256,
|
||||
description="Optional friendly name for this specific export item",
|
||||
)
|
||||
client_item_id: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Client item ID",
|
||||
max_length=128,
|
||||
description="Optional opaque client identifier echoed back in results",
|
||||
)
|
||||
|
||||
|
||||
class BatchExportBody(BaseModel):
|
||||
items: List[BatchExportItem] = Field(
|
||||
title="Items",
|
||||
min_length=1,
|
||||
max_length=MAX_BATCH_EXPORT_ITEMS,
|
||||
description="List of export items. Each item has its own camera and time range.",
|
||||
)
|
||||
export_case_id: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Export case ID",
|
||||
max_length=30,
|
||||
description=(
|
||||
"Existing export case ID to assign all exports to. Attaching to an "
|
||||
"existing case is temporarily admin-only until case-level ACLs exist."
|
||||
),
|
||||
)
|
||||
new_case_name: Optional[str] = Field(
|
||||
default=None,
|
||||
title="New case name",
|
||||
max_length=100,
|
||||
description="Name of a new export case to create when export_case_id is omitted",
|
||||
)
|
||||
new_case_description: Optional[str] = Field(
|
||||
default=None,
|
||||
title="New case description",
|
||||
description="Optional description for a newly created export case",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_case_target(self) -> "BatchExportBody":
|
||||
for item in self.items:
|
||||
if item.end_time <= item.start_time:
|
||||
raise ValueError("end_time must be after start_time")
|
||||
|
||||
return self
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Request bodies for bulk export operations."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, conlist, constr
|
||||
|
||||
|
||||
class ExportBulkDeleteBody(BaseModel):
|
||||
"""Request body for bulk deleting exports."""
|
||||
|
||||
# List of export IDs with at least one element and each element with at least one char
|
||||
ids: conlist(constr(min_length=1), min_length=1)
|
||||
|
||||
|
||||
class ExportBulkReassignBody(BaseModel):
|
||||
"""Request body for bulk reassigning exports to a case."""
|
||||
|
||||
# List of export IDs with at least one element and each element with at least one char
|
||||
ids: conlist(constr(min_length=1), min_length=1)
|
||||
export_case_id: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=30,
|
||||
description="Case ID to assign to, or null to unassign from current case",
|
||||
)
|
||||
@@ -23,13 +23,3 @@ class ExportCaseUpdateBody(BaseModel):
|
||||
description: Optional[str] = Field(
|
||||
default=None, description="Updated description of the export case"
|
||||
)
|
||||
|
||||
|
||||
class ExportCaseAssignBody(BaseModel):
|
||||
"""Request body for assigning or unassigning an export to a case."""
|
||||
|
||||
export_case_id: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=30,
|
||||
description="Case ID to assign to the export, or null to unassign",
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import List, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -28,6 +28,96 @@ class StartExportResponse(BaseModel):
|
||||
export_id: Optional[str] = Field(
|
||||
default=None, description="The export ID if successfully started"
|
||||
)
|
||||
status: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Queue status for the export job",
|
||||
)
|
||||
|
||||
|
||||
class BatchExportResultModel(BaseModel):
|
||||
"""Per-item result for a batch export request."""
|
||||
|
||||
camera: str = Field(description="Camera name for this export attempt")
|
||||
export_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="The export ID when the export was successfully queued",
|
||||
)
|
||||
success: bool = Field(description="Whether the export was successfully queued")
|
||||
status: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Queue status for this camera export",
|
||||
)
|
||||
error: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Validation or queueing error for this item, if any",
|
||||
)
|
||||
item_index: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Zero-based index of this result within the request items list",
|
||||
)
|
||||
client_item_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Opaque client-supplied item identifier echoed from the request",
|
||||
)
|
||||
|
||||
|
||||
class BatchExportResponse(BaseModel):
|
||||
"""Response model for starting an export batch."""
|
||||
|
||||
export_case_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Export case ID associated with the batch",
|
||||
)
|
||||
export_ids: List[str] = Field(description="Export IDs successfully queued")
|
||||
results: List[BatchExportResultModel] = Field(
|
||||
description="Per-item batch export results"
|
||||
)
|
||||
|
||||
|
||||
class ExportJobModel(BaseModel):
|
||||
"""Model representing a queued or running export job."""
|
||||
|
||||
id: str = Field(description="Unique identifier for the export job")
|
||||
job_type: str = Field(description="Job type")
|
||||
status: str = Field(description="Current job status")
|
||||
camera: str = Field(description="Camera associated with this export job")
|
||||
name: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Friendly name for the export",
|
||||
)
|
||||
export_case_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="ID of the export case this export belongs to",
|
||||
)
|
||||
request_start_time: float = Field(description="Requested export start time")
|
||||
request_end_time: float = Field(description="Requested export end time")
|
||||
start_time: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Unix timestamp when execution started",
|
||||
)
|
||||
end_time: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Unix timestamp when execution completed",
|
||||
)
|
||||
error_message: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Error message for failed jobs",
|
||||
)
|
||||
results: Optional[dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="Result metadata for completed jobs",
|
||||
)
|
||||
current_step: str = Field(
|
||||
default="queued",
|
||||
description="Current execution step (queued, preparing, encoding, encoding_retry, finalizing)",
|
||||
)
|
||||
progress_percent: float = Field(
|
||||
default=0.0,
|
||||
description="Progress percentage of the current step (0.0 - 100.0)",
|
||||
)
|
||||
|
||||
|
||||
ExportJobsResponse = List[ExportJobModel]
|
||||
|
||||
|
||||
ExportsResponse = List[ExportModel]
|
||||
|
||||
+29
-10
@@ -199,13 +199,18 @@ def events(
|
||||
sub_label_clauses.append((Event.sub_label.is_null()))
|
||||
|
||||
for label in filtered_sub_labels:
|
||||
lowered = label.lower()
|
||||
sub_label_clauses.append(
|
||||
(Event.sub_label.cast("text") == label)
|
||||
) # include exact matches
|
||||
(fn.LOWER(Event.sub_label.cast("text")) == lowered)
|
||||
) # include exact matches (case-insensitive)
|
||||
|
||||
# include this label when part of a list
|
||||
sub_label_clauses.append((Event.sub_label.cast("text") % f"*{label},*"))
|
||||
sub_label_clauses.append((Event.sub_label.cast("text") % f"*, {label}*"))
|
||||
# include this label when part of a list (LIKE is case-insensitive in sqlite for ASCII)
|
||||
sub_label_clauses.append(
|
||||
(fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*")
|
||||
)
|
||||
sub_label_clauses.append(
|
||||
(fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*")
|
||||
)
|
||||
|
||||
sub_label_clause = reduce(operator.or_, sub_label_clauses)
|
||||
clauses.append((sub_label_clause))
|
||||
@@ -609,13 +614,18 @@ def events_search(
|
||||
sub_label_clauses.append((Event.sub_label.is_null()))
|
||||
|
||||
for label in filtered_sub_labels:
|
||||
lowered = label.lower()
|
||||
sub_label_clauses.append(
|
||||
(Event.sub_label.cast("text") == label)
|
||||
) # include exact matches
|
||||
(fn.LOWER(Event.sub_label.cast("text")) == lowered)
|
||||
) # include exact matches (case-insensitive)
|
||||
|
||||
# include this label when part of a list
|
||||
sub_label_clauses.append((Event.sub_label.cast("text") % f"*{label},*"))
|
||||
sub_label_clauses.append((Event.sub_label.cast("text") % f"*, {label}*"))
|
||||
# include this label when part of a list (LIKE is case-insensitive in sqlite for ASCII)
|
||||
sub_label_clauses.append(
|
||||
(fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*")
|
||||
)
|
||||
sub_label_clauses.append(
|
||||
(fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*")
|
||||
)
|
||||
|
||||
event_filters.append((reduce(operator.or_, sub_label_clauses)))
|
||||
|
||||
@@ -744,6 +754,15 @@ def events_search(
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if search_event.camera not in allowed_cameras:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Event not found",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
thumb_result = context.search_thumbnail(search_event)
|
||||
thumb_ids = {result[0]: result[1] for result in thumb_result}
|
||||
search_results = {
|
||||
|
||||
+789
-278
File diff suppressed because it is too large
Load Diff
+21
-13
@@ -174,12 +174,10 @@ async def latest_frame(
|
||||
}
|
||||
quality_params = get_image_quality_params(extension.value, params.quality)
|
||||
|
||||
if camera_name in request.app.frigate_config.cameras:
|
||||
camera_config = request.app.frigate_config.cameras.get(camera_name)
|
||||
if camera_config is not None:
|
||||
frame = frame_processor.get_current_frame(camera_name, draw_options)
|
||||
retry_interval = float(
|
||||
request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval
|
||||
or 10
|
||||
)
|
||||
retry_interval = float(camera_config.ffmpeg.retry_interval or 10)
|
||||
|
||||
is_offline = False
|
||||
if frame is None or datetime.now().timestamp() > (
|
||||
@@ -1368,12 +1366,17 @@ def preview_gif(
|
||||
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}"
|
||||
|
||||
camera_files = [
|
||||
entry.name
|
||||
for entry in os.scandir(preview_dir)
|
||||
if entry.name.startswith(file_start)
|
||||
]
|
||||
camera_files.sort()
|
||||
|
||||
selected_previews = []
|
||||
|
||||
for file in sorted(os.listdir(preview_dir)):
|
||||
if not file.startswith(file_start):
|
||||
continue
|
||||
|
||||
for file in camera_files:
|
||||
if file < start_file:
|
||||
continue
|
||||
|
||||
@@ -1550,12 +1553,17 @@ def preview_mp4(
|
||||
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}"
|
||||
|
||||
camera_files = [
|
||||
entry.name
|
||||
for entry in os.scandir(preview_dir)
|
||||
if entry.name.startswith(file_start)
|
||||
]
|
||||
camera_files.sort()
|
||||
|
||||
selected_previews = []
|
||||
|
||||
for file in sorted(os.listdir(preview_dir)):
|
||||
if not file.startswith(file_start):
|
||||
continue
|
||||
|
||||
for file in camera_files:
|
||||
if file < start_file:
|
||||
continue
|
||||
|
||||
|
||||
@@ -148,12 +148,17 @@ def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: flo
|
||||
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}"
|
||||
|
||||
camera_files = [
|
||||
entry.name
|
||||
for entry in os.scandir(preview_dir)
|
||||
if entry.name.startswith(file_start)
|
||||
]
|
||||
camera_files.sort()
|
||||
|
||||
selected_previews = []
|
||||
|
||||
for file in sorted(os.listdir(preview_dir)):
|
||||
if not file.startswith(file_start):
|
||||
continue
|
||||
|
||||
for file in camera_files:
|
||||
if file < start_file:
|
||||
continue
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.recordings])
|
||||
|
||||
|
||||
@router.get("/recordings/storage", dependencies=[Depends(allow_any_authenticated())])
|
||||
@router.get("/recordings/storage", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_recordings_storage_usage(request: Request):
|
||||
recording_stats = request.app.stats_emitter.get_latest_stats()["service"][
|
||||
"storage"
|
||||
|
||||
@@ -746,7 +746,7 @@ async def set_not_reviewed(
|
||||
description="Use GenAI to summarize review items over a period of time.",
|
||||
)
|
||||
def generate_review_summary(request: Request, start_ts: float, end_ts: float):
|
||||
if not request.app.genai_manager.vision_client:
|
||||
if not request.app.genai_manager.description_client:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
|
||||
+6
-16
@@ -52,6 +52,7 @@ from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
|
||||
from frigate.events.audio import AudioProcessor
|
||||
from frigate.events.cleanup import EventCleanup
|
||||
from frigate.events.maintainer import EventProcessor
|
||||
from frigate.jobs.export import reap_stale_exports
|
||||
from frigate.jobs.motion_search import stop_all_motion_search_jobs
|
||||
from frigate.log import _stop_logging
|
||||
from frigate.models import (
|
||||
@@ -188,17 +189,6 @@ class FrigateApp:
|
||||
except PermissionError:
|
||||
logger.error("Unable to write to /config to save DB state")
|
||||
|
||||
def cleanup_timeline_db(db: SqliteExtDatabase) -> None:
|
||||
db.execute_sql(
|
||||
"DELETE FROM timeline WHERE source_id NOT IN (SELECT id FROM event);"
|
||||
)
|
||||
|
||||
try:
|
||||
with open(f"{CONFIG_DIR}/.timeline", "w") as f:
|
||||
f.write(str(datetime.datetime.now().timestamp()))
|
||||
except PermissionError:
|
||||
logger.error("Unable to write to /config to save DB state")
|
||||
|
||||
# Migrate DB schema
|
||||
migrate_db = SqliteExtDatabase(self.config.database.path)
|
||||
|
||||
@@ -215,11 +205,6 @@ class FrigateApp:
|
||||
|
||||
router.run()
|
||||
|
||||
# this is a temporary check to clean up user DB from beta
|
||||
# will be removed before final release
|
||||
if not os.path.exists(f"{CONFIG_DIR}/.timeline"):
|
||||
cleanup_timeline_db(migrate_db)
|
||||
|
||||
# check if vacuum needs to be run
|
||||
if os.path.exists(f"{CONFIG_DIR}/.vacuum"):
|
||||
with open(f"{CONFIG_DIR}/.vacuum") as f:
|
||||
@@ -611,6 +596,11 @@ class FrigateApp:
|
||||
# Clean up any stale replay camera artifacts (filesystem + DB)
|
||||
cleanup_replay_cameras()
|
||||
|
||||
# Reap any Export rows still marked in_progress from a previous
|
||||
# session (crash, kill, broken migration). Runs synchronously before
|
||||
# uvicorn binds so no API request can observe a stale row.
|
||||
reap_stale_exports()
|
||||
|
||||
self.init_inter_process_communicator()
|
||||
self.start_detectors()
|
||||
self.init_dispatcher()
|
||||
|
||||
@@ -118,10 +118,21 @@ class Dispatcher:
|
||||
|
||||
try:
|
||||
if command_type == "set":
|
||||
# Commands that require a sub-command (mask/zone name)
|
||||
sub_command_required = {
|
||||
"motion_mask",
|
||||
"object_mask",
|
||||
"zone",
|
||||
}
|
||||
if sub_command:
|
||||
self._camera_settings_handlers[command](
|
||||
camera_name, sub_command, payload
|
||||
)
|
||||
elif command in sub_command_required:
|
||||
logger.error(
|
||||
"Command %s requires a sub-command (mask/zone name)",
|
||||
command,
|
||||
)
|
||||
else:
|
||||
self._camera_settings_handlers[command](camera_name, payload)
|
||||
elif command_type == "ptz":
|
||||
|
||||
@@ -429,7 +429,10 @@ class WebPushClient(Communicator):
|
||||
else:
|
||||
title = base_title
|
||||
|
||||
message = payload["after"]["data"]["metadata"]["shortSummary"]
|
||||
if payload["after"]["data"]["metadata"].get("shortSummary"):
|
||||
message = payload["after"]["data"]["metadata"]["shortSummary"]
|
||||
else:
|
||||
message = f"Detected on {camera_name}"
|
||||
else:
|
||||
zone_names = payload["after"]["data"]["zones"]
|
||||
formatted_zone_names = []
|
||||
@@ -549,6 +552,14 @@ class WebPushClient(Communicator):
|
||||
logger.debug(f"Sending camera monitoring push notification for {camera_name}")
|
||||
|
||||
for user in self.web_pushers:
|
||||
if not self._user_has_camera_access(user, camera):
|
||||
logger.debug(
|
||||
"Skipping notification for user %s - no access to camera %s",
|
||||
user,
|
||||
camera,
|
||||
)
|
||||
continue
|
||||
|
||||
self.send_push_notification(
|
||||
user=user,
|
||||
payload=payload,
|
||||
|
||||
+99
-3
@@ -17,9 +17,90 @@ from ws4py.websocket import WebSocket as WebSocket_
|
||||
|
||||
from frigate.comms.base_communicator import Communicator
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import (
|
||||
CLEAR_ONGOING_REVIEW_SEGMENTS,
|
||||
EXPIRE_AUDIO_ACTIVITY,
|
||||
INSERT_MANY_RECORDINGS,
|
||||
INSERT_PREVIEW,
|
||||
NOTIFICATION_TEST,
|
||||
REQUEST_REGION_GRID,
|
||||
UPDATE_AUDIO_ACTIVITY,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
UPDATE_BIRDSEYE_LAYOUT,
|
||||
UPDATE_CAMERA_ACTIVITY,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
UPDATE_MODEL_STATE,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Internal IPC topics — NEVER allowed from WebSocket, regardless of role
|
||||
_WS_BLOCKED_TOPICS = frozenset(
|
||||
{
|
||||
INSERT_MANY_RECORDINGS,
|
||||
INSERT_PREVIEW,
|
||||
REQUEST_REGION_GRID,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
CLEAR_ONGOING_REVIEW_SEGMENTS,
|
||||
UPDATE_CAMERA_ACTIVITY,
|
||||
UPDATE_AUDIO_ACTIVITY,
|
||||
EXPIRE_AUDIO_ACTIVITY,
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPDATE_MODEL_STATE,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_BIRDSEYE_LAYOUT,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
NOTIFICATION_TEST,
|
||||
}
|
||||
)
|
||||
|
||||
# Read-only topics any authenticated user (including viewer) can send
|
||||
_WS_VIEWER_TOPICS = frozenset(
|
||||
{
|
||||
"onConnect",
|
||||
"modelState",
|
||||
"audioTranscriptionState",
|
||||
"birdseyeLayout",
|
||||
"embeddingsReindexProgress",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _check_ws_authorization(
|
||||
topic: str,
|
||||
role_header: str | None,
|
||||
separator: str,
|
||||
) -> bool:
|
||||
"""Check if a WebSocket message is authorized.
|
||||
|
||||
Args:
|
||||
topic: The message topic.
|
||||
role_header: The HTTP_REMOTE_ROLE header value, or None.
|
||||
separator: The role separator character from proxy config.
|
||||
|
||||
Returns:
|
||||
True if authorized, False if blocked.
|
||||
"""
|
||||
# Block IPC-only topics unconditionally
|
||||
if topic in _WS_BLOCKED_TOPICS:
|
||||
return False
|
||||
|
||||
# No role header: default to viewer (fail-closed)
|
||||
if role_header is None:
|
||||
return topic in _WS_VIEWER_TOPICS
|
||||
|
||||
# Check if any role is admin
|
||||
roles = [r.strip() for r in role_header.split(separator)]
|
||||
if "admin" in roles:
|
||||
return True
|
||||
|
||||
# Non-admin: only viewer topics allowed
|
||||
return topic in _WS_VIEWER_TOPICS
|
||||
|
||||
|
||||
class WebSocket(WebSocket_): # type: ignore[misc]
|
||||
def unhandled_error(self, error: Any) -> None:
|
||||
@@ -49,6 +130,7 @@ class WebSocketClient(Communicator):
|
||||
|
||||
class _WebSocketHandler(WebSocket):
|
||||
receiver = self._dispatcher
|
||||
role_separator = self.config.proxy.separator or ","
|
||||
|
||||
def received_message(self, message: WebSocket.received_message) -> None: # type: ignore[name-defined]
|
||||
try:
|
||||
@@ -63,11 +145,25 @@ class WebSocketClient(Communicator):
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"Publishing mqtt message from websockets at {json_message['topic']}."
|
||||
topic = json_message["topic"]
|
||||
|
||||
# Authorization check (skip when environ is None — direct internal connection)
|
||||
role_header = (
|
||||
self.environ.get("HTTP_REMOTE_ROLE") if self.environ else None
|
||||
)
|
||||
if self.environ is not None and not _check_ws_authorization(
|
||||
topic, role_header, self.role_separator
|
||||
):
|
||||
logger.warning(
|
||||
"Blocked unauthorized WebSocket message: topic=%s, role=%s",
|
||||
topic,
|
||||
role_header,
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(f"Publishing mqtt message from websockets at {topic}.")
|
||||
self.receiver(
|
||||
json_message["topic"],
|
||||
topic,
|
||||
json_message["payload"],
|
||||
)
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ class GenAIProviderEnum(str, Enum):
|
||||
|
||||
|
||||
class GenAIRoleEnum(str, Enum):
|
||||
tools = "tools"
|
||||
vision = "vision"
|
||||
chat = "chat"
|
||||
descriptions = "descriptions"
|
||||
embeddings = "embeddings"
|
||||
|
||||
|
||||
@@ -49,21 +49,21 @@ class GenAIConfig(FrigateBaseModel):
|
||||
roles: list[GenAIRoleEnum] = Field(
|
||||
default_factory=lambda: [
|
||||
GenAIRoleEnum.embeddings,
|
||||
GenAIRoleEnum.vision,
|
||||
GenAIRoleEnum.tools,
|
||||
GenAIRoleEnum.descriptions,
|
||||
GenAIRoleEnum.chat,
|
||||
],
|
||||
title="Roles",
|
||||
description="GenAI roles (tools, vision, embeddings); one provider per role.",
|
||||
description="GenAI roles (chat, descriptions, embeddings); one provider per role.",
|
||||
)
|
||||
provider_options: dict[str, Any] = Field(
|
||||
default={},
|
||||
title="Provider options",
|
||||
description="Additional provider-specific options to pass to the GenAI client.",
|
||||
json_schema_extra={"additionalProperties": {"type": "string"}},
|
||||
json_schema_extra={"additionalProperties": {}},
|
||||
)
|
||||
runtime_options: dict[str, Any] = Field(
|
||||
default={},
|
||||
title="Runtime options",
|
||||
description="Runtime options passed to the provider for each inference call.",
|
||||
json_schema_extra={"additionalProperties": {"type": "string"}},
|
||||
json_schema_extra={"additionalProperties": {}},
|
||||
)
|
||||
|
||||
@@ -92,6 +92,12 @@ class RecordExportConfig(FrigateBaseModel):
|
||||
title="Export hwaccel args",
|
||||
description="Hardware acceleration args to use for export/transcode operations.",
|
||||
)
|
||||
max_concurrent: int = Field(
|
||||
default=3,
|
||||
ge=1,
|
||||
title="Maximum concurrent exports",
|
||||
description="Maximum number of export jobs to process at the same time.",
|
||||
)
|
||||
|
||||
|
||||
class RecordConfig(FrigateBaseModel):
|
||||
|
||||
@@ -20,6 +20,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
ffmpeg = "ffmpeg"
|
||||
live = "live"
|
||||
motion = "motion" # includes motion and motion masks
|
||||
mqtt = "mqtt"
|
||||
notifications = "notifications"
|
||||
objects = "objects"
|
||||
object_genai = "object_genai"
|
||||
@@ -33,6 +34,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
lpr = "lpr"
|
||||
snapshots = "snapshots"
|
||||
timestamp_style = "timestamp_style"
|
||||
ui = "ui"
|
||||
zones = "zones"
|
||||
|
||||
|
||||
|
||||
@@ -730,6 +730,9 @@ class FrigateConfig(FrigateBaseModel):
|
||||
)
|
||||
|
||||
if need_detect_dimensions:
|
||||
logger.info(
|
||||
f"detect.width and detect.height not set for {camera_config.name}, probing detect stream to determine resolution."
|
||||
)
|
||||
stream_info = {"width": 0, "height": 0, "fourcc": None}
|
||||
try:
|
||||
stream_info = stream_info_retriever.get_stream_info(
|
||||
|
||||
+71
-1
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
@@ -15,8 +16,77 @@ if os.path.isdir(secrets_dir) and os.access(secrets_dir, os.R_OK):
|
||||
)
|
||||
|
||||
|
||||
# Matches a FRIGATE_* identifier following an opening brace.
|
||||
_FRIGATE_IDENT_RE = re.compile(r"FRIGATE_[A-Za-z0-9_]+")
|
||||
|
||||
|
||||
def substitute_frigate_vars(value: str) -> str:
|
||||
"""Substitute `{FRIGATE_*}` placeholders in *value*.
|
||||
|
||||
Reproduces the subset of `str.format()` brace semantics that Frigate's
|
||||
config has historically supported, while leaving unrelated brace content
|
||||
(e.g. ffmpeg `%{localtime\\:...}` expressions) untouched:
|
||||
|
||||
* `{{` and `}}` collapse to literal `{` / `}` (the documented escape).
|
||||
* `{FRIGATE_NAME}` is replaced from `FRIGATE_ENV_VARS`; an unknown name
|
||||
raises `KeyError` to preserve the existing "Invalid substitution"
|
||||
error path.
|
||||
* A `{` that begins `{FRIGATE_` but is not a well-formed
|
||||
`{FRIGATE_NAME}` placeholder raises `ValueError` (malformed
|
||||
placeholder). Callers that catch `KeyError` to allow unknown-var
|
||||
passthrough will still surface malformed syntax as an error.
|
||||
* Any other `{` or `}` is treated as a literal and passed through.
|
||||
"""
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
n = len(value)
|
||||
while i < n:
|
||||
ch = value[i]
|
||||
if ch == "{":
|
||||
# Escaped literal `{{`.
|
||||
if i + 1 < n and value[i + 1] == "{":
|
||||
out.append("{")
|
||||
i += 2
|
||||
continue
|
||||
# Possible `{FRIGATE_*}` placeholder.
|
||||
if value.startswith("{FRIGATE_", i):
|
||||
ident_match = _FRIGATE_IDENT_RE.match(value, i + 1)
|
||||
if (
|
||||
ident_match is not None
|
||||
and ident_match.end() < n
|
||||
and value[ident_match.end()] == "}"
|
||||
):
|
||||
key = ident_match.group(0)
|
||||
if key not in FRIGATE_ENV_VARS:
|
||||
raise KeyError(key)
|
||||
out.append(FRIGATE_ENV_VARS[key])
|
||||
i = ident_match.end() + 1
|
||||
continue
|
||||
# Looks like a FRIGATE placeholder but is malformed
|
||||
# (no closing brace, illegal char, format spec, etc.).
|
||||
raise ValueError(
|
||||
f"Malformed FRIGATE_ placeholder near {value[i : i + 32]!r}"
|
||||
)
|
||||
# Plain `{` — pass through (e.g. `%{localtime\:...}`).
|
||||
out.append("{")
|
||||
i += 1
|
||||
continue
|
||||
if ch == "}":
|
||||
# Escaped literal `}}`.
|
||||
if i + 1 < n and value[i + 1] == "}":
|
||||
out.append("}")
|
||||
i += 2
|
||||
continue
|
||||
out.append("}")
|
||||
i += 1
|
||||
continue
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def validate_env_string(v: str) -> str:
|
||||
return v.format(**FRIGATE_ENV_VARS)
|
||||
return substitute_frigate_vars(v)
|
||||
|
||||
|
||||
EnvString = Annotated[str, AfterValidator(validate_env_string)]
|
||||
|
||||
@@ -25,8 +25,8 @@ class StatsConfig(FrigateBaseModel):
|
||||
)
|
||||
intel_gpu_device: Optional[str] = Field(
|
||||
default=None,
|
||||
title="SR-IOV device",
|
||||
description="Device identifier used when treating Intel GPUs as SR-IOV to fix GPU stats.",
|
||||
title="Intel GPU device",
|
||||
description="PCI bus address or DRM device path (e.g. /dev/dri/card1) used to pin Intel GPU stats to a specific device when multiple are present.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
+17
-1
@@ -15,7 +15,7 @@ TRIGGER_DIR = f"{CLIPS_DIR}/triggers"
|
||||
BIRDSEYE_PIPE = "/tmp/cache/birdseye"
|
||||
CACHE_DIR = "/tmp/cache"
|
||||
REPLAY_CAMERA_PREFIX = "_replay_"
|
||||
REPLAY_DIR = os.path.join(CACHE_DIR, "replay")
|
||||
REPLAY_DIR = os.path.join(CLIPS_DIR, "replay")
|
||||
PLUS_ENV_VAR = "PLUS_API_KEY"
|
||||
PLUS_API_HOST = "https://api.frigate.video"
|
||||
|
||||
@@ -44,6 +44,22 @@ DEFAULT_ATTRIBUTE_LABEL_MAP = {
|
||||
],
|
||||
"motorcycle": ["license_plate"],
|
||||
}
|
||||
ATTRIBUTE_LABEL_DISPLAY_MAP = {
|
||||
"amazon": "Amazon",
|
||||
"an_post": "An Post",
|
||||
"canada_post": "Canada Post",
|
||||
"dhl": "DHL",
|
||||
"dpd": "DPD",
|
||||
"fedex": "FedEx",
|
||||
"gls": "GLS",
|
||||
"nzpost": "NZ Post",
|
||||
"postnl": "PostNL",
|
||||
"postnord": "PostNord",
|
||||
"purolator": "Purolator",
|
||||
"royal_mail": "Royal Mail",
|
||||
"ups": "UPS",
|
||||
"usps": "USPS",
|
||||
}
|
||||
LABEL_CONSOLIDATION_MAP = {
|
||||
"car": 0.8,
|
||||
"face": 0.5,
|
||||
|
||||
@@ -133,6 +133,61 @@ class FaceRecognizer(ABC):
|
||||
return 0.0
|
||||
|
||||
|
||||
def build_class_mean(
|
||||
embs: list[np.ndarray],
|
||||
trim: float = 0.15,
|
||||
outlier_threshold: float = 0.30,
|
||||
min_keep_frac: float = 0.7,
|
||||
max_iters: int = 3,
|
||||
) -> np.ndarray:
|
||||
"""Build a class-mean embedding with two-layer outlier protection.
|
||||
|
||||
Layer 1 (iterative, vector-wise): drop whole embeddings whose cosine
|
||||
similarity to the current class mean is below ``outlier_threshold``.
|
||||
Catches mislabeled or corrupted training samples (wrong face in the
|
||||
folder, full-frame screenshots, extreme crops) that per-dimension
|
||||
trimming cannot detect.
|
||||
|
||||
Layer 2 (per-dimension): ``scipy.stats.trim_mean`` on the retained set
|
||||
to smooth per-component noise (lighting, expression, alignment jitter).
|
||||
|
||||
Collections with fewer than 5 images bypass outlier rejection — too few
|
||||
samples to establish a reliable class center.
|
||||
"""
|
||||
arr = np.stack(embs, axis=0)
|
||||
|
||||
if len(arr) < 5:
|
||||
return np.asarray(stats.trim_mean(arr, trim, axis=0))
|
||||
|
||||
keep = np.ones(len(arr), dtype=bool)
|
||||
floor = max(5, int(np.ceil(min_keep_frac * len(arr))))
|
||||
|
||||
for _ in range(max_iters):
|
||||
mean = stats.trim_mean(arr[keep], trim, axis=0)
|
||||
m_norm = mean / (np.linalg.norm(mean) + 1e-9)
|
||||
e_norms = arr / (np.linalg.norm(arr, axis=1, keepdims=True) + 1e-9)
|
||||
cos = e_norms @ m_norm
|
||||
new_keep = cos >= outlier_threshold
|
||||
|
||||
if new_keep.sum() < floor:
|
||||
top = np.argsort(-cos)[:floor]
|
||||
new_keep = np.zeros(len(arr), dtype=bool)
|
||||
new_keep[top] = True
|
||||
|
||||
if np.array_equal(new_keep, keep):
|
||||
break
|
||||
keep = new_keep
|
||||
|
||||
dropped = int((~keep).sum())
|
||||
|
||||
if dropped:
|
||||
logger.debug(
|
||||
f"Vector-wise outlier filter dropped {dropped}/{len(arr)} embeddings"
|
||||
)
|
||||
|
||||
return np.asarray(stats.trim_mean(arr[keep], trim, axis=0))
|
||||
|
||||
|
||||
def similarity_to_confidence(
|
||||
cosine_similarity: float,
|
||||
median: float = 0.3,
|
||||
@@ -229,7 +284,7 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
|
||||
for name, embs in face_embeddings_map.items():
|
||||
if embs:
|
||||
self.mean_embs[name] = stats.trim_mean(embs, 0.15)
|
||||
self.mean_embs[name] = build_class_mean(embs)
|
||||
|
||||
logger.debug("Finished building ArcFace model")
|
||||
|
||||
@@ -340,7 +395,7 @@ class ArcFaceRecognizer(FaceRecognizer):
|
||||
|
||||
for name, embs in face_embeddings_map.items():
|
||||
if embs:
|
||||
self.mean_embs[name] = stats.trim_mean(embs, 0.15)
|
||||
self.mean_embs[name] = build_class_mean(embs)
|
||||
|
||||
logger.debug("Finished building ArcFace model")
|
||||
|
||||
|
||||
@@ -1073,10 +1073,6 @@ class LicensePlateProcessingMixin:
|
||||
top_score = score
|
||||
top_box = bbox
|
||||
|
||||
if score > top_score:
|
||||
top_score = score
|
||||
top_box = bbox
|
||||
|
||||
# Return the top scoring bounding box if found
|
||||
if top_box is not None:
|
||||
# expand box by 5% to help with OCR
|
||||
@@ -1092,9 +1088,6 @@ class LicensePlateProcessingMixin:
|
||||
]
|
||||
).clip(0, [input.shape[1], input.shape[0]] * 2)
|
||||
|
||||
logger.debug(
|
||||
f"{camera}: Found license plate. Bounding box: {expanded_box.astype(int)}"
|
||||
)
|
||||
return tuple(int(x) for x in expanded_box) # type: ignore[return-value]
|
||||
else:
|
||||
return None # No detection above the threshold
|
||||
@@ -1360,8 +1353,8 @@ class LicensePlateProcessingMixin:
|
||||
)
|
||||
|
||||
# check that license plate is valid
|
||||
# double the value because we've doubled the size of the car
|
||||
if license_plate_area < self.config.cameras[camera].lpr.min_area * 2:
|
||||
# quadruple the value because we've doubled both dimensions of the car
|
||||
if license_plate_area < self.config.cameras[camera].lpr.min_area * 4:
|
||||
logger.debug(f"{camera}: License plate is less than min_area")
|
||||
return
|
||||
|
||||
@@ -1465,6 +1458,7 @@ class LicensePlateProcessingMixin:
|
||||
license_plate_frame,
|
||||
)
|
||||
|
||||
logger.debug(f"{camera}: Found license plate. Bounding box: {list(plate_box)}")
|
||||
logger.debug(f"{camera}: Running plate recognition for id: {id}.")
|
||||
|
||||
# run detection, returns results sorted by confidence, best first
|
||||
|
||||
@@ -16,7 +16,7 @@ from frigate.config import CameraConfig, FrigateConfig
|
||||
from frigate.const import CLIPS_DIR, UPDATE_EVENT_DESCRIPTION
|
||||
from frigate.data_processing.post.semantic_trigger import SemanticTriggerProcessor
|
||||
from frigate.data_processing.types import PostProcessDataEnum
|
||||
from frigate.genai import GenAIClient
|
||||
from frigate.genai.manager import GenAIClientManager
|
||||
from frigate.models import Event
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
|
||||
@@ -41,7 +41,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
embeddings: "Embeddings",
|
||||
requestor: InterProcessRequestor,
|
||||
metrics: DataProcessorMetrics,
|
||||
client: GenAIClient,
|
||||
genai_manager: GenAIClientManager,
|
||||
semantic_trigger_processor: SemanticTriggerProcessor | None,
|
||||
):
|
||||
super().__init__(config, metrics, None)
|
||||
@@ -49,7 +49,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
self.embeddings = embeddings
|
||||
self.requestor = requestor
|
||||
self.metrics = metrics
|
||||
self.genai_client = client
|
||||
self.genai_manager = genai_manager
|
||||
self.semantic_trigger_processor = semantic_trigger_processor
|
||||
self.tracked_events: dict[str, list[Any]] = {}
|
||||
self.early_request_sent: dict[str, bool] = {}
|
||||
@@ -198,6 +198,9 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
if data_type != PostProcessDataEnum.tracked_object:
|
||||
return
|
||||
|
||||
if self.genai_manager.description_client is None:
|
||||
return
|
||||
|
||||
state: str | None = frame_data.get("state", None)
|
||||
|
||||
if state is not None:
|
||||
@@ -329,7 +332,12 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
"""Embed the description for an event."""
|
||||
start = datetime.datetime.now().timestamp()
|
||||
camera_config = self.config.cameras[str(event.camera)]
|
||||
description = self.genai_client.generate_object_description(
|
||||
client = self.genai_manager.description_client
|
||||
|
||||
if client is None:
|
||||
return
|
||||
|
||||
description = client.generate_object_description(
|
||||
camera_config, thumbnails, event
|
||||
)
|
||||
|
||||
|
||||
@@ -19,9 +19,15 @@ from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera import CameraConfig
|
||||
from frigate.config.camera.review import GenAIReviewConfig, ImageSourceEnum
|
||||
from frigate.const import CACHE_DIR, CLIPS_DIR, UPDATE_REVIEW_DESCRIPTION
|
||||
from frigate.const import (
|
||||
ATTRIBUTE_LABEL_DISPLAY_MAP,
|
||||
CACHE_DIR,
|
||||
CLIPS_DIR,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
)
|
||||
from frigate.data_processing.types import PostProcessDataEnum
|
||||
from frigate.genai import GenAIClient
|
||||
from frigate.genai.manager import GenAIClientManager
|
||||
from frigate.models import Recordings, ReviewSegment
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
|
||||
from frigate.util.image import get_image_from_recording
|
||||
@@ -33,6 +39,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
RECORDING_BUFFER_EXTENSION_PERCENT = 0.10
|
||||
MIN_RECORDING_DURATION = 10
|
||||
MAX_IMAGE_TOKENS = 24000
|
||||
MAX_FRAMES_PER_SECOND = 1
|
||||
|
||||
|
||||
class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
@@ -41,12 +49,12 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
config: FrigateConfig,
|
||||
requestor: InterProcessRequestor,
|
||||
metrics: DataProcessorMetrics,
|
||||
client: GenAIClient,
|
||||
genai_manager: GenAIClientManager,
|
||||
):
|
||||
super().__init__(config, metrics, None)
|
||||
self.requestor = requestor
|
||||
self.metrics = metrics
|
||||
self.genai_client = client
|
||||
self.genai_manager = genai_manager
|
||||
self.review_desc_speed = InferenceSpeed(self.metrics.review_desc_speed)
|
||||
self.review_desc_dps = EventsPerSecond()
|
||||
self.review_desc_dps.start()
|
||||
@@ -54,16 +62,29 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
def calculate_frame_count(
|
||||
self,
|
||||
camera: str,
|
||||
duration: float,
|
||||
image_source: ImageSourceEnum = ImageSourceEnum.preview,
|
||||
height: int = 480,
|
||||
) -> int:
|
||||
"""Calculate optimal number of frames based on context size, image source, and resolution.
|
||||
"""Calculate optimal number of frames based on event duration, context size,
|
||||
image source, and resolution.
|
||||
|
||||
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.
|
||||
Per-image token cost is asked of the GenAI provider so providers that know
|
||||
their model's true cost (e.g. llama.cpp can probe the loaded mmproj) can
|
||||
diverge from the default ~1-token-per-1250-pixels heuristic. The frame
|
||||
budget is bounded by:
|
||||
- remaining context window after prompt + response reservations
|
||||
- a fixed MAX_IMAGE_TOKENS ceiling
|
||||
- MAX_FRAMES_PER_SECOND x duration, to avoid drowning short events in
|
||||
near-duplicate frames where the model latches onto the redundant middle
|
||||
and skips the start/end action
|
||||
"""
|
||||
context_size = self.genai_client.get_context_size()
|
||||
client = self.genai_manager.description_client
|
||||
|
||||
if client is None:
|
||||
return 3
|
||||
|
||||
context_size = client.get_context_size()
|
||||
camera_config = self.config.cameras[camera]
|
||||
|
||||
detect_width = camera_config.detect.width
|
||||
@@ -94,14 +115,15 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
width = target_width
|
||||
height = int(target_width / aspect_ratio)
|
||||
|
||||
pixels_per_image = width * height
|
||||
tokens_per_image = pixels_per_image / 1250
|
||||
tokens_per_image = client.estimate_image_tokens(width, height)
|
||||
prompt_tokens = 3800
|
||||
response_tokens = 300
|
||||
available_tokens = context_size - prompt_tokens - response_tokens
|
||||
max_frames = int(available_tokens / tokens_per_image)
|
||||
|
||||
return min(max(max_frames, 3), 20)
|
||||
context_budget = context_size - prompt_tokens - response_tokens
|
||||
image_token_budget = min(context_budget, MAX_IMAGE_TOKENS)
|
||||
max_frames_by_tokens = int(image_token_budget / tokens_per_image)
|
||||
max_frames_by_duration = int(duration * MAX_FRAMES_PER_SECOND)
|
||||
max_frames = min(max_frames_by_tokens, max_frames_by_duration)
|
||||
return max(max_frames, 3)
|
||||
|
||||
def process_data(
|
||||
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
||||
@@ -111,6 +133,9 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
if data_type != PostProcessDataEnum.review:
|
||||
return
|
||||
|
||||
if self.genai_manager.description_client is None:
|
||||
return
|
||||
|
||||
camera = data["after"]["camera"]
|
||||
camera_config = self.config.cameras[camera]
|
||||
|
||||
@@ -200,7 +225,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
target=run_analysis,
|
||||
args=(
|
||||
self.requestor,
|
||||
self.genai_client,
|
||||
self.genai_manager.description_client,
|
||||
self.review_desc_speed,
|
||||
camera_config,
|
||||
final_data,
|
||||
@@ -316,7 +341,12 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
os.path.join(CLIPS_DIR, "genai-requests", f"{start_ts}-{end_ts}")
|
||||
).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return self.genai_client.generate_review_summary(
|
||||
client = self.genai_manager.description_client
|
||||
|
||||
if client is None:
|
||||
return None
|
||||
|
||||
return client.generate_review_summary(
|
||||
start_ts,
|
||||
end_ts,
|
||||
events_with_context,
|
||||
@@ -336,12 +366,17 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
file_start = f"preview_{camera}-"
|
||||
start_file = f"{file_start}{start_time}.webp"
|
||||
end_file = f"{file_start}{end_time}.webp"
|
||||
|
||||
camera_files = [
|
||||
entry.name
|
||||
for entry in os.scandir(preview_dir)
|
||||
if entry.name.startswith(file_start)
|
||||
]
|
||||
camera_files.sort()
|
||||
|
||||
all_frames: list[str] = []
|
||||
|
||||
for file in sorted(os.listdir(preview_dir)):
|
||||
if not file.startswith(file_start):
|
||||
continue
|
||||
|
||||
for file in camera_files:
|
||||
if file < start_file:
|
||||
if len(all_frames):
|
||||
all_frames[0] = os.path.join(preview_dir, file)
|
||||
@@ -357,7 +392,9 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
all_frames.append(os.path.join(preview_dir, file))
|
||||
|
||||
frame_count = len(all_frames)
|
||||
desired_frame_count = self.calculate_frame_count(camera)
|
||||
desired_frame_count = self.calculate_frame_count(
|
||||
camera, duration=end_time - start_time
|
||||
)
|
||||
|
||||
if frame_count <= desired_frame_count:
|
||||
return all_frames
|
||||
@@ -381,7 +418,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
"""Get frames from recordings at specified timestamps."""
|
||||
duration = end_time - start_time
|
||||
desired_frame_count = self.calculate_frame_count(
|
||||
camera, ImageSourceEnum.recordings, height
|
||||
camera, duration, ImageSourceEnum.recordings, height
|
||||
)
|
||||
|
||||
# Calculate evenly spaced timestamps throughout the duration
|
||||
@@ -542,10 +579,11 @@ def run_analysis(
|
||||
if "-verified" in label:
|
||||
continue
|
||||
elif label in labelmap_objects:
|
||||
object_type = titlecase(label.replace("_", " "))
|
||||
object_type = label.replace("_", " ")
|
||||
|
||||
if label in attribute_labels:
|
||||
unified_objects.append(f"{object_type} (delivery/service)")
|
||||
display_name = ATTRIBUTE_LABEL_DISPLAY_MAP.get(label, object_type)
|
||||
unified_objects.append(f"{display_name} (delivery/service)")
|
||||
else:
|
||||
unified_objects.append(object_type)
|
||||
|
||||
|
||||
@@ -1,21 +1,48 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
|
||||
|
||||
ObservationItem = Annotated[str, StringConstraints(min_length=20, max_length=160)]
|
||||
|
||||
|
||||
class ReviewMetadata(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", protected_namespaces=())
|
||||
|
||||
observations: list[ObservationItem] = Field(
|
||||
...,
|
||||
min_length=3,
|
||||
max_length=15,
|
||||
description=(
|
||||
"Enumerate the significant observations across all frames, in "
|
||||
"chronological order, BEFORE composing the scene narrative. "
|
||||
"Include the very start of the activity — for example, a vehicle "
|
||||
"entering the frame or pulling into the driveway — even if it "
|
||||
"lasts only a few frames and the rest of the clip is dominated "
|
||||
"by a longer activity. Include each arrival, departure, motion "
|
||||
"event, object handled, and notable change in position or state. "
|
||||
"Each item is a single concrete fact written as a complete "
|
||||
"sentence. Do not summarize, interpret, or assign meaning here — "
|
||||
"that belongs in the scene field."
|
||||
),
|
||||
)
|
||||
title: str = Field(
|
||||
description="A short title characterizing what took place and where, under 10 words."
|
||||
max_length=80,
|
||||
description="Under 10 words. Name the apparent purpose or outcome of the activity together with the location involved. Do not narrate or list the sequence of actions step by step.",
|
||||
)
|
||||
scene: str = Field(
|
||||
description="A chronological narrative of what happens from start to finish."
|
||||
min_length=150,
|
||||
max_length=600,
|
||||
description="A chronological narrative of what happens from start to finish, drawing directly from the items in observations.",
|
||||
)
|
||||
shortSummary: str = Field(
|
||||
description="A brief 2-sentence summary of the scene, suitable for notifications."
|
||||
min_length=70,
|
||||
max_length=120,
|
||||
description="A brief 2-sentence summary of the scene, suitable for notifications.",
|
||||
)
|
||||
confidence: float = Field(
|
||||
ge=0.0,
|
||||
description="Confidence in the analysis, from 0 to 1.",
|
||||
le=1.0,
|
||||
description="Confidence in the analysis as a decimal between 0.0 and 1.0, where 0.0 means no confidence and 1.0 means complete confidence. Express ONLY as a decimal.",
|
||||
)
|
||||
potential_threat_level: int = Field(
|
||||
ge=0,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""Local only processors for handling real time object processing."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
from collections import deque
|
||||
from concurrent.futures import Future
|
||||
from queue import Empty, Full, Queue
|
||||
from typing import Any, Callable
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -74,3 +78,123 @@ class RealTimeProcessorApi(ABC):
|
||||
payload: The updated configuration object.
|
||||
"""
|
||||
pass
|
||||
|
||||
def drain_results(self) -> list[dict[str, Any]]:
|
||||
"""Return pending results that need IPC side-effects.
|
||||
|
||||
Deferred processors accumulate results on a worker thread.
|
||||
The maintainer calls this each loop iteration to collect them
|
||||
and perform publishes on the main thread.
|
||||
|
||||
Synchronous processors return an empty list (default).
|
||||
"""
|
||||
return []
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop any background work and release resources.
|
||||
|
||||
Called when the processor is being removed or the maintainer
|
||||
is shutting down. Default is a no-op for synchronous processors.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class DeferredRealtimeProcessorApi(RealTimeProcessorApi):
|
||||
"""Base class for processors that offload heavy work to a background thread.
|
||||
|
||||
Subclasses implement:
|
||||
- process_frame(): do cheap gating + crop + copy, then call _enqueue_task()
|
||||
- _process_task(task): heavy work (inference, consensus) on the worker thread
|
||||
- handle_request(): optionally use _enqueue_request() for sync request/response
|
||||
- expire_object(): call _enqueue_task() with a control message
|
||||
|
||||
The worker thread owns all processor state. No locks are needed because
|
||||
only the worker mutates state. Results that need IPC are placed in
|
||||
_pending_results via _emit_result(), and the maintainer drains them
|
||||
each loop iteration.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
metrics: DataProcessorMetrics,
|
||||
max_queue: int = 8,
|
||||
) -> None:
|
||||
super().__init__(config, metrics)
|
||||
self._task_queue: Queue = Queue(maxsize=max_queue)
|
||||
self._pending_results: deque[dict[str, Any]] = deque()
|
||||
self._results_lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
self._worker = threading.Thread(
|
||||
target=self._drain_loop,
|
||||
daemon=True,
|
||||
name=f"{type(self).__name__}_worker",
|
||||
)
|
||||
self._worker.start()
|
||||
|
||||
def _drain_loop(self) -> None:
|
||||
"""Worker thread main loop — drains the task queue until stopped."""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
task = self._task_queue.get(timeout=0.5)
|
||||
except Empty:
|
||||
continue
|
||||
|
||||
if (
|
||||
isinstance(task, tuple)
|
||||
and len(task) == 2
|
||||
and isinstance(task[1], Future)
|
||||
):
|
||||
# Request/response: (callable_and_args, future)
|
||||
(func, args), future = task
|
||||
try:
|
||||
result = func(args)
|
||||
future.set_result(result)
|
||||
except Exception as e:
|
||||
future.set_exception(e)
|
||||
else:
|
||||
try:
|
||||
self._process_task(task)
|
||||
except Exception:
|
||||
logger.exception("Error processing deferred task")
|
||||
|
||||
def _enqueue_task(self, task: Any) -> bool:
|
||||
"""Enqueue a task for the worker. Returns False if queue is full (dropped)."""
|
||||
try:
|
||||
self._task_queue.put_nowait(task)
|
||||
return True
|
||||
except Full:
|
||||
logger.debug("Deferred processor queue full, dropping task")
|
||||
return False
|
||||
|
||||
def _enqueue_request(self, func: Callable, args: Any, timeout: float = 10.0) -> Any:
|
||||
"""Enqueue a request and block until the worker returns a result."""
|
||||
future: Future = Future()
|
||||
self._task_queue.put(((func, args), future), timeout=timeout)
|
||||
return future.result(timeout=timeout)
|
||||
|
||||
def _emit_result(self, result: dict[str, Any]) -> None:
|
||||
"""Called by the worker thread to stage a result for the maintainer."""
|
||||
with self._results_lock:
|
||||
self._pending_results.append(result)
|
||||
|
||||
def drain_results(self) -> list[dict[str, Any]]:
|
||||
"""Called by the maintainer on the main thread to collect pending results."""
|
||||
with self._results_lock:
|
||||
results = list(self._pending_results)
|
||||
self._pending_results.clear()
|
||||
return results
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Signal the worker to stop and wait for it to finish."""
|
||||
self._stop_event.set()
|
||||
self._worker.join(timeout=5.0)
|
||||
|
||||
@abstractmethod
|
||||
def _process_task(self, task: Any) -> None:
|
||||
"""Process a single task on the worker thread.
|
||||
|
||||
Subclasses implement inference, consensus, training image saves here.
|
||||
Call _emit_result() to stage results for the maintainer to publish.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Real time processor that works with classification tflite models."""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
@@ -10,25 +9,18 @@ import cv2
|
||||
import numpy as np
|
||||
|
||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
||||
from frigate.comms.event_metadata_updater import (
|
||||
EventMetadataPublisher,
|
||||
EventMetadataTypeEnum,
|
||||
)
|
||||
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import (
|
||||
CustomClassificationConfig,
|
||||
ObjectClassificationType,
|
||||
)
|
||||
from frigate.config.classification import CustomClassificationConfig
|
||||
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.image import calculate_region
|
||||
from frigate.util.object import box_overlaps
|
||||
|
||||
from ..types import DataProcessorMetrics
|
||||
from .api import RealTimeProcessorApi
|
||||
from .api import DeferredRealtimeProcessorApi
|
||||
|
||||
try:
|
||||
from tflite_runtime.interpreter import Interpreter
|
||||
@@ -40,7 +32,7 @@ logger = logging.getLogger(__name__)
|
||||
MAX_OBJECT_CLASSIFICATIONS = 16
|
||||
|
||||
|
||||
class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
@@ -48,7 +40,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
requestor: InterProcessRequestor,
|
||||
metrics: DataProcessorMetrics,
|
||||
):
|
||||
super().__init__(config, metrics)
|
||||
super().__init__(config, metrics, max_queue=4)
|
||||
self.model_config = model_config
|
||||
|
||||
if not self.model_config.name:
|
||||
@@ -259,14 +251,34 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
return
|
||||
|
||||
frame = rgb[y1:y2, x1:x2]
|
||||
cropped_frame = rgb[y1:y2, x1:x2]
|
||||
|
||||
try:
|
||||
resized_frame = cv2.resize(frame, (224, 224))
|
||||
resized_frame = cv2.resize(cropped_frame, (224, 224))
|
||||
except Exception:
|
||||
logger.warning("Failed to resize image for state classification")
|
||||
return
|
||||
|
||||
# Copy for training image saves on worker thread
|
||||
crop_bgr = cv2.cvtColor(cropped_frame, cv2.COLOR_RGB2BGR)
|
||||
|
||||
self._enqueue_task(("classify", camera, now, resized_frame, crop_bgr))
|
||||
|
||||
def _process_task(self, task: Any) -> None:
|
||||
kind = task[0]
|
||||
if kind == "classify":
|
||||
_, camera, timestamp, resized_frame, crop_bgr = task
|
||||
self._classify_state(camera, timestamp, resized_frame, crop_bgr)
|
||||
elif kind == "reload":
|
||||
self.__build_detector()
|
||||
|
||||
def _classify_state(
|
||||
self,
|
||||
camera: str,
|
||||
timestamp: float,
|
||||
resized_frame: np.ndarray,
|
||||
crop_bgr: np.ndarray,
|
||||
) -> None:
|
||||
if self.interpreter is None:
|
||||
# When interpreter is None, always save (score is 0.0, which is < 1.0)
|
||||
if self._should_save_image(camera, "unknown", 0.0):
|
||||
@@ -277,15 +289,18 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
write_classification_attempt(
|
||||
self.train_dir,
|
||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||
crop_bgr,
|
||||
"none-none",
|
||||
now,
|
||||
timestamp,
|
||||
"unknown",
|
||||
0.0,
|
||||
max_files=save_attempts,
|
||||
)
|
||||
return
|
||||
|
||||
if not self.tensor_input_details or not self.tensor_output_details:
|
||||
return
|
||||
|
||||
input = np.expand_dims(resized_frame, axis=0)
|
||||
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], input)
|
||||
self.interpreter.invoke()
|
||||
@@ -298,7 +313,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
best_id = int(np.argmax(probs))
|
||||
score = round(probs[best_id], 2)
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - timestamp)
|
||||
|
||||
detected_state = self.labelmap[best_id]
|
||||
|
||||
@@ -310,9 +325,9 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
write_classification_attempt(
|
||||
self.train_dir,
|
||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||
crop_bgr,
|
||||
"none-none",
|
||||
now,
|
||||
timestamp,
|
||||
detected_state,
|
||||
score,
|
||||
max_files=save_attempts,
|
||||
@@ -327,9 +342,14 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
verified_state = self.verify_state_change(camera, detected_state)
|
||||
|
||||
if verified_state is not None:
|
||||
self.requestor.send_data(
|
||||
f"{camera}/classification/{self.model_config.name}",
|
||||
verified_state,
|
||||
self._emit_result(
|
||||
{
|
||||
"type": "classification",
|
||||
"processor": "state",
|
||||
"model_name": self.model_config.name,
|
||||
"camera": camera,
|
||||
"state": verified_state,
|
||||
}
|
||||
)
|
||||
|
||||
def handle_request(
|
||||
@@ -337,14 +357,19 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
) -> dict[str, Any] | None:
|
||||
if topic == EmbeddingsRequestEnum.reload_classification_model.value:
|
||||
if request_data.get("model_name") == self.model_config.name:
|
||||
self.__build_detector()
|
||||
logger.info(
|
||||
f"Successfully loaded updated model for {self.model_config.name}"
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Loaded {self.model_config.name} model.",
|
||||
}
|
||||
|
||||
def _do_reload(data: dict[str, Any]) -> dict[str, Any]:
|
||||
self.__build_detector()
|
||||
logger.info(
|
||||
f"Successfully loaded updated model for {self.model_config.name}"
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Loaded {self.model_config.name} model.",
|
||||
}
|
||||
|
||||
result: dict[str, Any] = self._enqueue_request(_do_reload, request_data)
|
||||
return result
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
@@ -354,7 +379,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
pass
|
||||
|
||||
|
||||
class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
class CustomObjectClassificationProcessor(DeferredRealtimeProcessorApi):
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
@@ -363,7 +388,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
requestor: InterProcessRequestor,
|
||||
metrics: DataProcessorMetrics,
|
||||
):
|
||||
super().__init__(config, metrics)
|
||||
super().__init__(config, metrics, max_queue=8)
|
||||
self.model_config = model_config
|
||||
|
||||
if not self.model_config.name:
|
||||
@@ -536,18 +561,41 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
|
||||
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420)
|
||||
crop = rgb[
|
||||
y:y2,
|
||||
x:x2,
|
||||
]
|
||||
crop = rgb[y:y2, x:x2]
|
||||
|
||||
if crop.shape != (224, 224):
|
||||
try:
|
||||
resized_crop = cv2.resize(crop, (224, 224))
|
||||
except Exception:
|
||||
logger.warning("Failed to resize image for state classification")
|
||||
return
|
||||
try:
|
||||
resized_crop = cv2.resize(crop, (224, 224))
|
||||
except Exception:
|
||||
logger.warning("Failed to resize image for object classification")
|
||||
return
|
||||
|
||||
# Copy crop for training images (will be used on worker thread)
|
||||
crop_bgr = cv2.cvtColor(crop, cv2.COLOR_RGB2BGR)
|
||||
|
||||
self._enqueue_task(
|
||||
("classify", object_id, obj_data["camera"], now, resized_crop, crop_bgr)
|
||||
)
|
||||
|
||||
def _process_task(self, task: Any) -> None:
|
||||
kind = task[0]
|
||||
if kind == "classify":
|
||||
_, object_id, camera, timestamp, resized_crop, crop_bgr = task
|
||||
self._classify_object(object_id, camera, timestamp, resized_crop, crop_bgr)
|
||||
elif kind == "expire":
|
||||
_, object_id = task
|
||||
if object_id in self.classification_history:
|
||||
self.classification_history.pop(object_id)
|
||||
elif kind == "reload":
|
||||
self.__build_detector()
|
||||
|
||||
def _classify_object(
|
||||
self,
|
||||
object_id: str,
|
||||
camera: str,
|
||||
timestamp: float,
|
||||
resized_crop: np.ndarray,
|
||||
crop_bgr: np.ndarray,
|
||||
) -> None:
|
||||
if self.interpreter is None:
|
||||
save_attempts = (
|
||||
self.model_config.save_attempts
|
||||
@@ -556,9 +604,9 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
write_classification_attempt(
|
||||
self.train_dir,
|
||||
cv2.cvtColor(crop, cv2.COLOR_RGB2BGR),
|
||||
crop_bgr,
|
||||
object_id,
|
||||
now,
|
||||
timestamp,
|
||||
"unknown",
|
||||
0.0,
|
||||
max_files=save_attempts,
|
||||
@@ -569,7 +617,10 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
if object_id not in self.classification_history:
|
||||
self.classification_history[object_id] = []
|
||||
|
||||
self.classification_history[object_id].append(("unknown", 0.0, now))
|
||||
self.classification_history[object_id].append(("unknown", 0.0, timestamp))
|
||||
return
|
||||
|
||||
if not self.tensor_input_details or not self.tensor_output_details:
|
||||
return
|
||||
|
||||
input = np.expand_dims(resized_crop, axis=0)
|
||||
@@ -584,7 +635,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
best_id = int(np.argmax(probs))
|
||||
score = round(probs[best_id], 2)
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - timestamp)
|
||||
|
||||
save_attempts = (
|
||||
self.model_config.save_attempts
|
||||
@@ -593,9 +644,9 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
write_classification_attempt(
|
||||
self.train_dir,
|
||||
cv2.cvtColor(crop, cv2.COLOR_RGB2BGR),
|
||||
crop_bgr,
|
||||
object_id,
|
||||
now,
|
||||
timestamp,
|
||||
self.labelmap[best_id],
|
||||
score,
|
||||
max_files=save_attempts,
|
||||
@@ -610,92 +661,57 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
sub_label = self.labelmap[best_id]
|
||||
|
||||
logger.debug(
|
||||
f"{self.model_config.name}: Object {object_id} (label={obj_data['label']}) passed threshold with sub_label={sub_label}, score={score}"
|
||||
f"{self.model_config.name}: Object {object_id} passed threshold with sub_label={sub_label}, score={score}"
|
||||
)
|
||||
|
||||
consensus_label, consensus_score = self.get_weighted_score(
|
||||
object_id, sub_label, score, now
|
||||
object_id, sub_label, score, timestamp
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"{self.model_config.name}: get_weighted_score returned consensus_label={consensus_label}, consensus_score={consensus_score} for {object_id}"
|
||||
)
|
||||
|
||||
if consensus_label is not None:
|
||||
camera = obj_data["camera"]
|
||||
logger.debug(
|
||||
f"{self.model_config.name}: Publishing sub_label={consensus_label} for {obj_data['label']} object {object_id} on {camera}"
|
||||
if consensus_label is not None and self.model_config.object_config is not None:
|
||||
self._emit_result(
|
||||
{
|
||||
"type": "classification",
|
||||
"processor": "object",
|
||||
"model_name": self.model_config.name,
|
||||
"classification_type": self.model_config.object_config.classification_type,
|
||||
"object_id": object_id,
|
||||
"camera": camera,
|
||||
"timestamp": timestamp,
|
||||
"label": consensus_label,
|
||||
"score": consensus_score,
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
self.model_config.object_config.classification_type
|
||||
== ObjectClassificationType.sub_label
|
||||
):
|
||||
self.sub_label_publisher.publish(
|
||||
(object_id, consensus_label, consensus_score),
|
||||
EventMetadataTypeEnum.sub_label,
|
||||
)
|
||||
self.requestor.send_data(
|
||||
"tracked_object_update",
|
||||
json.dumps(
|
||||
{
|
||||
"type": TrackedObjectUpdateTypesEnum.classification,
|
||||
"id": object_id,
|
||||
"camera": camera,
|
||||
"timestamp": now,
|
||||
"model": self.model_config.name,
|
||||
"sub_label": consensus_label,
|
||||
"score": consensus_score,
|
||||
}
|
||||
),
|
||||
)
|
||||
elif (
|
||||
self.model_config.object_config.classification_type
|
||||
== ObjectClassificationType.attribute
|
||||
):
|
||||
self.sub_label_publisher.publish(
|
||||
(
|
||||
object_id,
|
||||
self.model_config.name,
|
||||
consensus_label,
|
||||
consensus_score,
|
||||
),
|
||||
EventMetadataTypeEnum.attribute.value,
|
||||
)
|
||||
self.requestor.send_data(
|
||||
"tracked_object_update",
|
||||
json.dumps(
|
||||
{
|
||||
"type": TrackedObjectUpdateTypesEnum.classification,
|
||||
"id": object_id,
|
||||
"camera": camera,
|
||||
"timestamp": now,
|
||||
"model": self.model_config.name,
|
||||
"attribute": consensus_label,
|
||||
"score": consensus_score,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def handle_request(self, topic: str, request_data: dict) -> dict | None:
|
||||
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()
|
||||
logger.info(
|
||||
f"Successfully loaded updated model for {self.model_config.name}"
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Loaded {self.model_config.name} model.",
|
||||
}
|
||||
|
||||
def _do_reload(data: dict[str, Any]) -> dict[str, Any]:
|
||||
self.__build_detector()
|
||||
logger.info(
|
||||
f"Successfully loaded updated model for {self.model_config.name}"
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Loaded {self.model_config.name} model.",
|
||||
}
|
||||
|
||||
result: dict[str, Any] = self._enqueue_request(_do_reload, request_data)
|
||||
return result
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
if object_id in self.classification_history:
|
||||
self.classification_history.pop(object_id)
|
||||
self._enqueue_task(("expire", object_id))
|
||||
|
||||
|
||||
def write_classification_attempt(
|
||||
|
||||
+78
-182
@@ -1,9 +1,13 @@
|
||||
"""Debug replay camera management for replaying recordings with detection overlays."""
|
||||
"""Debug replay camera management for replaying recordings with detection overlays.
|
||||
|
||||
The startup work (ffmpeg concat + camera config publish) lives in
|
||||
frigate.jobs.debug_replay. This module owns only session presence
|
||||
(active), session metadata, and post-session cleanup.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess as sp
|
||||
import threading
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
@@ -21,7 +25,7 @@ from frigate.const import (
|
||||
REPLAY_DIR,
|
||||
THUMB_DIR,
|
||||
)
|
||||
from frigate.models import Recordings
|
||||
from frigate.jobs.debug_replay import cancel_debug_replay_job, wait_for_runner
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
@@ -29,7 +33,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DebugReplayManager:
|
||||
"""Manages a single debug replay session."""
|
||||
"""Owns the lifecycle pointers for a single debug replay session.
|
||||
|
||||
A session exists from the moment mark_starting is called (synchronously,
|
||||
inside the API handler) until clear_session runs (on success cleanup,
|
||||
failure, or stop). The active property is the source of truth that the
|
||||
status bar consumes — broader than the startup job, which only covers the
|
||||
preparing_clip / starting_camera window.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
@@ -41,144 +52,66 @@ class DebugReplayManager:
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""Whether a replay session is currently active."""
|
||||
"""True from mark_starting until clear_session."""
|
||||
return self.replay_camera_name is not None
|
||||
|
||||
def start(
|
||||
def mark_starting(
|
||||
self,
|
||||
source_camera: str,
|
||||
replay_camera_name: str,
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> str:
|
||||
"""Start a debug replay session.
|
||||
) -> None:
|
||||
"""Synchronously claim the session before the job runner starts.
|
||||
|
||||
Args:
|
||||
source_camera: Name of the source camera to replay
|
||||
start_ts: Start timestamp
|
||||
end_ts: End timestamp
|
||||
frigate_config: Current Frigate configuration
|
||||
config_publisher: Publisher for camera config updates
|
||||
|
||||
Returns:
|
||||
The replay camera name
|
||||
|
||||
Raises:
|
||||
ValueError: If a session is already active or parameters are invalid
|
||||
RuntimeError: If clip generation fails
|
||||
Called inside the API handler so the status bar sees active=True
|
||||
immediately, before the worker thread does any ffmpeg work.
|
||||
"""
|
||||
with self._lock:
|
||||
return self._start_locked(
|
||||
source_camera, start_ts, end_ts, frigate_config, config_publisher
|
||||
)
|
||||
self.replay_camera_name = replay_camera_name
|
||||
self.source_camera = source_camera
|
||||
self.start_ts = start_ts
|
||||
self.end_ts = end_ts
|
||||
self.clip_path = None
|
||||
|
||||
def _start_locked(
|
||||
def mark_session_ready(self, clip_path: str) -> None:
|
||||
"""Record the on-disk clip path after the camera has been published."""
|
||||
with self._lock:
|
||||
self.clip_path = clip_path
|
||||
|
||||
def clear_session(self) -> None:
|
||||
"""Reset session pointers without publishing camera removal.
|
||||
|
||||
Used by the job runner on failure paths. stop() does the camera
|
||||
teardown plus this clear in one step.
|
||||
"""
|
||||
with self._lock:
|
||||
self._clear_locked()
|
||||
|
||||
def _clear_locked(self) -> None:
|
||||
self.replay_camera_name = None
|
||||
self.source_camera = None
|
||||
self.clip_path = None
|
||||
self.start_ts = None
|
||||
self.end_ts = None
|
||||
|
||||
def publish_camera(
|
||||
self,
|
||||
source_camera: str,
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
replay_name: str,
|
||||
clip_path: str,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> str:
|
||||
if self.active:
|
||||
raise ValueError("A replay session is already active")
|
||||
) -> None:
|
||||
"""Build the in-memory replay camera config and publish the add event.
|
||||
|
||||
if source_camera not in frigate_config.cameras:
|
||||
raise ValueError(f"Camera '{source_camera}' not found")
|
||||
|
||||
if end_ts <= start_ts:
|
||||
raise ValueError("End time must be after start time")
|
||||
|
||||
# Query recordings for the source camera in the time range
|
||||
recordings = (
|
||||
Recordings.select(
|
||||
Recordings.path,
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.where(
|
||||
Recordings.start_time.between(start_ts, end_ts)
|
||||
| Recordings.end_time.between(start_ts, end_ts)
|
||||
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time))
|
||||
)
|
||||
.where(Recordings.camera == source_camera)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
)
|
||||
|
||||
if not recordings.count():
|
||||
raise ValueError(
|
||||
f"No recordings found for camera '{source_camera}' in the specified time range"
|
||||
)
|
||||
|
||||
# Create replay directory
|
||||
os.makedirs(REPLAY_DIR, exist_ok=True)
|
||||
|
||||
# Generate replay camera name
|
||||
replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}"
|
||||
|
||||
# Build concat file for ffmpeg
|
||||
concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt")
|
||||
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
|
||||
|
||||
with open(concat_file, "w") as f:
|
||||
for recording in recordings:
|
||||
f.write(f"file '{recording.path}'\n")
|
||||
|
||||
# Concatenate recordings into a single clip with -c copy (fast)
|
||||
ffmpeg_cmd = [
|
||||
frigate_config.ffmpeg.ffmpeg_path,
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
concat_file,
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
clip_path,
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"Generating replay clip for %s (%.1f - %.1f)",
|
||||
source_camera,
|
||||
start_ts,
|
||||
end_ts,
|
||||
)
|
||||
|
||||
try:
|
||||
result = sp.run(
|
||||
ffmpeg_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("FFmpeg error: %s", result.stderr)
|
||||
raise RuntimeError(
|
||||
f"Failed to generate replay clip: {result.stderr[-500:]}"
|
||||
)
|
||||
except sp.TimeoutExpired:
|
||||
raise RuntimeError("Clip generation timed out")
|
||||
finally:
|
||||
# Clean up concat file
|
||||
if os.path.exists(concat_file):
|
||||
os.remove(concat_file)
|
||||
|
||||
if not os.path.exists(clip_path):
|
||||
raise RuntimeError("Clip file was not created")
|
||||
|
||||
# Build camera config dict for the replay camera
|
||||
Called by the job runner during the starting_camera phase.
|
||||
"""
|
||||
source_config = frigate_config.cameras[source_camera]
|
||||
camera_dict = self._build_camera_config_dict(
|
||||
source_config, replay_name, clip_path
|
||||
)
|
||||
|
||||
# Build an in-memory config with the replay camera added
|
||||
config_file = find_config_file()
|
||||
yaml_parser = YAML()
|
||||
with open(config_file, "r") as f:
|
||||
@@ -191,75 +124,48 @@ class DebugReplayManager:
|
||||
try:
|
||||
new_config = FrigateConfig.parse_object(config_data)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to validate replay camera config: {e}")
|
||||
|
||||
# Update the running config
|
||||
raise RuntimeError(f"Failed to validate replay camera config: {e}") from e
|
||||
frigate_config.cameras[replay_name] = new_config.cameras[replay_name]
|
||||
|
||||
# Publish the add event
|
||||
config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.add, replay_name),
|
||||
new_config.cameras[replay_name],
|
||||
)
|
||||
|
||||
# Store session state
|
||||
self.replay_camera_name = replay_name
|
||||
self.source_camera = source_camera
|
||||
self.clip_path = clip_path
|
||||
self.start_ts = start_ts
|
||||
self.end_ts = end_ts
|
||||
|
||||
logger.info("Debug replay started: %s -> %s", source_camera, replay_name)
|
||||
return replay_name
|
||||
|
||||
def stop(
|
||||
self,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> None:
|
||||
"""Stop the active replay session and clean up all artifacts.
|
||||
"""Cancel any in-flight startup job and tear down the active session.
|
||||
|
||||
Args:
|
||||
frigate_config: Current Frigate configuration
|
||||
config_publisher: Publisher for camera config updates
|
||||
Safe to call when no session is active (no-op with a warning).
|
||||
"""
|
||||
cancel_debug_replay_job()
|
||||
wait_for_runner(timeout=2.0)
|
||||
|
||||
with self._lock:
|
||||
self._stop_locked(frigate_config, config_publisher)
|
||||
if not self.active:
|
||||
logger.warning("No active replay session to stop")
|
||||
return
|
||||
|
||||
def _stop_locked(
|
||||
self,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> None:
|
||||
if not self.active:
|
||||
logger.warning("No active replay session to stop")
|
||||
return
|
||||
replay_name = self.replay_camera_name
|
||||
|
||||
replay_name = self.replay_camera_name
|
||||
# Only publish remove if the camera was actually added to the live
|
||||
# config (i.e. the runner reached the starting_camera phase).
|
||||
if replay_name is not None and replay_name in frigate_config.cameras:
|
||||
config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, replay_name),
|
||||
frigate_config.cameras[replay_name],
|
||||
)
|
||||
|
||||
# Publish remove event so subscribers stop and remove from their config
|
||||
if replay_name in frigate_config.cameras:
|
||||
config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, replay_name),
|
||||
frigate_config.cameras[replay_name],
|
||||
)
|
||||
# Do NOT pop here — let subscribers handle removal from the shared
|
||||
# config dict when they process the ZMQ message to avoid race conditions
|
||||
if replay_name is not None:
|
||||
self._cleanup_db(replay_name)
|
||||
self._cleanup_files(replay_name)
|
||||
|
||||
# Defensive DB cleanup
|
||||
self._cleanup_db(replay_name)
|
||||
self._clear_locked()
|
||||
|
||||
# Remove filesystem artifacts
|
||||
self._cleanup_files(replay_name)
|
||||
|
||||
# Reset state
|
||||
self.replay_camera_name = None
|
||||
self.source_camera = None
|
||||
self.clip_path = None
|
||||
self.start_ts = None
|
||||
self.end_ts = None
|
||||
|
||||
logger.info("Debug replay stopped and cleaned up: %s", replay_name)
|
||||
logger.info("Debug replay stopped and cleaned up: %s", replay_name)
|
||||
|
||||
def _build_camera_config_dict(
|
||||
self,
|
||||
@@ -267,16 +173,7 @@ class DebugReplayManager:
|
||||
replay_name: str,
|
||||
clip_path: str,
|
||||
) -> dict:
|
||||
"""Build a camera config dictionary for the replay camera.
|
||||
|
||||
Args:
|
||||
source_config: Source camera's CameraConfig
|
||||
replay_name: Name for the replay camera
|
||||
clip_path: Path to the replay clip file
|
||||
|
||||
Returns:
|
||||
Camera config as a dictionary
|
||||
"""
|
||||
"""Build a camera config dictionary for the replay camera."""
|
||||
# Extract detect config (exclude computed fields)
|
||||
detect_dict = source_config.detect.model_dump(
|
||||
exclude={"min_initialized", "max_disappeared", "enabled_in_config"}
|
||||
@@ -311,7 +208,6 @@ class DebugReplayManager:
|
||||
zone_dump = zone_config.model_dump(
|
||||
exclude={"contour", "color"}, exclude_defaults=True
|
||||
)
|
||||
# Always include required fields
|
||||
zone_dump.setdefault("coordinates", zone_config.coordinates)
|
||||
zones_dict[zone_name] = zone_dump
|
||||
|
||||
|
||||
@@ -132,7 +132,6 @@ class ONNXModelRunner(BaseModelRunner):
|
||||
return model_type in [
|
||||
EnrichmentModelTypeEnum.paddleocr.value,
|
||||
EnrichmentModelTypeEnum.jina_v2.value,
|
||||
EnrichmentModelTypeEnum.arcface.value,
|
||||
ModelTypeEnum.rfdetr.value,
|
||||
ModelTypeEnum.dfine.value,
|
||||
]
|
||||
|
||||
@@ -52,6 +52,12 @@ class OvDetector(DetectionApi):
|
||||
self.h = detector_config.model.height
|
||||
self.w = detector_config.model.width
|
||||
|
||||
logger.info(
|
||||
"Loading OpenVINO model %s on device %s",
|
||||
detector_config.model.path,
|
||||
detector_config.device,
|
||||
)
|
||||
|
||||
self.runner = OpenVINOModelRunner(
|
||||
model_path=detector_config.model.path,
|
||||
device=detector_config.device,
|
||||
|
||||
@@ -4,6 +4,7 @@ import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from json.decoder import JSONDecodeError
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
@@ -52,6 +53,14 @@ class EmbeddingProcess(FrigateProcess):
|
||||
self.stop_event,
|
||||
)
|
||||
maintainer.start()
|
||||
maintainer.join()
|
||||
|
||||
# If the maintainer thread exited but no shutdown was requested, it
|
||||
# crashed. Surface as a non-zero exit so the watchdog restarts us
|
||||
# instead of treating the silent thread death as a clean shutdown.
|
||||
if not self.stop_event.is_set():
|
||||
logger.error("Embeddings maintainer thread exited unexpectedly")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class EmbeddingsContext:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
@@ -33,6 +34,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.config.classification import ObjectClassificationType
|
||||
from frigate.data_processing.common.license_plate.model import (
|
||||
LicensePlateModelRunner,
|
||||
)
|
||||
@@ -61,6 +63,7 @@ from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum
|
||||
from frigate.genai import GenAIClientManager
|
||||
from frigate.models import Event, Recordings, ReviewSegment, Trigger
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.builtin import serialize
|
||||
from frigate.util.file import get_event_thumbnail_bytes
|
||||
from frigate.util.image import SharedMemoryFrameManager
|
||||
@@ -92,6 +95,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
CameraConfigUpdateEnum.object_genai,
|
||||
CameraConfigUpdateEnum.review,
|
||||
CameraConfigUpdateEnum.review_genai,
|
||||
CameraConfigUpdateEnum.semantic_search,
|
||||
],
|
||||
@@ -202,15 +206,13 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
# post processors
|
||||
self.post_processors: list[PostProcessorApi] = []
|
||||
|
||||
if self.genai_manager.vision_client is not None and any(
|
||||
c.review.genai.enabled_in_config for c in self.config.cameras.values()
|
||||
):
|
||||
if any(c.review.genai.enabled_in_config for c in self.config.cameras.values()):
|
||||
self.post_processors.append(
|
||||
ReviewDescriptionProcessor(
|
||||
self.config,
|
||||
self.requestor,
|
||||
self.metrics,
|
||||
self.genai_manager.vision_client,
|
||||
self.genai_manager,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -248,16 +250,14 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
)
|
||||
self.post_processors.append(semantic_trigger_processor)
|
||||
|
||||
if self.genai_manager.vision_client is not None and any(
|
||||
c.objects.genai.enabled_in_config for c in self.config.cameras.values()
|
||||
):
|
||||
if any(c.objects.genai.enabled_in_config for c in self.config.cameras.values()):
|
||||
self.post_processors.append(
|
||||
ObjectDescriptionProcessor(
|
||||
self.config,
|
||||
self.embeddings,
|
||||
self.requestor,
|
||||
self.metrics,
|
||||
self.genai_manager.vision_client,
|
||||
self.genai_manager,
|
||||
semantic_trigger_processor,
|
||||
)
|
||||
)
|
||||
@@ -277,10 +277,15 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
self._process_recordings_updates()
|
||||
self._process_review_updates()
|
||||
self._process_frame_updates()
|
||||
self._process_deferred_results()
|
||||
self._expire_dedicated_lpr()
|
||||
self._process_finalized()
|
||||
self._process_event_metadata()
|
||||
|
||||
# Shutdown deferred processors
|
||||
for processor in self.realtime_processors:
|
||||
processor.shutdown()
|
||||
|
||||
self.config_updater.stop()
|
||||
self.enrichment_config_subscriber.stop()
|
||||
self.event_subscriber.stop()
|
||||
@@ -305,6 +310,10 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
self._handle_custom_classification_update(topic, payload)
|
||||
return
|
||||
|
||||
if topic == "config/genai":
|
||||
self.config.genai = payload
|
||||
self.genai_manager.update_config(self.config)
|
||||
|
||||
# Broadcast to all processors — each decides if the topic is relevant
|
||||
for processor in self.realtime_processors:
|
||||
processor.update_config(topic, payload)
|
||||
@@ -319,10 +328,9 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
model_name = topic.split("/")[-1]
|
||||
|
||||
if model_config is None:
|
||||
self.realtime_processors = [
|
||||
processor
|
||||
for processor in self.realtime_processors
|
||||
if not (
|
||||
remaining = []
|
||||
for processor in self.realtime_processors:
|
||||
if (
|
||||
isinstance(
|
||||
processor,
|
||||
(
|
||||
@@ -331,8 +339,11 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
),
|
||||
)
|
||||
and processor.model_config.name == model_name
|
||||
)
|
||||
]
|
||||
):
|
||||
processor.shutdown()
|
||||
else:
|
||||
remaining.append(processor)
|
||||
self.realtime_processors = remaining
|
||||
|
||||
logger.info(
|
||||
f"Successfully removed classification processor for model: {model_name}"
|
||||
@@ -506,10 +517,16 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
except DoesNotExist:
|
||||
for processor in self.post_processors:
|
||||
if isinstance(processor, ObjectDescriptionProcessor):
|
||||
processor.cleanup_event(event_id)
|
||||
continue
|
||||
|
||||
# Skip the event if not an object
|
||||
if event.data.get("type") != "object":
|
||||
for processor in self.post_processors:
|
||||
if isinstance(processor, ObjectDescriptionProcessor):
|
||||
processor.cleanup_event(event_id)
|
||||
continue
|
||||
|
||||
# Extract valid thumbnail
|
||||
@@ -700,6 +717,68 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
|
||||
self.frame_manager.close(frame_name)
|
||||
|
||||
def _process_deferred_results(self) -> None:
|
||||
"""Drain results from deferred processors and perform IPC side-effects."""
|
||||
for processor in self.realtime_processors:
|
||||
results = processor.drain_results()
|
||||
|
||||
for result in results:
|
||||
if result.get("type") != "classification":
|
||||
continue
|
||||
|
||||
if result["processor"] == "state":
|
||||
self.requestor.send_data(
|
||||
f"{result['camera']}/classification/{result['model_name']}",
|
||||
result["state"],
|
||||
)
|
||||
elif result["processor"] == "object":
|
||||
object_id = result["object_id"]
|
||||
camera = result["camera"]
|
||||
timestamp = result["timestamp"]
|
||||
model_name = result["model_name"]
|
||||
label = result["label"]
|
||||
score = result["score"]
|
||||
classification_type = result["classification_type"]
|
||||
|
||||
if classification_type == ObjectClassificationType.sub_label:
|
||||
self.event_metadata_publisher.publish(
|
||||
(object_id, label, score),
|
||||
EventMetadataTypeEnum.sub_label,
|
||||
)
|
||||
self.requestor.send_data(
|
||||
"tracked_object_update",
|
||||
json.dumps(
|
||||
{
|
||||
"type": TrackedObjectUpdateTypesEnum.classification,
|
||||
"id": object_id,
|
||||
"camera": camera,
|
||||
"timestamp": timestamp,
|
||||
"model": model_name,
|
||||
"sub_label": label,
|
||||
"score": score,
|
||||
}
|
||||
),
|
||||
)
|
||||
elif classification_type == ObjectClassificationType.attribute:
|
||||
self.event_metadata_publisher.publish(
|
||||
(object_id, model_name, label, score),
|
||||
EventMetadataTypeEnum.attribute.value,
|
||||
)
|
||||
self.requestor.send_data(
|
||||
"tracked_object_update",
|
||||
json.dumps(
|
||||
{
|
||||
"type": TrackedObjectUpdateTypesEnum.classification,
|
||||
"id": object_id,
|
||||
"camera": camera,
|
||||
"timestamp": timestamp,
|
||||
"model": model_name,
|
||||
"attribute": label,
|
||||
"score": score,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def _embed_thumbnail(self, event_id: str, thumbnail: bytes) -> None:
|
||||
"""Embed the thumbnail for an event."""
|
||||
if not self.config.semantic_search.enabled:
|
||||
|
||||
@@ -205,6 +205,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.transcription_thread.start()
|
||||
|
||||
self.was_enabled = camera.enabled
|
||||
self.was_audio_enabled = camera.audio.enabled
|
||||
|
||||
def detect_audio(self, audio: np.ndarray) -> None:
|
||||
if not self.camera_config.audio.enabled or self.stop_event.is_set():
|
||||
@@ -363,6 +364,17 @@ class AudioEventMaintainer(threading.Thread):
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
audio_enabled = self.camera_config.audio.enabled
|
||||
if audio_enabled != self.was_audio_enabled:
|
||||
if not audio_enabled:
|
||||
self.logger.debug(
|
||||
f"Disabling audio detections for {self.camera_config.name}, ending events"
|
||||
)
|
||||
self.requestor.send_data(
|
||||
EXPIRE_AUDIO_ACTIVITY, self.camera_config.name
|
||||
)
|
||||
self.was_audio_enabled = audio_enabled
|
||||
|
||||
self.read_audio()
|
||||
|
||||
if self.audio_listener:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user