mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-06 14:52:47 +03:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab20815584 | ||
|
|
890512b054 | ||
|
|
a1fd978cab | ||
|
|
5de5cee3c6 | ||
|
|
e99eb77ca1 | ||
|
|
bb1e556ba9 | ||
|
|
4f0c1b8ee7 | ||
|
|
a2085e27f6 | ||
|
|
194e9bef69 | ||
|
|
9d109bfd12 | ||
|
|
dcda458a82 | ||
|
|
6c6683034e | ||
|
|
3ce3217db2 | ||
|
|
8a0c848914 | ||
|
|
fa4002cbe2 | ||
|
|
a94b532655 | ||
|
|
fec73c887e | ||
|
|
da135da0fb | ||
|
|
f5e398036e | ||
|
|
2dd700aa5a | ||
|
|
378fbec416 | ||
|
|
91a93167d2 | ||
|
|
dfe6428111 | ||
|
|
5e37b2c5c2 | ||
|
|
36607133e5 | ||
|
|
622fc97671 | ||
|
|
5d807587ab | ||
|
|
31bbf910c7 | ||
|
|
f0d7c1d7d4 | ||
|
|
a83219af56 | ||
|
|
4a2fb2f09c | ||
|
|
68893b28fc | ||
|
|
fbb904302c | ||
|
|
e425ab5f90 | ||
|
|
f486f7d57e | ||
|
|
c0adab1228 |
@@ -55,6 +55,7 @@ Dahua
|
||||
datasheet
|
||||
debconf
|
||||
deci
|
||||
deepstack
|
||||
defragment
|
||||
devcontainer
|
||||
DEVICEMAP
|
||||
|
||||
@@ -26,8 +26,8 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: Beta Version
|
||||
description: Visible on the System Metrics page in the Web UI. Please include the full version including the build identifier (eg. 0.19.0-beta1, 0.19.0-8b72c7a, etc.)
|
||||
placeholder: "0.19.0-beta1"
|
||||
description: Visible on the System Metrics page in the Web UI. Please include the full version including the build identifier (eg. 0.18.0-beta1, 0.18.0-8b72c7a, etc.)
|
||||
placeholder: "0.18.0-beta1"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -6,9 +6,7 @@ body:
|
||||
value: |
|
||||
Use this form to submit a reproducible bug in Frigate or Frigate's UI.
|
||||
|
||||
If you are running on Proxmox, please see the [Proxmox FAQ](https://github.com/blakeblackshear/frigate/discussions/23916) and reproduce the issue on a standard Docker install first (bare metal, or a VM running plain Debian/Ubuntu) before submitting here.
|
||||
|
||||
**⚠️ If you are running a beta version (0.19.0-beta or similar), please use the [Beta Support template](https://github.com/blakeblackshear/frigate/discussions/new?category=beta-support) instead.**
|
||||
**⚠️ If you are running a beta version (0.18.0-beta or similar), please use the [Beta Support template](https://github.com/blakeblackshear/frigate/discussions/new?category=beta-support) instead.**
|
||||
|
||||
Before submitting your bug report, please ask the AI with the "Ask AI" button on the [official documentation site][ai] about your issue, [search the discussions][discussions], look at recent open and closed [pull requests][prs], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your bug has already been fixed by the developers or reported by the community.
|
||||
|
||||
|
||||
+13
-138
@@ -23,7 +23,7 @@ jobs:
|
||||
name: AMD64 Build
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up QEMU and Buildx
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
- amd64_build
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up QEMU and Buildx
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
# directive user; stdout discarded because -t reopens the config's
|
||||
# /dev/stdout logs and the docker exec pipe is root-owned
|
||||
docker exec frigate /command/s6-setuidgid frigate bash -c '/usr/local/nginx/sbin/nginx -e stderr -t -c /tmp/nginx/conf/nginx.conf >/dev/null'
|
||||
docker exec frigate stat -c %a /config/tls/privkey.pem | grep -qx 600
|
||||
docker exec frigate stat -c %a /etc/letsencrypt/live/frigate/privkey.pem | grep -qx 600
|
||||
docker exec frigate stat -c %a /dev/shm/go2rtc.yaml | grep -qx 640
|
||||
- name: Assert services run as non-root
|
||||
run: |
|
||||
@@ -144,11 +144,9 @@ jobs:
|
||||
done'
|
||||
- name: Assert device access grants
|
||||
run: |
|
||||
# a fake accelerator node created after boot, then the oneshot re-run.
|
||||
# /command is on PATH only for s6-supervised services, and the
|
||||
# with-contenv shebang resolves its execline helpers through PATH
|
||||
# a fake accelerator node created after boot, then the oneshot re-run
|
||||
docker exec frigate mknod /dev/apex_9 c 120 99
|
||||
docker exec frigate sh -c 'export PATH=/command:$PATH; exec /etc/s6-overlay/s6-rc.d/init-devices/run'
|
||||
docker exec frigate /etc/s6-overlay/s6-rc.d/init-devices/run
|
||||
acl=$(docker exec frigate getfacl -p /dev/apex_9)
|
||||
echo "$acl"
|
||||
echo "$acl" | grep -q "user:frigate:rw-"
|
||||
@@ -156,7 +154,7 @@ jobs:
|
||||
# the usb tree gets recursive grants plus a default ACL that
|
||||
# newly created nodes inherit (the Coral re-enumeration path)
|
||||
docker exec frigate sh -c 'mkdir -p /dev/bus/usb/001 && mknod /dev/bus/usb/001/002 c 189 1'
|
||||
docker exec frigate sh -c 'export PATH=/command:$PATH; exec /etc/s6-overlay/s6-rc.d/init-devices/run'
|
||||
docker exec frigate /etc/s6-overlay/s6-rc.d/init-devices/run
|
||||
docker exec frigate getfacl -p /dev/bus/usb/001 | grep -q "user:frigate:rwx"
|
||||
docker exec frigate sh -c 'mknod /dev/bus/usb/001/099 c 189 98 && chmod 664 /dev/bus/usb/001/099'
|
||||
inherited=$(docker exec frigate getfacl -p /dev/bus/usb/001/099)
|
||||
@@ -173,7 +171,7 @@ jobs:
|
||||
# hardware that is absent must stay silent: the literal table entries
|
||||
# are not globs, so nullglob does not drop them and only an existence
|
||||
# check keeps them from warning on every boot
|
||||
out=$(docker exec frigate sh -c 'export PATH=/command:$PATH; exec /etc/s6-overlay/s6-rc.d/init-devices/run')
|
||||
out=$(docker exec frigate /etc/s6-overlay/s6-rc.d/init-devices/run)
|
||||
echo "$out"
|
||||
if echo "$out" | grep -q "WARN"; then
|
||||
echo "grant warned about device nodes that do not exist"; exit 1
|
||||
@@ -295,129 +293,6 @@ jobs:
|
||||
done
|
||||
if [ "$ok" -ne 1 ]; then echo "sentinel skip never logged"; docker logs frigate-puid; exit 1; fi
|
||||
docker rm -f frigate-puid
|
||||
- name: Assert read-only rootfs with --user works
|
||||
run: |
|
||||
mkdir -p /tmp/frigate-config-ro /tmp/frigate-media-ro
|
||||
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config-ro/config.yml
|
||||
sudo chown -R 1000:1000 /tmp/frigate-config-ro /tmp/frigate-media-ro
|
||||
# /run must allow exec: S6_READ_ONLY_ROOT has s6 copy its service
|
||||
# scripts there and run them, and --tmpfs defaults to noexec
|
||||
docker run -d --name frigate-ro --shm-size 256m \
|
||||
--read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755,uid=1000,gid=1000 \
|
||||
--user 1000:1000 \
|
||||
--security-opt no-new-privileges:true \
|
||||
-v /tmp/frigate-config-ro:/config \
|
||||
-v /tmp/frigate-media-ro:/media/frigate \
|
||||
${{ steps.setup.outputs.image-name }}-amd64
|
||||
up=0
|
||||
for i in $(seq 1 60); do
|
||||
docker exec frigate-ro curl -fs http://127.0.0.1:5000/api/version && up=1 && break
|
||||
sleep 5
|
||||
done
|
||||
if [ "$up" -ne 1 ]; then echo "read-only container never healthy"; docker logs frigate-ro; exit 1; fi
|
||||
# an if, not "! grep": bash exempts a negated command from set -e and
|
||||
# the assertion would never fail
|
||||
if docker logs frigate-ro 2>&1 | grep -i "read-only file system"; then
|
||||
echo "a service tried to write to the read-only rootfs"; exit 1
|
||||
fi
|
||||
# the self-signed cert has to land in /config, the only writable path
|
||||
docker exec frigate-ro test -f /config/tls/privkey.pem
|
||||
# and nginx must serve it, which is what proves the templated cert path
|
||||
docker exec frigate-ro curl -ksSI https://127.0.0.1:8971/ >/dev/null
|
||||
# logging must work via the s6-log fallback (no logutil-service as non-root)
|
||||
docker exec frigate-ro test -s /dev/shm/logs/frigate/current
|
||||
# runtime user can write recordings storage
|
||||
docker exec frigate-ro touch /media/frigate/.write-probe
|
||||
docker exec frigate-ro rm /media/frigate/.write-probe
|
||||
docker rm -f frigate-ro
|
||||
- name: Assert PUID with read-only fails fast with clear error
|
||||
run: |
|
||||
docker run -d --name frigate-ro-puid --shm-size 256m \
|
||||
--read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755 \
|
||||
-e PUID=1500 -e PGID=1500 \
|
||||
-v /tmp/frigate-config-ro:/config \
|
||||
${{ steps.setup.outputs.image-name }}-amd64
|
||||
found=0
|
||||
for i in $(seq 1 12); do
|
||||
if docker logs frigate-ro-puid 2>&1 | grep -q "not compatible with read_only"; then found=1; break; fi
|
||||
sleep 5
|
||||
done
|
||||
if [ "$found" -ne 1 ]; then
|
||||
echo "no fail-fast error for PUID with a read-only rootfs"; docker logs frigate-ro-puid; exit 1
|
||||
fi
|
||||
docker rm -f frigate-ro-puid
|
||||
- name: Assert EXTRA_GROUPS with read-only fails fast with clear error
|
||||
run: |
|
||||
docker run -d --name frigate-ro-groups --shm-size 256m \
|
||||
--read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755 \
|
||||
-e EXTRA_GROUPS=44 \
|
||||
-v /tmp/frigate-config-ro:/config \
|
||||
-v /tmp/frigate-media-ro:/media/frigate \
|
||||
${{ steps.setup.outputs.image-name }}-amd64
|
||||
found=0
|
||||
for i in $(seq 1 12); do
|
||||
if docker logs frigate-ro-groups 2>&1 | grep -q "EXTRA_GROUPS needs a writable /etc"; then found=1; break; fi
|
||||
sleep 5
|
||||
done
|
||||
if [ "$found" -ne 1 ]; then
|
||||
echo "no fail-fast error for EXTRA_GROUPS with a read-only rootfs"; docker logs frigate-ro-groups; exit 1
|
||||
fi
|
||||
docker rm -f frigate-ro-groups
|
||||
- name: Assert read-only rootfs in the default mode works
|
||||
run: |
|
||||
mkdir -p /tmp/frigate-config-rod /tmp/frigate-media-rod
|
||||
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config-rod/config.yml
|
||||
docker run -d --name frigate-rod --shm-size 256m \
|
||||
--read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755 \
|
||||
--security-opt no-new-privileges:true \
|
||||
-v /tmp/frigate-config-rod:/config \
|
||||
-v /tmp/frigate-media-rod:/media/frigate \
|
||||
${{ steps.setup.outputs.image-name }}-amd64
|
||||
up=0
|
||||
for i in $(seq 1 60); do
|
||||
docker exec frigate-rod curl -fs http://127.0.0.1:5000/api/version && up=1 && break
|
||||
sleep 5
|
||||
done
|
||||
if [ "$up" -ne 1 ]; then echo "read-only default-mode container never healthy"; docker logs frigate-rod; exit 1; fi
|
||||
if docker logs frigate-rod 2>&1 | grep -i "read-only file system"; then
|
||||
echo "a service tried to write to the read-only rootfs"; exit 1
|
||||
fi
|
||||
# the point of this mode over docker's user:: the drop still happens
|
||||
# and go2rtc still gets its own separate user
|
||||
ps_out=$(docker exec frigate-rod ps -eo user=,comm=)
|
||||
echo "$ps_out"
|
||||
for svc in python3 nginx; do
|
||||
if echo "$ps_out" | grep -w "$svc" | grep -q '^root'; then
|
||||
echo "$svc is running as root"; exit 1
|
||||
fi
|
||||
done
|
||||
echo "$ps_out" | grep -w go2rtc | grep -q '^go2rtc'
|
||||
# the ownership sweep still ran and recorded itself in /config
|
||||
docker exec frigate-rod cat /config/.permissions_version | grep -qx "2:1000:1000"
|
||||
# setfacl under a read-only rootfs, which nothing else covers:
|
||||
# init-devices exits early under --user, so that path is never reached
|
||||
docker exec frigate-rod mknod /dev/apex_9 c 120 99
|
||||
docker exec frigate-rod sh -c 'export PATH=/command:$PATH; exec /etc/s6-overlay/s6-rc.d/init-devices/run'
|
||||
docker exec frigate-rod getfacl -p /dev/apex_9 | grep -q "user:frigate:rw-"
|
||||
docker rm -f frigate-rod
|
||||
- name: "Assert switching that install to user: still starts"
|
||||
run: |
|
||||
# the config dir above now holds a go2rtc-owned go2rtc_homekit.yml,
|
||||
# which user: keeps readable but not writable (no supplementary groups)
|
||||
docker run -d --name frigate-rod-user --shm-size 256m \
|
||||
--read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755,uid=1000,gid=1000 \
|
||||
--user 1000:1000 \
|
||||
-v /tmp/frigate-config-rod:/config \
|
||||
-v /tmp/frigate-media-rod:/media/frigate \
|
||||
${{ steps.setup.outputs.image-name }}-amd64
|
||||
up=0
|
||||
for i in $(seq 1 60); do
|
||||
docker exec frigate-rod-user curl -fs http://127.0.0.1:5000/api/version && up=1 && break
|
||||
sleep 5
|
||||
done
|
||||
if [ "$up" -ne 1 ]; then echo "container did not survive the switch to user:"; docker logs frigate-rod-user; exit 1; fi
|
||||
docker logs frigate-rod-user 2>&1 | grep -q "HomeKit pairing changes will not persist"
|
||||
docker rm -f frigate-rod-user
|
||||
- name: Teardown
|
||||
if: always()
|
||||
run: docker rm -f frigate || true
|
||||
@@ -426,7 +301,7 @@ jobs:
|
||||
name: ARM Build
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up QEMU and Buildx
|
||||
@@ -461,7 +336,7 @@ jobs:
|
||||
name: Jetson Jetpack 6
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up QEMU and Buildx
|
||||
@@ -492,7 +367,7 @@ jobs:
|
||||
- amd64_build
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up QEMU and Buildx
|
||||
@@ -533,7 +408,7 @@ jobs:
|
||||
- arm64_build
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up QEMU and Buildx
|
||||
@@ -558,7 +433,7 @@ jobs:
|
||||
- arm64_build
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up QEMU and Buildx
|
||||
@@ -590,7 +465,7 @@ jobs:
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -16,10 +16,10 @@ jobs:
|
||||
name: Web - Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
- run: npm install
|
||||
@@ -35,10 +35,10 @@ jobs:
|
||||
name: Web - Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
- run: npm install
|
||||
@@ -46,15 +46,18 @@ jobs:
|
||||
- name: Build web
|
||||
run: npm run build
|
||||
working-directory: ./web
|
||||
# - name: Test
|
||||
# run: npm run test
|
||||
# working-directory: ./web
|
||||
|
||||
web_e2e:
|
||||
name: Web - E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
- run: npm install
|
||||
@@ -83,11 +86,11 @@ jobs:
|
||||
name: Python Checks
|
||||
steps:
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v7.0.0
|
||||
uses: actions/setup-python@v5.4.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Install requirements
|
||||
@@ -106,10 +109,10 @@ jobs:
|
||||
name: Python Tests
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Install devcontainer cli
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- id: lowercaseRepo
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -28,7 +28,3 @@ core
|
||||
docs/src/components/DockerComposeGenerator/config/devices.ts
|
||||
docs/src/components/DockerComposeGenerator/config/hardware.ts
|
||||
docs/src/components/DockerComposeGenerator/config/ports.ts
|
||||
|
||||
# GenAI review prompt tester local data (frames from real cameras)
|
||||
testing-scripts/genai-review-examples/*
|
||||
!testing-scripts/genai-review-examples/README.md
|
||||
|
||||
@@ -160,7 +160,7 @@ When reviewing code, do NOT comment on:
|
||||
|
||||
### Code Quality
|
||||
|
||||
- **Linting**: ESLint (see `web/eslint.config.js`)
|
||||
- **Linting**: ESLint (see `web/.eslintrc.cjs`)
|
||||
- **Formatting**: Prettier with Tailwind CSS plugin
|
||||
- **Type Safety**: TypeScript strict mode enabled
|
||||
|
||||
|
||||
+12
-10
@@ -202,6 +202,10 @@ RUN pip3 wheel --wheel-dir=/wheels -r /requirements-wheels.txt && \
|
||||
pip3 wheel --wheel-dir=/wheels -r /requirements-dev.txt; \
|
||||
fi
|
||||
|
||||
# Install HailoRT & Wheels
|
||||
RUN --mount=type=bind,source=docker/main/install_hailort.sh,target=/deps/install_hailort.sh \
|
||||
/deps/install_hailort.sh
|
||||
|
||||
# Collect deps in a single layer
|
||||
FROM scratch AS deps-rootfs
|
||||
COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/
|
||||
@@ -212,6 +216,7 @@ COPY --from=libusb-build /usr/local/lib /usr/local/lib
|
||||
COPY --from=tempio /rootfs/ /
|
||||
COPY --from=s6-overlay /rootfs/ /
|
||||
COPY --from=models /rootfs/ /
|
||||
COPY --from=wheels /rootfs/ /
|
||||
COPY docker/main/rootfs/ /
|
||||
|
||||
|
||||
@@ -289,12 +294,16 @@ RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
||||
RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \
|
||||
pip3 install -U /deps/wheels/*.whl
|
||||
|
||||
# The Hailo, MemryX, and Axera runtimes are installed at first start by
|
||||
# frigate/util/runtime_deps.py, only when that detector is configured.
|
||||
# Axera's native libraries are bind mounted from the host.
|
||||
# Install Axera Engine
|
||||
RUN pip3 install https://github.com/AXERA-TECH/pyaxengine/releases/download/0.1.3-frigate/axengine-0.1.3-py3-none-any.whl
|
||||
|
||||
ENV PATH="${PATH}:/usr/bin/axcl"
|
||||
ENV LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:/usr/lib/axcl"
|
||||
|
||||
# Install MemryX runtime (requires libgomp (OpenMP) in the final docker image)
|
||||
RUN --mount=type=bind,source=docker/main/install_memryx.sh,target=/deps/install_memryx.sh \
|
||||
bash -c "bash /deps/install_memryx.sh"
|
||||
|
||||
COPY --from=deps-rootfs / /
|
||||
|
||||
RUN ldconfig
|
||||
@@ -307,9 +316,6 @@ EXPOSE 8555/tcp 8555/udp
|
||||
ENV S6_LOGGING_SCRIPT="T 1 n0 s10000000 T"
|
||||
# Do not fail on long-running download scripts
|
||||
ENV S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0
|
||||
# Allow running with a read-only root filesystem: s6 copies its scan dir into
|
||||
# /run and executes service scripts from there, so /run must allow exec
|
||||
ENV S6_READ_ONLY_ROOT=1
|
||||
|
||||
ENTRYPOINT ["/init"]
|
||||
CMD []
|
||||
@@ -381,7 +387,3 @@ FROM deps AS frigate
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
|
||||
# Pre-compile bytecode so a read-only rootfs doesn't force re-parsing the
|
||||
# source tree on every boot (pip-installed packages are already compiled)
|
||||
RUN python3 -m compileall -q -j0 /opt/frigate/frigate
|
||||
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
hailo_version="4.21.0"
|
||||
|
||||
# sha256 digests of the release artifacts; update when bumping hailo_version.
|
||||
# The runtime tarball is keyed by TARGETARCH, the wheel by the python arch tag.
|
||||
declare -A hailort_checksums=(
|
||||
["amd64"]="0a57ac5f7cc8c2c3668133189d9285b55f498e8cb219797e203f6f5015fec4b3"
|
||||
["arm64"]="dd840548eb5d0d147c99aee2cb013d39d64be09c5bc63061171fcfacf4547b3f"
|
||||
["x86_64"]="8112a973ab48095399b29d883f31987828df5861b8553f614c89f098a67b3fb6"
|
||||
["aarch64"]="658432a43573280d472f6402d7934669effe7f163ba3dffa31c50bbeeaa7c01d"
|
||||
)
|
||||
|
||||
if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||
arch="x86_64"
|
||||
elif [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||
arch="aarch64"
|
||||
fi
|
||||
|
||||
# downloaded rather than streamed into tar because streaming and verifying the
|
||||
# digest before extraction are mutually exclusive
|
||||
wget -qO /tmp/hailort.tar.gz "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-debian12-${TARGETARCH}.tar.gz"
|
||||
echo "${hailort_checksums[${TARGETARCH}]} /tmp/hailort.tar.gz" | sha256sum -c -
|
||||
tar -C / -xzf /tmp/hailort.tar.gz
|
||||
rm -f /tmp/hailort.tar.gz
|
||||
|
||||
wheel="/wheels/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl"
|
||||
mkdir -p /wheels
|
||||
wget -qO "${wheel}" "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl"
|
||||
echo "${hailort_checksums[${arch}]} ${wheel}" | sha256sum -c -
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Download the MxAccl for Frigate github release
|
||||
wget https://github.com/memryx/mx_accl_frigate/archive/refs/tags/v2.1.0.zip -O /tmp/mxaccl.zip
|
||||
unzip /tmp/mxaccl.zip -d /tmp
|
||||
mv /tmp/mx_accl_frigate-2.1.0 /opt/mx_accl_frigate
|
||||
rm /tmp/mxaccl.zip
|
||||
|
||||
# Install Python dependencies
|
||||
pip3 install -r /opt/mx_accl_frigate/freeze
|
||||
|
||||
# Link the Python package dynamically
|
||||
SITE_PACKAGES=$(python3 -c "import site; print(site.getsitepackages()[0])")
|
||||
ln -s /opt/mx_accl_frigate/memryx "$SITE_PACKAGES/memryx"
|
||||
|
||||
# Copy architecture-specific shared libraries
|
||||
ARCH=$(uname -m)
|
||||
if [[ "$ARCH" == "x86_64" ]]; then
|
||||
cp /opt/mx_accl_frigate/memryx/x86/libmemx.so* /usr/lib/x86_64-linux-gnu/
|
||||
cp /opt/mx_accl_frigate/memryx/x86/libmx_accl.so* /usr/lib/x86_64-linux-gnu/
|
||||
elif [[ "$ARCH" == "aarch64" ]]; then
|
||||
cp /opt/mx_accl_frigate/memryx/arm/libmemx.so* /usr/lib/aarch64-linux-gnu/
|
||||
cp /opt/mx_accl_frigate/memryx/arm/libmx_accl.so* /usr/lib/aarch64-linux-gnu/
|
||||
else
|
||||
echo "Unsupported architecture: $ARCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Refresh linker cache
|
||||
ldconfig
|
||||
@@ -1,4 +1,4 @@
|
||||
ruff == 0.15.20
|
||||
|
||||
# types
|
||||
types-peewee == 4.0.*
|
||||
types-peewee == 3.17.*
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
aiofiles == 25.1.*
|
||||
click == 8.5.*
|
||||
aiofiles == 24.1.*
|
||||
click == 8.1.*
|
||||
# FastAPI
|
||||
aiohttp == 3.12.*
|
||||
starlette == 0.47.*
|
||||
starlette-context == 0.5.*
|
||||
starlette-context == 0.4.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
|
||||
uvicorn == 0.52.*
|
||||
uvicorn == 0.35.*
|
||||
slowapi == 0.1.*
|
||||
joserfc == 1.6.*
|
||||
cryptography == 46.0.*
|
||||
joserfc == 1.2.*
|
||||
cryptography == 44.0.*
|
||||
pathvalidate == 3.3.*
|
||||
markupsafe == 3.0.*
|
||||
python-multipart == 0.0.31
|
||||
python-multipart == 0.0.26
|
||||
# Classification Model Training
|
||||
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
||||
tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
|
||||
# General
|
||||
mypy == 2.3.1
|
||||
mypy == 1.6.1
|
||||
onvif-zeep-async == 4.0.*
|
||||
paho-mqtt == 2.1.*
|
||||
pandas == 2.2.*
|
||||
@@ -26,15 +26,15 @@ psutil == 7.1.*
|
||||
pydantic == 2.10.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
pytz == 2025.*
|
||||
pyzmq == 27.1.*
|
||||
pyzmq == 26.2.*
|
||||
ruamel.yaml == 0.18.*
|
||||
tzlocal == 5.2
|
||||
requests == 2.33.*
|
||||
requests == 2.32.*
|
||||
types-requests == 2.32.*
|
||||
norfair == 2.3.*
|
||||
setproctitle == 1.3.*
|
||||
ws4py == 0.5.*
|
||||
unidecode == 1.4.*
|
||||
unidecode == 1.3.*
|
||||
titlecase == 2.4.*
|
||||
# Image Manipulation
|
||||
numpy == 1.26.*
|
||||
@@ -51,26 +51,33 @@ google-genai == 1.58.*
|
||||
ollama == 0.6.*
|
||||
openai == 1.65.*
|
||||
# push notifications
|
||||
py-vapid == 1.9.4
|
||||
py-vapid == 1.9.*
|
||||
pywebpush == 2.0.*
|
||||
# alpr
|
||||
pyclipper == 1.4.*
|
||||
pyclipper == 1.3.*
|
||||
shapely == 2.0.*
|
||||
rapidfuzz==3.12.*
|
||||
# HailoRT
|
||||
# HailoRT Wheels
|
||||
appdirs==1.4.*
|
||||
argcomplete==2.0.*
|
||||
contextlib2==0.6.*
|
||||
distlib==0.3.*
|
||||
filelock==3.8.*
|
||||
future==0.18.*
|
||||
netaddr==1.3.*
|
||||
importlib-metadata==5.1.*
|
||||
importlib-resources==5.1.*
|
||||
netaddr==0.8.*
|
||||
netifaces==0.10.*
|
||||
prometheus-client == 0.26.*
|
||||
verboselogs==1.7.*
|
||||
virtualenv==20.17.*
|
||||
prometheus-client == 0.21.*
|
||||
# TFLite
|
||||
tflite_runtime @ https://github.com/frigate-nvr/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_x86_64.whl; platform_machine == 'x86_64'
|
||||
tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_aarch64.whl; platform_machine == 'aarch64'
|
||||
# audio transcription
|
||||
sherpa-onnx==1.13.*
|
||||
faster-whisper==1.2.*
|
||||
sherpa-onnx==1.12.*
|
||||
faster-whisper==1.1.*
|
||||
librosa==0.11.*
|
||||
soundfile==0.13.*
|
||||
# Memory profiling
|
||||
memray == 1.20.*
|
||||
memray == 1.15.*
|
||||
|
||||
@@ -26,15 +26,7 @@ function reload_nginx() {
|
||||
|
||||
echo "[INFO] Starting certsync..."
|
||||
|
||||
# Resolved once, and the condition must stay identical to the nginx run
|
||||
# script's. Testing only fullchain.pem here would pick the mounted cert on a
|
||||
# half-populated mount that nginx rejected, and the two fingerprints would then
|
||||
# never agree, reloading nginx every cycle forever.
|
||||
if [ -f /etc/letsencrypt/live/frigate/privkey.pem ] && [ -f /etc/letsencrypt/live/frigate/fullchain.pem ]; then
|
||||
lefile="/etc/letsencrypt/live/frigate/fullchain.pem"
|
||||
else
|
||||
lefile="/config/tls/fullchain.pem"
|
||||
fi
|
||||
lefile="/etc/letsencrypt/live/frigate/fullchain.pem"
|
||||
|
||||
tls_enabled=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled`
|
||||
listen_external_port=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port`
|
||||
|
||||
@@ -17,11 +17,6 @@ if [[ "$runs_as_root" -eq 0 ]]; then
|
||||
export HOME=/config
|
||||
fi
|
||||
|
||||
# detector runtimes installed at first start (pip install --user) live under
|
||||
# $HOME/.local; the dynamic loader only reads LD_LIBRARY_PATH at exec time
|
||||
export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+${LD_LIBRARY_PATH}:}${HOME}/.local/lib"
|
||||
export PATH="${PATH}:${HOME}/.local/bin"
|
||||
|
||||
# opt out of openvino telemetry
|
||||
if [ -e /usr/local/bin/opt_in_out ]; then
|
||||
/usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1
|
||||
|
||||
@@ -79,15 +79,6 @@ fi
|
||||
|
||||
# EXTRA_GROUPS: numeric host GIDs granting device access (e.g. host render/video)
|
||||
if [[ -n "${EXTRA_GROUPS:-}" ]]; then
|
||||
# groupadd and usermod -aG both write /etc/group. Checked up front so a
|
||||
# read-only rootfs reports the real problem instead of dying mid-loop.
|
||||
if [[ ! -w /etc/group ]]; then
|
||||
echo "[ERROR] EXTRA_GROUPS needs a writable /etc and is not compatible with read_only: true." >&2
|
||||
echo "[ERROR] Use docker's group_add: with the same GIDs instead; it needs no writes inside the container." >&2
|
||||
echo "[ERROR] See https://docs.frigate.video/configuration/non_root for the compatibility matrix." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for gid in ${EXTRA_GROUPS//,/ }; do
|
||||
if ! [[ "$gid" =~ ^[0-9]+$ ]] || [[ "$gid" -eq 0 ]]; then
|
||||
echo "[ERROR] EXTRA_GROUPS must be nonzero numeric GIDs, got '${gid}'" >&2
|
||||
|
||||
@@ -80,49 +80,22 @@ cp -r /usr/local/nginx/conf/. /tmp/nginx/conf/
|
||||
|
||||
set_worker_processes
|
||||
|
||||
# TLS certs: user-mounted certs at /etc/letsencrypt/live/frigate (documented
|
||||
# contract) always win; otherwise fall back to a self-signed cert persisted in
|
||||
# /config/tls, which stays writable under a read-only root filesystem.
|
||||
# ensure the directory for ACME challenges exists
|
||||
mkdir -p /etc/letsencrypt/www
|
||||
|
||||
# Create self signed certs if needed
|
||||
letsencrypt_path=/etc/letsencrypt/live/frigate
|
||||
selfsigned_path=/config/tls
|
||||
mkdir -p $letsencrypt_path
|
||||
|
||||
if [ -f "$letsencrypt_path/privkey.pem" ] && [ -f "$letsencrypt_path/fullchain.pem" ]; then
|
||||
cert_path="$letsencrypt_path"
|
||||
else
|
||||
cert_path="$selfsigned_path"
|
||||
|
||||
# Root writing into /config follows any symlink planted there, and /config
|
||||
# is owned by whoever the host mount says, not by root. Generate as the
|
||||
# runtime user wherever we are going to drop to it; the escape hatch keeps
|
||||
# root all the way through, so that path is refused rather than dropped.
|
||||
gen=()
|
||||
if [[ "$(id -u)" -eq 0 && "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then
|
||||
gen=(s6-setuidgid frigate)
|
||||
elif [[ "$(id -u)" -eq 0 ]]; then
|
||||
for link in "$cert_path" "$cert_path/privkey.pem" "$cert_path/fullchain.pem"; do
|
||||
if [[ -L "$link" ]]; then
|
||||
echo "[ERROR] ${link} is a symlink; refusing to write TLS material through it as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
"${gen[@]}" mkdir -p "$cert_path"
|
||||
|
||||
if [ ! \( -f "$cert_path/privkey.pem" -a -f "$cert_path/fullchain.pem" \) ]; then
|
||||
echo "[INFO] No TLS certificate found. Generating a self signed certificate..."
|
||||
"${gen[@]}" openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \
|
||||
-subj "/O=FRIGATE DEFAULT CERT/CN=*" \
|
||||
-keyout "$cert_path/privkey.pem" -out "$cert_path/fullchain.pem" 2>/dev/null
|
||||
"${gen[@]}" chmod 600 "$cert_path/privkey.pem"
|
||||
"${gen[@]}" chmod 644 "$cert_path/fullchain.pem"
|
||||
fi
|
||||
if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.pem" \) ]; then
|
||||
echo "[INFO] No TLS certificate found. Generating a self signed certificate..."
|
||||
openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \
|
||||
-subj "/O=FRIGATE DEFAULT CERT/CN=*" \
|
||||
-keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem" 2>/dev/null
|
||||
chmod 600 "$letsencrypt_path/privkey.pem"
|
||||
chmod 644 "$letsencrypt_path/fullchain.pem"
|
||||
fi
|
||||
|
||||
# ACME challenges are only served from a writable rootfs; skipping the mkdir
|
||||
# under read_only leaves the location 404ing, which is the same as unused
|
||||
mkdir -p /etc/letsencrypt/www 2>/dev/null || true
|
||||
|
||||
# nginx settings are read once; both templates consume them
|
||||
nginx_settings=$(python3 /usr/local/nginx/get_nginx_settings.py)
|
||||
|
||||
@@ -131,10 +104,8 @@ echo "$nginx_settings" | \
|
||||
tempio -template /usr/local/nginx/templates/base_path.gotmpl \
|
||||
-out /tmp/nginx/conf/base_path.conf
|
||||
|
||||
# build templates for additional network settings; listen.conf is the only
|
||||
# template that needs the resolved cert directory
|
||||
# build templates for additional network settings
|
||||
echo "$nginx_settings" | \
|
||||
jq --arg p "$cert_path" '.tls.cert_path = $p' | \
|
||||
tempio -template /usr/local/nginx/templates/listen.gotmpl \
|
||||
-out /tmp/nginx/conf/listen.conf
|
||||
|
||||
@@ -147,12 +118,9 @@ if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
|
||||
# nginx reopens /dev/stdout by path for its logs, and s6 made the pipe
|
||||
# root-owned 0600; without this the non-root master exits EACCES
|
||||
chown frigate /dev/stdout
|
||||
# Only mounted certs need handing over; the self-signed pair is already
|
||||
# owned by the runtime user that generated it. Never chown the /config copy:
|
||||
# chown follows symlinks, so it would retarget onto any root file the
|
||||
# runtime user pointed it at. Tolerant because mounted certs may be :ro.
|
||||
if [ "$cert_path" = "$letsencrypt_path" ] && [ -f "$cert_path/privkey.pem" ]; then
|
||||
chown frigate:frigate "$cert_path/privkey.pem" "$cert_path/fullchain.pem" 2>/dev/null || true
|
||||
# self-signed certs are root-generated; tolerant because mounted certs may be :ro
|
||||
if [ -f "$letsencrypt_path/privkey.pem" ]; then
|
||||
chown frigate:frigate "$letsencrypt_path/privkey.pem" "$letsencrypt_path/fullchain.pem" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -189,14 +189,7 @@ fi
|
||||
# Must stay after the sweep, which reads an absent /media/frigate as an
|
||||
# unmounted volume rather than a swept one
|
||||
if [[ "$(id -u)" -eq 0 && ! -d /media/frigate ]]; then
|
||||
# The image does not ship this directory, so on a read-only rootfs it can
|
||||
# only come from a mount. Report that rather than failing under errexit.
|
||||
if ! mkdir -p /media/frigate 2>/dev/null; then
|
||||
echo "[ERROR] /media/frigate does not exist and could not be created, which is what happens with read_only: true and no recordings volume." >&2
|
||||
echo "[ERROR] Mount a volume at /media/frigate." >&2
|
||||
echo "[ERROR] See https://docs.frigate.video/configuration/non_root for the compatibility matrix." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p /media/frigate
|
||||
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then
|
||||
chown "${PUID:-1000}:${PGID:-1000}" /media/frigate
|
||||
fi
|
||||
|
||||
@@ -66,17 +66,7 @@ def main() -> int:
|
||||
path = sys.argv[1]
|
||||
do_chown = "--chown" in sys.argv[2:]
|
||||
|
||||
try:
|
||||
fd = open_nofollow(path)
|
||||
except PermissionError:
|
||||
print(
|
||||
f"[WARN] {path} is not writable by uid {os.geteuid()}, so HomeKit "
|
||||
"pairing changes will not persist. It is owned by the go2rtc user "
|
||||
"from an earlier run in the default mode. To fix, on the host run: "
|
||||
f"chown {os.geteuid()}:{os.getegid()} <your config dir>/{os.path.basename(path)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
fd = open_nofollow(path)
|
||||
try:
|
||||
content = os.read(fd, MAX_BYTES).decode("utf-8", "replace")
|
||||
normalized = normalize(content)
|
||||
|
||||
@@ -166,9 +166,7 @@ http {
|
||||
include auth_request.conf;
|
||||
types {
|
||||
video/mp4 mp4;
|
||||
image/jpeg jpg jpeg;
|
||||
image/png png;
|
||||
image/webp webp;
|
||||
image/jpeg jpg;
|
||||
}
|
||||
|
||||
expires 7d;
|
||||
@@ -343,6 +341,13 @@ http {
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /fonts/ {
|
||||
access_log off;
|
||||
expires 1y;
|
||||
include security_headers.conf;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /locales/ {
|
||||
access_log off;
|
||||
include security_headers.conf;
|
||||
@@ -369,7 +374,7 @@ http {
|
||||
sub_filter '"/BASE_PATH/assets/' '"$http_x_ingress_path/assets/';
|
||||
sub_filter '"/BASE_PATH/locales/' '"$http_x_ingress_path/locales/';
|
||||
sub_filter '"/BASE_PATH/monacoeditorwork/' '"$http_x_ingress_path/assets/';
|
||||
sub_filter 'return`/BASE_PATH/`' 'return window.baseUrl';
|
||||
sub_filter 'return"/BASE_PATH/"' 'return window.baseUrl';
|
||||
sub_filter '<body>' '<body><script>window.baseUrl="$http_x_ingress_path/";</script>';
|
||||
sub_filter_types text/css application/javascript;
|
||||
sub_filter_once off;
|
||||
|
||||
@@ -8,8 +8,8 @@ listen {{ .listen.internal }};
|
||||
listen {{ .listen.external }} ssl;
|
||||
{{ if .ipv6.enabled }}listen [::]:{{ .listen.external_port }} ssl;{{ end }}
|
||||
|
||||
ssl_certificate {{ .tls.cert_path }}/fullchain.pem;
|
||||
ssl_certificate_key {{ .tls.cert_path }}/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem;
|
||||
|
||||
# generated 2024-06-01, Mozilla Guideline v5.7, nginx 1.25.3, OpenSSL 1.1.1w, modern configuration, no OCSP
|
||||
# https://ssl-config.mozilla.org/#server=nginx&version=1.25.3&config=modern&openssl=1.1.1w&ocsp=false&guideline=5.7
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Nvidia ONNX Runtime GPU Support
|
||||
--extra-index-url 'https://pypi.nvidia.com'
|
||||
cython==3.0.*; platform_machine == 'x86_64'
|
||||
nvidia-cuda-cupti-cu12==12.8.90; platform_machine == 'x86_64'
|
||||
nvidia-cublas-cu12==12.8.4.1; platform_machine == 'x86_64'
|
||||
nvidia-cudnn-cu12==9.8.0.87; platform_machine == 'x86_64'
|
||||
|
||||
@@ -36,13 +36,13 @@ edgeTPU:
|
||||
height: 320 # <--- should match the imgsize of the model, typically 320
|
||||
path: /config/model_cache/yolov9-s-relu6-best_320_int8_edgetpu.tflite
|
||||
labelmap_path: /config/labels-coco17.txt
|
||||
hailo:
|
||||
title: Hailo
|
||||
hailo8l:
|
||||
title: Hailo-8/Hailo-8L
|
||||
models:
|
||||
- key: yolo
|
||||
label: YOLO
|
||||
recommended: true
|
||||
download: If no custom model path or URL is provided, the Hailo detector automatically downloads the default model (YOLOv6n) from the Hailo Model Zoo on first startup, choosing the build that matches the attached device. Once cached under `/config/model_cache`, the model works fully offline.
|
||||
download: If no custom model path or URL is provided, the Hailo detector automatically downloads the default model (YOLOv6n) from the Hailo Model Zoo on first startup based on the detected hardware. Once cached under `/config/model_cache/hailo`, the model works fully offline.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detection models** and select **Hailo** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure the model settings:
|
||||
|
||||
@@ -60,7 +60,7 @@ hailo:
|
||||
yaml: |-
|
||||
models:
|
||||
- devices:
|
||||
- hailo:PCIe
|
||||
- hailo8l:PCIe
|
||||
width: 320
|
||||
height: 320
|
||||
input_tensor: nhwc
|
||||
@@ -101,7 +101,7 @@ hailo:
|
||||
yaml: |-
|
||||
models:
|
||||
- devices:
|
||||
- hailo:PCIe
|
||||
- hailo8l:PCIe
|
||||
width: 300
|
||||
height: 300
|
||||
input_tensor: nhwc
|
||||
@@ -824,6 +824,24 @@ cpu:
|
||||
models:
|
||||
- devices:
|
||||
- cpu:3
|
||||
deepstack:
|
||||
title: DeepStack / CodeProject.AI
|
||||
models:
|
||||
- key: yolo
|
||||
label: YOLO
|
||||
recommended: true
|
||||
download: This detector runs object detection over the network against a CodeProject.AI or DeepStack server, so no model is downloaded into Frigate itself. Visit the [CodeProject.AI official website](https://www.codeproject.com/Articles/5322557/CodeProject-AI-Server-AI-the-easy-way) to download and install the AI server on your preferred device (e.g. Raspberry Pi, Nvidia Jetson, or other compatible hardware) before configuring the detector.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detection models** and add a model. The CodeProject.AI server is not reported by the hardware probe, so set `devices` to `deepstack:http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection` in YAML.
|
||||
|
||||
| Field | Value |
|
||||
| ------------- | ---------------------------------------------------------------------- |
|
||||
| **API URL** | `http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection` |
|
||||
| **API Timeout** | `0.1` (seconds) |
|
||||
yaml: |-
|
||||
models:
|
||||
- devices:
|
||||
- deepstack:http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection
|
||||
memryx:
|
||||
title: MemryX
|
||||
models:
|
||||
@@ -1015,7 +1033,7 @@ synaptics:
|
||||
- key: ssd
|
||||
label: SSD MobileNet
|
||||
recommended: true
|
||||
download: A synap model is provided in the container at `/synaptics/mobilenet.synap` and is used by this detector type by default. The model comes from the [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80).
|
||||
download: A synap model is provided in the container at `/mobilenet.synap` and is used by this detector type by default. The model comes from the [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80).
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detection models** and select **Synaptics NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
|
||||
@@ -496,11 +496,6 @@ review:
|
||||
- Animals in the garden
|
||||
# Optional: Preferred response language (default: English)
|
||||
preferred_language: English
|
||||
# Optional: Writing style preset for generated descriptions (default: shown below)
|
||||
# Options: "default", "natural", "concise", "detailed"
|
||||
# Presets adjust the tone and level of detail of the user-facing title,
|
||||
# summary, and scene description; "default" leaves the built-in prompt unchanged.
|
||||
response_style: default
|
||||
# Optional: Save thumbnails sent to the GenAI provider for review/debugging purposes (default: shown below)
|
||||
debug_save_thumbnails: False
|
||||
|
||||
|
||||
@@ -216,9 +216,9 @@ A default role can be provided. Any value in the mapped `role` header will overr
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Proxy" /> and set the default role.
|
||||
|
||||
| Field | Description |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| **Default role** | Fallback role when no role header is present (e.g., `viewer`), or `None (deny access)` to reject unmapped users |
|
||||
| Field | Description |
|
||||
| ---------------- | ------------------------------------------------------------- |
|
||||
| **Default role** | Fallback role when no role header is present (e.g., `viewer`) |
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -232,14 +232,6 @@ proxy:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
Setting `default_role` to `none` denies access instead of falling back to a role. Any proxy-authenticated user whose headers do not match an explicit `role_map` entry receives a 403 response. This is useful when the upstream proxy authenticates a broader set of users than should reach Frigate, so that only mapped groups are allowed in.
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
...
|
||||
default_role: none
|
||||
```
|
||||
|
||||
## Role mapping
|
||||
|
||||
In some environments, upstream identity providers (OIDC, SAML, LDAP, etc.) do not pass a Frigate-compatible role directly, but instead pass one or more group claims. To handle this, Frigate supports a `role_map` that translates upstream group names into Frigate's internal roles (`admin`, `viewer`, or custom). This is configurable via YAML in the configuration file:
|
||||
@@ -265,7 +257,7 @@ In this example:
|
||||
- If the proxy passes a role header containing `sysadmins` or `access-level-security`, the user is assigned the `admin` role.
|
||||
- If the proxy passes a role header containing `camera-viewer`, the user is assigned the `viewer` role.
|
||||
- If the proxy passes a role header containing `operators`, the user is assigned the `operator` custom role.
|
||||
- If no mapping matches, Frigate falls back to `default_role` if configured, or denies access if `default_role` is `none`.
|
||||
- If no mapping matches, Frigate falls back to `default_role` if configured.
|
||||
- If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name.
|
||||
|
||||
**Note on matching semantics:**
|
||||
@@ -339,7 +331,7 @@ Frigate supports user roles to control access to certain features in the UI and
|
||||
|
||||
- **admin**: Full access to all features, including user management and configuration.
|
||||
- **viewer**: Read-only access to the UI and API, including viewing cameras, review items, and historical footage. Configuration editor and settings in the UI are inaccessible.
|
||||
- **Custom Roles**: Arbitrary role names (alphanumeric, dots/underscores) with specific camera permissions. These extend the system for granular access (e.g., "operator" for select cameras). The names `admin`, `viewer`, and `none` are reserved and cannot be used.
|
||||
- **Custom Roles**: Arbitrary role names (alphanumeric, dots/underscores) with specific camera permissions. These extend the system for granular access (e.g., "operator" for select cameras).
|
||||
|
||||
### Custom Roles and Camera Access
|
||||
|
||||
|
||||
@@ -517,6 +517,6 @@ objects:
|
||||
7. If descriptions are generated but the results are poor or inconsistent, look at the model and the context window.
|
||||
- Empty fields, missing `shortSummary` values, or `Failed to parse review description` errors usually mean the model is not following the requested JSON schema. Smaller models struggle with structured output. Try a larger parameter size or one of the [recommended models](#recommended-local-models).
|
||||
- Frigate calculates how many frames to send from the context size the provider reports. If your server reports a different value than it is actually running with, frames will be truncated or the request will fail. Pin the value by adding `context_size` under <NavPath path="Settings > Enrichments > Generative AI > Provider options" /> (`genai.<provider>.provider_options`), and for Ollama also confirm `options.num_ctx` there matches the context you have configured.
|
||||
- Check **Review Description Speed** and **Object Description Speed** in <NavPath path="Health and Metrics > Enrichments" />. If inference takes tens of seconds, requests will queue behind each other and descriptions will appear to stop. For Ollama, review `OLLAMA_NUM_PARALLEL`, `OLLAMA_MAX_QUEUE`, and `OLLAMA_MAX_LOADED_MODELS` so that concurrent requests from Frigate are handled the way you expect.
|
||||
- Check **Review Description Speed** and **Object Description Speed** in <NavPath path="System metrics > Enrichments" />. If inference takes tens of seconds, requests will queue behind each other and descriptions will appear to stop. For Ollama, review `OLLAMA_NUM_PARALLEL`, `OLLAMA_MAX_QUEUE`, and `OLLAMA_MAX_LOADED_MODELS` so that concurrent requests from Frigate are handled the way you expect.
|
||||
|
||||
</FaqItem>
|
||||
|
||||
@@ -192,39 +192,6 @@ review:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### Response Style
|
||||
|
||||
Different models respond to the built-in prompt with very different writing styles: some produce natural narration while others sound short and mechanical. The `response_style` option selects a writing style preset that rewords the prompt's instructions for the user-facing fields (the title, short summary, and scene description). Presets replace those instructions rather than adding extra ones, so the model never receives competing style directions.
|
||||
|
||||
Available presets:
|
||||
|
||||
- `default`: The built-in prompt, unchanged. This already reads like a neutral security report.
|
||||
- `natural`: Plain, everyday narration with flowing sentences and sentence-style headline titles. Useful when a model's output sounds robotic.
|
||||
- `concise`: As brief as possible while still covering each significant action, with terse two-to-four word titles.
|
||||
- `detailed`: Thorough descriptions and titles that include the most identifying specifics, like colors, clothing, and carried items.
|
||||
|
||||
Style presets only adjust how the user-facing text reads; the model's step-by-step observations and threat level scoring guidance are unaffected. Results vary by model, so it is worth comparing presets against saved debug output using `testing-scripts/genai_review_tester.py` in the Frigate repository.
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Review" />.
|
||||
|
||||
- Set **GenAI config > Response style** to the desired preset (e.g., `natural`)
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
```yaml {4}
|
||||
review:
|
||||
genai:
|
||||
enabled: true
|
||||
response_style: natural
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
## Review Reports
|
||||
|
||||
Along with individual review item summaries, Generative AI can also produce a single report of review items from all cameras marked "suspicious" over a specified time period (for example, a daily summary of suspicious activity while you're on vacation).
|
||||
|
||||
@@ -499,7 +499,7 @@ cameras:
|
||||
|
||||
## Synaptics
|
||||
|
||||
Hardware accelerated video de-/encoding is supported on Synaptics SL-series SoC.
|
||||
Hardware accelerated video de-/encoding is supported on Synpatics SL-series SoC.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
|
||||
@@ -697,7 +697,7 @@ lpr:
|
||||
- You may need to adjust your `detection_threshold` if your plates are not being detected.
|
||||
|
||||
4. Ensure the characters on detected plates are being _recognized_.
|
||||
- Check the **Plate recognition** inference time in Enrichment metrics (<NavPath path="Health and Metrics > Enrichments" />). High inference times (> 100ms) could lead to poor recognition results, especially for dedicated LPR cameras where the plate crosses the frame quickly.
|
||||
- Check the **Plate recognition** inference time in Enrichment metrics (<NavPath path="System metrics > Enrichments" />). High inference times (> 100ms) could lead to poor recognition results, especially for dedicated LPR cameras where the plate crosses the frame quickly.
|
||||
- Enable `debug_save_plates` to save images of detected text on plates to the clips directory (`/media/frigate/clips/lpr`). Ensure these images are readable and the text is clear.
|
||||
- Watch the debug view to see plates recognized in real-time. For non-dedicated LPR cameras, the vehicle's label will change to the recognized plate when LPR is enabled and working.
|
||||
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
|
||||
|
||||
@@ -15,13 +15,13 @@ Most upgrades need nothing. Frigate aligns your volume ownership on the first bo
|
||||
|
||||
| Mode | How to enable | Ownership of `/config` and `/media/frigate` | `read_only: true` |
|
||||
| ------------------- | ------------------------------- | ------------------------------------------------------ | ----------------- |
|
||||
| Default | nothing, this is the default | Aligned to `1000:1000` on first boot | Supported |
|
||||
| Default | nothing, this is the default | Aligned to `1000:1000` on first boot | Not supported |
|
||||
| `PUID`/`PGID` | `PUID=1001`, `PGID=1001` | Aligned to the values you set, on first boot | Not supported |
|
||||
| Docker-native user | `user: "1001:1001"` | You own it, Frigate never changes ownership | Supported |
|
||||
| Docker-native user | `user: "1001:1001"` | You own it, Frigate never changes ownership | Not supported |
|
||||
| Root (escape hatch) | `FRIGATE_RUN_AS_ROOT=true` | Never touched | Not supported |
|
||||
| Granular root | `FRIGATE_ROOT_SERVICES=frigate` | Aligned at boot; recordings and exports also at create | Not supported |
|
||||
|
||||
`PUID`/`PGID` remapping runs `usermod` at startup, which writes to `/etc/passwd`, so it can't work with a read-only root filesystem. That combination stops at startup with a message pointing here. `EXTRA_GROUPS` writes to `/etc/group` and stops the same way; use Docker's `group_add:` instead, which needs no writes inside the container. The default mode and Docker's `user:` mode both work with `read_only: true`; see [Hardened deployment](#hardened-deployment).
|
||||
`PUID`/`PGID` remapping runs `usermod` at startup, which writes to `/etc/passwd`, so it can't work with a read-only root filesystem. That combination stops at startup with a message pointing here.
|
||||
|
||||
`FRIGATE_RUN_AS_ROOT` is matched against the exact lowercase string `true`. `True`, `TRUE`, and `1` are all ignored. `FRIGATE_DEVICE_ACLS` works the same way: only the lowercase string `false` turns off the automatic device grants.
|
||||
|
||||
@@ -38,7 +38,7 @@ Try the device grants and `EXTRA_GROUPS` first. The `frigate` service runs the A
|
||||
|
||||
A listed service also stops honoring a [custom ffmpeg or go2rtc build](/configuration/advanced/system#custom-dependencies) kept in `/config`, since that directory stays owned by the unprivileged user and a binary there would run as root. `FRIGATE_RUN_AS_ROOT=true` has no such restriction.
|
||||
|
||||
Recordings and exports are owned by `PUID`/`PGID` as soon as they're written, even by a root service. Snapshots, thumbnails, and other files under `clips/` are corrected on each restart, so they can show as root-owned from the host until then. A listed service also keeps root's home directory, so library caches go to the container layer instead of `/config`. The same applies to the [detector runtimes](/frigate/network_requirements#detector-runtimes) Frigate installs at first start (Hailo, MemryX, AXEngine): a root `frigate` service installs them into `/root/.local`, which is lost when the container is recreated, and never loads a copy left behind in `/config/.local`.
|
||||
Recordings and exports are owned by `PUID`/`PGID` as soon as they're written, even by a root service. Snapshots, thumbnails, and other files under `clips/` are corrected on each restart, so they can show as root-owned from the host until then. A listed service also keeps root's home directory, so library caches go to the container layer instead of `/config`.
|
||||
|
||||
Listing all three services is not the same as `FRIGATE_RUN_AS_ROOT=true`. The escape hatch never touches ownership; the list keeps the ownership handling active. A few more details:
|
||||
|
||||
@@ -263,121 +263,6 @@ What each device needs when you're setting it up by hand. The automatic grant co
|
||||
| ZMQ detector | none | Nothing, inference happens over a socket |
|
||||
| Apple Silicon | none | Nothing, the NPU client runs on the host and Frigate reaches it over the network |
|
||||
|
||||
## Hardened deployment
|
||||
|
||||
A read-only root filesystem means the container can't modify itself, only the volumes you give it. It works in the default mode and under Docker's `user:`, but not with `PUID`/`PGID` or `EXTRA_GROUPS`, which both need to write to `/etc`.
|
||||
|
||||
Start with the default mode. It keeps go2rtc on its own restricted user and still grants your hardware automatically, at the cost of a short root startup that finishes before any service runs.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
container_name: frigate
|
||||
image: ghcr.io/blakeblackshear/frigate:stable
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
shm_size: "512mb" # size for your cameras, see the shm-size calculation
|
||||
devices:
|
||||
- /dev/dri/renderD128:/dev/dri/renderD128 # your hardware, granted at startup
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /path/to/your/config:/config
|
||||
- /path/to/your/storage:/media/frigate
|
||||
tmpfs:
|
||||
- /tmp:size=256m
|
||||
- /tmp/cache:size=1000000000 # recording segments, sized as before
|
||||
- /run:exec,nosuid,nodev,mode=0755,size=16m
|
||||
ports:
|
||||
- "8971:8971"
|
||||
- "8554:8554" # RTSP feeds
|
||||
- "8555:8555/tcp" # WebRTC over tcp
|
||||
- "8555:8555/udp" # WebRTC over udp
|
||||
```
|
||||
|
||||
`/run` has to allow `exec`. With a read-only root filesystem s6 copies its service scripts into `/run` and runs them from there, and tmpfs mounts default to `noexec`. The equivalent for `docker run` is `--tmpfs /run:exec,nosuid,nodev,mode=0755`. Spelling out `nosuid` and `nodev` matters: passing any tmpfs options replaces Docker's defaults instead of adjusting them, so asking for `exec` alone would drop those two as well.
|
||||
|
||||
Size `/tmp` deliberately. It now carries nginx's config copy and its five proxy temp directories as well as the recording cache. Keeping `/tmp/cache` as its own nested tmpfs, as above, leaves your existing [cache sizing](/frigate/installation#storage) untouched and adds a small allowance for nginx. If you'd rather use one tmpfs over all of `/tmp`, size it as your cache budget plus roughly 50MB, or recordings begin failing once the cache fills.
|
||||
|
||||
The self signed certificate is written to `/config/tls`, which stays writable. Certificates you mount at `/etc/letsencrypt/live/frigate` work unchanged and still take precedence.
|
||||
|
||||
[Detector runtimes](/frigate/network_requirements#detector-runtimes) that Frigate installs at first start (Hailo, MemryX, AXEngine) are staged in `/tmp` and installed into `/config/.local`, so they work with a read-only root filesystem in the default mode and under `user:`. A root `frigate` service installs into `/root/.local` instead, which a read-only root filesystem prevents; either leave `frigate` out of `FRIGATE_ROOT_SERVICES` or drop `read_only`.
|
||||
|
||||
Soak a hardened deployment for 24 hours against real cameras before relying on it. A read-only root filesystem turns an occasional write into a failure that startup won't reveal.
|
||||
|
||||
### Never starting as root
|
||||
|
||||
To remove root from the container entirely, add Docker's `user:`:
|
||||
|
||||
```yaml
|
||||
user: "1000:1000" # NOT compatible with PUID/PGID, see the run modes table
|
||||
tmpfs:
|
||||
- /tmp:size=256m
|
||||
- /tmp/cache:size=1000000000
|
||||
- /run:exec,nosuid,nodev,mode=0755,uid=1000,gid=1000,size=16m # uid must match user:
|
||||
```
|
||||
|
||||
`/run` has to be owned by that uid as well. s6 writes its runtime state there before anything else starts, and with no root in the container a root-owned `/run` stops it during init with `cannot create /run/test of writability`. Keep `uid` and `gid` in the tmpfs options matching `user:`, and don't carry that pair back into the default mode, where a root-owned `/run` is what keeps the unprivileged services out of s6's runtime state.
|
||||
|
||||
This only bites once root is genuinely gone. s6's init helper is setuid, so `user:` on its own still lets init regain root and correct `/run` itself. The `no-new-privileges:true` above is what blocks that, which is also what makes the `/run` ownership mandatory. Dropping it would hide the problem by handing init root again.
|
||||
|
||||
Two things change, and the first one will break a working install if you skip it. The startup device grants can't run, because there is no root left to run them, so every device you pass stops working until you grant that uid access yourself with `group_add:` or a udev rule; see [Manual setup](#manual-setup). Expect this to surface as a driver error rather than a permission error, like `No VA display found` from VAAPI. And every service then runs as that one uid, so go2rtc no longer gets its own restricted user. `/config` and `/media/frigate` have to be owned by that uid already, since Frigate never adjusts ownership in this mode. Switching an existing install over also leaves `/config/go2rtc_homekit.yml` owned by the go2rtc user, which this mode can't write; `chown` it to your uid or HomeKit pairing changes stop persisting. Frigate warns and starts either way.
|
||||
|
||||
This mode can also take `cap_drop: [ALL]`, which the default mode cannot: starting as root needs `CAP_CHOWN` for the ownership sweep, `CAP_SETUID` and `CAP_SETGID` to drop to the runtime user, and `CAP_FOWNER` for the device grants.
|
||||
|
||||
### Per-variant exceptions
|
||||
|
||||
- **Rockchip** needs `- /sys/:/sys/:ro` alongside its device nodes, in addition to everything above.
|
||||
- **MemryX** and **QNAP Container Station** still require `privileged: true` per their own documentation, which gives back most of what this layout removes. MemryX also downloads its models to `/memryx_models` on the root filesystem, so it can't run read-only regardless. Its SDK is installed into `/config/.local` like the other detector runtimes.
|
||||
|
||||
## Network isolation
|
||||
|
||||
Everything above limits what a compromised container can do to the host. It doesn't limit what your cameras can do to your network. Camera firmware is closed source, rarely patched, and not something you can audit, and none of it needs internet access for Frigate to work.
|
||||
|
||||
Put the cameras on their own VLAN or subnet, give the Frigate host a route into it, and deny that VLAN any route out. Frigate reaches in to pull streams, the cameras reach nothing. A second NIC on the Frigate host is the simplest version of this, and a tagged VLAN on the NIC you already have works just as well.
|
||||
|
||||
Here's the deny as nftables on the router, with cameras on `vlan20` and the Frigate host at `192.168.10.5`:
|
||||
|
||||
```
|
||||
table inet cameras {
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
|
||||
ct state established,related accept
|
||||
iifname "vlan20" ip daddr 192.168.10.5 accept
|
||||
iifname "vlan20" drop
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
It's in its own table so it can sit alongside an existing ruleset without touching it. Streams keep working because Frigate opens those connections and the return traffic is `established`. Cameras can still reach each other on their own VLAN, since that traffic never reaches the router, so use client isolation on the switch if that matters to you.
|
||||
|
||||
Two things break when you do this. The manufacturer's phone app stops working, which is the point, and camera clocks drift, because most of them set their time over NTP and are bad at it. Point them at an NTP server on your own network rather than opening the VLAN back up, or their timestamps and Frigate's will disagree.
|
||||
|
||||
Frigate itself needs some outbound access, though nearly all of it is optional. The startup version check is the only piece that's on by default, and `telemetry.version_check: false` turns it off. Everything else (model downloads for the enrichment features, push notifications, Frigate+, and cloud GenAI providers) only reaches out once you enable that feature. See [Network Requirements](/frigate/network_requirements) for the full list and how to run fully offline.
|
||||
|
||||
For containers that only talk to each other, an internal compose network gets you the same isolation without involving the router:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
networks: [default, iot]
|
||||
# the rest of your frigate service
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto
|
||||
networks: [iot]
|
||||
|
||||
networks:
|
||||
iot:
|
||||
internal: true
|
||||
```
|
||||
|
||||
`internal: true` gives that network no route off the host, so the broker isn't reachable from anywhere else on your LAN. Frigate sits on both networks and keeps its normal outbound path.
|
||||
|
||||
One Docker specific trap: published ports are inserted ahead of the host firewall, so `ufw deny 8971` doesn't do what it looks like it does. Bind the port to the interface you want instead, like `127.0.0.1:8971:8971` for a reverse proxy on the same host, or your LAN address for everything else.
|
||||
|
||||
## Known limitations
|
||||
|
||||
`telemetry.stats.network_bandwidth` uses nethogs, which needs `CAP_NET_ADMIN` and `CAP_NET_RAW` and therefore root. The stat is turned off automatically when Frigate isn't running as root, with one warning in the log. Use `FRIGATE_ROOT_SERVICES=frigate` (or `FRIGATE_RUN_AS_ROOT=true`) if you need it.
|
||||
|
||||
@@ -22,7 +22,7 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
**Most Hardware**
|
||||
|
||||
- [Coral EdgeTPU](#edge-tpu-detector): The Google Coral EdgeTPU is available in USB, Mini PCIe, and m.2 formats allowing for a wide range of compatibility with devices.
|
||||
- [Hailo](#hailo): The Hailo-8, Hailo-8L and Hailo-8R AI Acceleration modules are available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices.
|
||||
- [Hailo](#hailo-8): The Hailo8 and Hailo8L AI Acceleration module is available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices.
|
||||
- <CommunityBadge /> [MemryX](#memryx-mx3): The MX3 Acceleration module is available in m.2 format, offering broad compatibility across various platforms.
|
||||
|
||||
**AMD**
|
||||
@@ -134,7 +134,7 @@ Along with picking a detector for your hardware, you will choose a model's **inp
|
||||
|
||||
**Resolution (320x320 vs 640x640):** Frigate is optimized for `320x320` models, and `320x320` is the best choice for the vast majority of setups. Frigate is specifically designed to compensate for the smaller model by cropping a region of motion from the full frame and zooming into it before running detection, so a `320x320` model is actually _better_ at small and distant objects, not worse. A `640x640` model is slower and uses more resources, and its main benefit is fitting more objects into a single inference when many objects are spread across a large area. Recent versions of Frigate have improved support for `640x640` models, but `320x320` remains the recommended starting point for nearly all setups.
|
||||
|
||||
**Variant size (tiny/small/medium):** Larger variants are gradually more accurate but slower. Whether the difference is noticeable depends on your specific cameras and scenes. A good rule of thumb is to use the largest model your hardware can run without skipping detections, which you can monitor on the <NavPath path="Health and Metrics > Cameras" /> page in the UI. Better accuracy only helps if your detector keeps up with the detection load across all cameras.
|
||||
**Variant size (tiny/small/medium):** Larger variants are gradually more accurate but slower. Whether the difference is noticeable depends on your specific cameras and scenes. A good rule of thumb is to use the largest model your hardware can run without skipping detections, which you can monitor on the <NavPath path="System > Metrics > Cameras" /> page in the UI. Better accuracy only helps if your detector keeps up with the detection load across all cameras.
|
||||
|
||||
**Acceptable inference time depends on your hardware.** Inference time alone does not tell the whole story, because different hardware has different capacity. A GPU can run multiple instances of the same model concurrently, so an inference time around 30ms can still keep up with several cameras. A Google Coral runs only a single instance of the model, so it needs a much lower inference time (around 10ms) to keep up.
|
||||
|
||||
@@ -285,9 +285,9 @@ models:
|
||||
|
||||
---
|
||||
|
||||
## Hailo
|
||||
## Hailo-8
|
||||
|
||||
This detector is available for use with the Hailo-8, Hailo-8L and Hailo-8R AI Acceleration Modules. The integration identifies which of them is attached and selects the matching default model if no custom model is specified.
|
||||
This detector is available for use with both Hailo-8 and Hailo-8L AI Acceleration Modules. The integration automatically detects your hardware architecture via the Hailo CLI and selects the appropriate default model if no custom model is specified.
|
||||
|
||||
See the [installation docs](../frigate/installation.md#hailo-8) for information on configuring the Hailo hardware.
|
||||
|
||||
@@ -297,22 +297,16 @@ If no custom model is provided, the Hailo detector downloads a default model fro
|
||||
|
||||
:::
|
||||
|
||||
:::info
|
||||
|
||||
The HailoRT runtime is not part of the Frigate image. It is downloaded and installed into `/config/.local` the first time a Hailo detector is configured, verified against pinned checksums, and updated automatically when a Frigate release pins a new version. If the container has no internet access, see [Detector runtimes](/frigate/network_requirements#detector-runtimes) for how to provide the files yourself.
|
||||
|
||||
:::
|
||||
|
||||
### Configuration {#configuration-hailo}
|
||||
|
||||
When configuring the Hailo detector, you have two options to specify the model: a local **path** or a **URL**.
|
||||
If both are provided, the detector will first check for the model at the given local path. If the file is not found, it will download the model from the specified URL. The model file is cached under `/config/model_cache/hailo`.
|
||||
|
||||
<ModelConfigDropdown detectorTitle="Hailo" models={objectDetectorsModels.hailo.models} />
|
||||
<ModelConfigDropdown detectorTitle="Hailo-8/Hailo-8L" models={objectDetectorsModels.hailo8l.models} />
|
||||
|
||||
For additional ready-to-use models, please visit: https://github.com/hailo-ai/hailo_model_zoo
|
||||
|
||||
Hailo supports all models in the Hailo Model Zoo that include HailoRT post-processing. You're welcome to choose any of these pre-configured models for your implementation.
|
||||
Hailo8 supports all models in the Hailo Model Zoo that include HailoRT post-processing. You're welcome to choose any of these pre-configured models for your implementation.
|
||||
|
||||
> **Note:**
|
||||
> The config.path parameter can accept either a local file path or a URL ending with .hef. When provided, the detector will first check if the path is a local file path. If the file exists locally, it will use it directly. If the file is not found locally or if a URL was provided, it will attempt to download the model from the specified URL.
|
||||
@@ -362,7 +356,7 @@ Intel NPUs cannot be used under Home Assistant OS, which does not include the NP
|
||||
|
||||
:::warning
|
||||
|
||||
The Apple Silicon detector client is being reworked. Its extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now, and `request_timeout_ms` and `linger_ms` are ignored. Anything else is dropped when your config is migrated.
|
||||
The network-based detectors (Deepstack and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
|
||||
|
||||
:::
|
||||
|
||||
@@ -540,6 +534,30 @@ A TensorFlow Lite model is provided in the container at `/cpu_model.tflite` and
|
||||
|
||||
When using CPU detectors, you can add one CPU detector per camera. Adding more detectors than the number of cameras should not improve performance.
|
||||
|
||||
## Deepstack / CodeProject.AI Server Detector
|
||||
|
||||
:::warning
|
||||
|
||||
The network-based detectors (Deepstack and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
|
||||
|
||||
:::
|
||||
|
||||
The Deepstack / CodeProject.AI Server detector for Frigate allows you to integrate Deepstack and CodeProject.AI object detection capabilities into Frigate. CodeProject.AI and DeepStack are open-source AI platforms that can be run on various devices such as the Raspberry Pi, Nvidia Jetson, and other compatible hardware. It is important to note that the integration is performed over the network, so the inference times may not be as fast as native Frigate detectors, but it still provides an efficient and reliable solution for object detection and tracking.
|
||||
|
||||
### Setup {#setup-deepstack}
|
||||
|
||||
To get started with CodeProject.AI, visit their [official website](https://www.codeproject.com/Articles/5322557/CodeProject-AI-Server-AI-the-easy-way) to follow the instructions to download and install the AI server on your preferred device. Detailed setup instructions for CodeProject.AI are outside the scope of the Frigate documentation.
|
||||
|
||||
To integrate CodeProject.AI into Frigate, configure the detector as follows:
|
||||
|
||||
### Configuration {#configuration-deepstack}
|
||||
|
||||
<ModelConfigDropdown detectorTitle="DeepStack" models={objectDetectorsModels.deepstack.models} />
|
||||
|
||||
Replace `<your_codeproject_ai_server_ip>` and `<port>` with the IP address and port of your CodeProject.AI server.
|
||||
|
||||
To verify that the integration is working correctly, start Frigate and observe the logs for any error messages related to CodeProject.AI. Additionally, you can check the Frigate web interface to see if the objects detected by CodeProject.AI are being displayed and tracked properly.
|
||||
|
||||
# Community Supported Detectors
|
||||
|
||||
## MemryX MX3
|
||||
@@ -550,12 +568,6 @@ See the [installation docs](../frigate/installation.md#memryx-mx3) for informati
|
||||
|
||||
To configure a MemryX detector, simply set the `type` attribute to `memryx` and follow the configuration guide below.
|
||||
|
||||
:::info
|
||||
|
||||
The MemryX SDK is not part of the Frigate image. It is downloaded and installed into `/config/.local` the first time a MemryX detector is configured, verified against pinned checksums, and updated automatically when a Frigate release pins a new version. If the container has no internet access, see [Detector runtimes](/frigate/network_requirements#detector-runtimes) for how to provide the files yourself.
|
||||
|
||||
:::
|
||||
|
||||
### Configuration {#configuration-memryx}
|
||||
|
||||
<ModelConfigDropdown detectorTitle="MemryX" models={objectDetectorsModels.memryx.models} />
|
||||
@@ -816,12 +828,6 @@ The AXEngine detector downloads its default model from HuggingFace on first star
|
||||
|
||||
:::
|
||||
|
||||
:::info
|
||||
|
||||
The AXEngine python package is not part of the Frigate image. It is downloaded and installed into `/config/.local` the first time an AXEngine detector is configured, verified against a pinned checksum, and updated automatically when a Frigate release pins a new version. If the container has no internet access, see [Detector runtimes](/frigate/network_requirements#detector-runtimes) for how to provide the file yourself.
|
||||
|
||||
:::
|
||||
|
||||
### Configuration {#configuration-axengine}
|
||||
|
||||
When configuring the AXEngine detector, you have to specify the model name.
|
||||
|
||||
@@ -280,7 +280,6 @@ This configuration will retain recording segments that overlap with alerts and d
|
||||
In addition to the main recording stream, Frigate can record a second, lower quality stream for each camera. This serves two purposes:
|
||||
|
||||
- **Quality selection during playback**: A quality selector (`Auto`, `Original`, or `Low`) appears in History view for cameras with sub stream recording enabled. `Original` and `Low` play only that stream's recordings. Time ranges where the selected stream has no footage are skipped during playback, and the selector notes when the selected stream has no recordings at all in the viewed time range. With `Auto` (the default), playback prefers the original quality and automatically falls back to the low quality stream when the connection cannot keep up, or for time ranges where the original recordings have expired. The selector shows each stream's video codec and audio details beneath the options; footage recorded by older Frigate versions shows no details.
|
||||
- **Quality selection when exporting**: A `Quality` selector (`Auto`, `Original`, or `Low`) is available for cameras with sub stream recording enabled. See [exporting](#exporting-a-camera-that-records-two-streams) for details on each option.
|
||||
- **Extended retention**: Sub stream recordings have their own retention settings, fully independent of the main recordings. By giving the low quality recordings a longer retention period, you can keep weeks or months of low quality history using a fraction of the storage, and that history remains playable after the main recordings expire. Playback falls back to the low quality recordings automatically, and the timeline shows a muted treatment for time ranges where only low quality footage remains. Timeline previews are kept for as long as either stream still has recordings, so scrubbing works across the whole retained history.
|
||||
|
||||
### Configuring sub stream recording
|
||||
@@ -417,8 +416,7 @@ As a general rule, features that read recordings prefer the main stream and fall
|
||||
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Recording playback (History and Review) | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected manually |
|
||||
| Tracking details and Explore clip playback | Main, falling back to sub where the main recordings have expired |
|
||||
| Exports | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected in the export dialog |
|
||||
| Clip downloads | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) |
|
||||
| Exports and clip downloads | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) |
|
||||
| Frames grabbed from a recording in History (download snapshot, submit frame to Frigate+) | Main preferred, sub fallback |
|
||||
| Audio extraction (e.g., transcription) | Main preferred, sub fallback |
|
||||
| Motion search | Main only |
|
||||
@@ -450,7 +448,7 @@ For advanced use cases, the [custom export HTTP API](../integrations/api/export-
|
||||
POST /export/custom/{camera_name}/start/{start_time}/end/{end_time}
|
||||
```
|
||||
|
||||
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) with audio removed (`-an`). When providing your own `ffmpeg_input_args`, include `-an` if you want audio stripped from the export.
|
||||
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).
|
||||
|
||||
The following example exports a time-lapse at 60x speed with 25 FPS:
|
||||
|
||||
@@ -501,7 +499,7 @@ Media files (event snapshots, event thumbnails, review thumbnails, previews, exp
|
||||
|
||||
Normal operation may leave small numbers of orphaned files until Frigate's scheduled cleanup, but crashes, configuration changes, or upgrades may cause more orphaned files that Frigate does not clean up. This feature checks the file system for media files and removes any that are not referenced in the database.
|
||||
|
||||
The Maintenance pane in the Frigate UI or an API endpoint `POST /api/media/sync` can be used to trigger a media sync. When using the API, a job ID is returned and the operation continues on the server. Status can be checked with the `/api/media/sync/status/{job_id}` endpoint. Results include the disk space reclaimed, or with `dry_run: true`, the space that would be reclaimed.
|
||||
The Maintenance pane in the Frigate UI or an API endpoint `POST /api/media/sync` can be used to trigger a media sync. When using the API, a job ID is returned and the operation continues on the server. Status can be checked with the `/api/media/sync/status/{job_id}` endpoint.
|
||||
|
||||
Setting `verbose: true` writes a detailed report of every orphaned file and database entry to `/config/media_sync/<job_id>.txt`. For recordings, the report separates orphaned database entries (DB records whose files are missing from disk) from orphaned files (files on disk with no corresponding database record).
|
||||
|
||||
@@ -517,7 +515,7 @@ The storage usage Frigate reports will not exactly match what the operating syst
|
||||
|
||||
### How Frigate measures recording usage
|
||||
|
||||
The **Recordings** value on the Storage Metrics page (<NavPath path="Health and Metrics > Storage" />), and the per-camera **Camera Storage** breakdown, is the sum of the recording segment sizes Frigate has written, taken from Frigate's database. It is **not** computed by a scan of the disk. Frigate tracks usage this way by design: repeatedly walking the entire drive to total its size would keep hard drives spun up and add unnecessary I/O.
|
||||
The **Recordings** value on the Storage Metrics page (<NavPath path="System > Storage" />), and the per-camera **Camera Storage** breakdown, is the sum of the recording segment sizes Frigate has written, taken from Frigate's database. It is **not** computed by a scan of the disk. Frigate tracks usage this way by design: repeatedly walking the entire drive to total its size would keep hard drives spun up and add unnecessary I/O.
|
||||
|
||||
The disk **total** shown beside it, and the free-space figure Frigate uses to decide when to delete recordings, instead come from the operating system's report for the whole filesystem mounted at `/media/frigate`. As a result, the **Unused** value on the page is _total disk capacity minus Frigate's recordings_, not the drive's real free space, which will be lower whenever anything else is stored on the disk.
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ For cameras that support two-way talk, go2rtc will automatically establish an au
|
||||
To prevent this, you must configure two separate stream instances:
|
||||
|
||||
1. One stream instance with `#backchannel=0` for Frigate's viewing, recording, and detection (prevents go2rtc from establishing the blocking backchannel)
|
||||
2. A second stream instance with no `#` parameters at all for two-way talk functionality (can be used by Frigate's WebRTC viewer or other applications)
|
||||
2. A second stream instance without `#backchannel=0` for two-way talk functionality (can be used by Frigate's WebRTC viewer or other applications)
|
||||
|
||||
Configuration example:
|
||||
|
||||
@@ -215,8 +215,6 @@ In this configuration:
|
||||
- `front_door` stream is used by Frigate for viewing, recording, and detection. The `#backchannel=0` parameter prevents go2rtc from establishing the audio output backchannel, so it won't block two-way talk access.
|
||||
- `front_door_twoway` stream is used for two-way talk functionality. This stream can be used by Frigate's WebRTC viewer when two-way talk is enabled, or by other applications (like Home Assistant Advanced Camera Card) that need access to the camera's audio output channel.
|
||||
|
||||
Any `#` parameter on a bare `rtsp://` source disables the backchannel unless the URL explicitly contains `#backchannel=1`. A two-way talk stream with something like `#video=h264` on it silently loses two-way audio, and Frigate will report that two-way talk is unavailable for that stream.
|
||||
|
||||
## Security: Restricted Stream Sources
|
||||
|
||||
For security reasons, the `echo:`, `expr:`, and `exec:` stream sources are disabled by default in go2rtc. These sources allow arbitrary command execution and can pose security risks if misconfigured.
|
||||
|
||||
@@ -9,7 +9,7 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
# TLS
|
||||
|
||||
Frigate's integrated NGINX server supports TLS certificates. By default Frigate will generate a self signed certificate that will be used for port 8971, stored in `/config/tls` so it survives container recreation. Frigate is designed to make it easy to use whatever tool you prefer to manage certificates.
|
||||
Frigate's integrated NGINX server supports TLS certificates. By default Frigate will generate a self signed certificate that will be used for port 8971. Frigate is designed to make it easy to use whatever tool you prefer to manage certificates.
|
||||
|
||||
Frigate is often running behind a reverse proxy that manages TLS certificates for multiple services. You will likely need to set your reverse proxy to allow self signed certificates or you can disable TLS in Frigate's config. However, if you are running on a dedicated device that's separate from your proxy or if you expose Frigate directly to the internet, you may want to configure TLS with valid certificates.
|
||||
|
||||
@@ -45,9 +45,7 @@ frigate:
|
||||
...
|
||||
```
|
||||
|
||||
Within the folder, the private key is expected to be named `privkey.pem` and the certificate is expected to be named `fullchain.pem`. Mounted certificates take precedence over the self signed pair in `/config/tls`.
|
||||
|
||||
`privkey.pem` must be readable by the runtime user that runs NGINX. Frigate hands it over at startup when the mount is writable; on a `:ro` mount, make it readable by uid 1000 (or your `PUID`) yourself. See [Running as a non-root user](/configuration/non_root).
|
||||
Within the folder, the private key is expected to be named `privkey.pem` and the certificate is expected to be named `fullchain.pem`.
|
||||
|
||||
Note that certbot uses symlinks, and those can't be followed by the container unless it has access to the targets as well, so if using certbot you'll also have to mount the `archive` folder for your domain, e.g.:
|
||||
|
||||
@@ -61,7 +59,7 @@ frigate:
|
||||
|
||||
```
|
||||
|
||||
Frigate automatically compares the fingerprint of the certificate it loaded, from either location, against the fingerprint of the TLS cert in NGINX every minute. If these differ, the NGINX config is reloaded to pick up the updated certificate.
|
||||
Frigate automatically compares the fingerprint of the certificate at `/etc/letsencrypt/live/frigate/fullchain.pem` against the fingerprint of the TLS cert in NGINX every minute. If these differ, the NGINX config is reloaded to pick up the updated certificate.
|
||||
|
||||
If you issue Frigate valid certificates you will likely want to configure it to run on port 443 so you can access it without a port number like `https://your-frigate-domain.com` by mapping 8971 to 443.
|
||||
|
||||
@@ -75,4 +73,4 @@ frigate:
|
||||
|
||||
## ACME Challenge
|
||||
|
||||
Frigate also supports hosting the acme challenge files for the HTTP challenge method if needed. The challenge files should be mounted at `/etc/letsencrypt/www`. With a read-only root filesystem this has to be a mounted volume, since Frigate cannot create the directory itself.
|
||||
Frigate also supports hosting the acme challenge files for the HTTP challenge method if needed. The challenge files should be mounted at `/etc/letsencrypt/www`.
|
||||
|
||||
@@ -204,20 +204,11 @@ Light guidelines and advice:
|
||||
npm run lint
|
||||
```
|
||||
|
||||
- Ensure the backend [unit tests](#unit-tests) pass. Your PR cannot be merged unless tests pass.
|
||||
|
||||
```shell
|
||||
python3 -u -m unittest
|
||||
```
|
||||
|
||||
- Ensure the end-to-end tests pass. They run in Playwright against a production build with mocked API data, so they don't need a running Frigate instance. Add or update tests in `web/e2e/specs/` when you change UI behavior.
|
||||
- Add to unit tests and ensure they pass. As much as possible, you should strive to _increase_ test coverage whenever making changes. This will help ensure features do not accidentally become broken in the future.
|
||||
- If you run into error messages like "TypeError: Cannot read properties of undefined (reading 'context')" when running tests, this may be due to these issues (https://github.com/vitest-dev/vitest/issues/1910, https://github.com/vitest-dev/vitest/issues/1652) in vitest, but I haven't been able to resolve them.
|
||||
|
||||
```console
|
||||
# First-time setup
|
||||
npx playwright install chromium
|
||||
|
||||
# Build the app and run all tests
|
||||
npm run e2e:build && npm run e2e
|
||||
npm run test
|
||||
```
|
||||
|
||||
- Test in different browsers. Firefox, Chrome, and Safari all have different quirks that make them unique targets to interact with.
|
||||
|
||||
@@ -54,7 +54,7 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
|
||||
**Most Hardware**
|
||||
|
||||
- [Hailo](#hailo-8): The Hailo-8, Hailo-8L and Hailo-8R AI Acceleration modules are available in m.2 format with a HAT for RPi devices offering a wide range of compatibility with devices.
|
||||
- [Hailo](#hailo-8): The Hailo8 and Hailo8L AI Acceleration module is available in m.2 format with a HAT for RPi devices offering a wide range of compatibility with devices.
|
||||
- [Supports many model architectures](../../configuration/object_detectors#configuration-hailo)
|
||||
- Runs best with tiny or small size models
|
||||
|
||||
@@ -111,13 +111,12 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
|
||||
### Hailo-8
|
||||
|
||||
Frigate supports the Hailo-8, Hailo-8L and Hailo-8R AI Acceleration Modules on compatible hardware platforms, including the Raspberry Pi 5 with the PCIe hat from the AI kit. The Hailo detector integration in Frigate identifies which of them is attached and selects the matching default model when a custom model isn’t provided.
|
||||
Frigate supports both the Hailo-8 and Hailo-8L AI Acceleration Modules on compatible hardware platforms, including the Raspberry Pi 5 with the PCIe hat from the AI kit. The Hailo detector integration in Frigate automatically identifies your hardware type and selects the appropriate default model when a custom model isn’t provided.
|
||||
|
||||
**Default Model Configuration:**
|
||||
|
||||
- **Hailo-8L:** Default model is **YOLOv6n**, compiled for the Hailo-8L.
|
||||
- **Hailo-8:** Default model is **YOLOv6n**, compiled for the Hailo-8.
|
||||
- **Hailo-8R:** Default model is the **Hailo-8** build of **YOLOv6n**, since the Hailo Model Zoo publishes no Hailo-8R build.
|
||||
- **Hailo-8L:** Default model is **YOLOv6n**.
|
||||
- **Hailo-8:** Default model is **YOLOv6n**.
|
||||
|
||||
In real-world deployments, even with multiple cameras running concurrently, Frigate has demonstrated consistent performance. Testing on x86 platforms, with dual PCIe lanes, yields further improvements in FPS, throughput, and latency compared to the Raspberry Pi setup.
|
||||
|
||||
|
||||
@@ -122,9 +122,7 @@ Additionally, the USB Coral draws a considerable amount of power. If using any o
|
||||
|
||||
### Hailo-8
|
||||
|
||||
The Hailo-8, Hailo-8L and Hailo-8R AI accelerators are available in both M.2 and HAT form factors for the Raspberry Pi. The M.2 version typically connects to a carrier board for PCIe, which then interfaces with the Raspberry Pi 5 as part of the AI Kit. The HAT version can be mounted directly onto compatible Raspberry Pi models. Both form factors have been successfully tested on x86 platforms as well, making them versatile options for various computing environments.
|
||||
|
||||
The HailoRT runtime is not part of the Frigate image; Frigate downloads and installs it at first start once a Hailo detector is configured. Containers without internet access can provide the files themselves, see [Detector runtimes](/frigate/network_requirements#detector-runtimes).
|
||||
The Hailo-8 and Hailo-8L AI accelerators are available in both M.2 and HAT form factors for the Raspberry Pi. The M.2 version typically connects to a carrier board for PCIe, which then interfaces with the Raspberry Pi 5 as part of the AI Kit. The HAT version can be mounted directly onto compatible Raspberry Pi models. Both form factors have been successfully tested on x86 platforms as well, making them versatile options for various computing environments.
|
||||
|
||||
#### Installation
|
||||
|
||||
@@ -300,7 +298,7 @@ If you are using `docker run`, add this option to your command `--device /dev/ha
|
||||
|
||||
#### Configuration
|
||||
|
||||
Finally, configure [hardware object detection](/configuration/object_detectors#hailo) to complete the setup.
|
||||
Finally, configure [hardware object detection](/configuration/object_detectors#hailo-8) to complete the setup.
|
||||
|
||||
### MemryX MX3
|
||||
|
||||
@@ -317,8 +315,6 @@ The MemryX MX3 Accelerator is available in the M.2 2280 form factor (like an NVM
|
||||
|
||||
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).
|
||||
|
||||
The MemryX SDK used inside the container is not part of the Frigate image; Frigate downloads and installs it at first start once a MemryX detector is configured. Containers without internet access can provide the file themselves, see [Detector runtimes](/frigate/network_requirements#detector-runtimes). The host side driver still has to be installed as described below.
|
||||
|
||||
Then follow these steps for installing the correct driver/runtime configuration:
|
||||
|
||||
1. Copy or download [this script](https://github.com/blakeblackshear/frigate/blob/dev/docker/memryx/user_installation.sh).
|
||||
@@ -483,8 +479,6 @@ Follow these steps for installation:
|
||||
|
||||
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
|
||||
|
||||
The AXEngine python package is not part of the Frigate image; Frigate downloads and installs it at first start once an AXEngine detector is configured. Containers without internet access can provide the file themselves, see [Detector runtimes](/frigate/network_requirements#detector-runtimes).
|
||||
|
||||
Next, grant Docker permissions to access your hardware by adding the following lines to your `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
@@ -574,7 +568,7 @@ Platforms that genuinely require `privileged: true` (MemryX, some QNAP setups) a
|
||||
|
||||
:::
|
||||
|
||||
Frigate's services run as an unprivileged user inside the container. See [Running as a non-root user](../configuration/non_root.md) for the run modes, the one time volume ownership migration, what each accelerator needs on the host, and the [hardened deployment](../configuration/non_root.md#hardened-deployment) layout with a read-only root filesystem.
|
||||
Frigate's services run as an unprivileged user inside the container. See [Running as a non-root user](../configuration/non_root.md) for the run modes, the one time volume ownership migration, and what each accelerator needs on the host.
|
||||
|
||||
**Docker CLI**
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ If you are using one of the following hardware detectors and have not provided y
|
||||
| Detector | Model Downloaded | Source |
|
||||
| ------------------------------------------------------------------ | -------------------- | ------------------------ |
|
||||
| [Rockchip RKNN](/configuration/object_detectors#rockchip-platform) | RKNN detection model | GitHub |
|
||||
| [Hailo 8 / 8L / 8R](/configuration/object_detectors#hailo) | YOLOv6n (.hef) | Hailo Model Zoo (AWS S3) |
|
||||
| [Hailo 8 / 8L](/configuration/object_detectors#hailo-8) | YOLOv6n (.hef) | Hailo Model Zoo (AWS S3) |
|
||||
| [AXERA AXEngine](/configuration/object_detectors) | Detection model | HuggingFace |
|
||||
|
||||
:::note
|
||||
@@ -56,24 +56,6 @@ The default CPU, EdgeTPU, and OpenVINO object detection models are bundled into
|
||||
|
||||
:::
|
||||
|
||||
### Detector Runtimes
|
||||
|
||||
The SDKs for a few hardware detectors are not shipped in the Frigate image. They are downloaded the first time that detector is configured, verified against checksums pinned in the Frigate release, and installed into the Frigate user's home directory (`/config/.local` by default). Once installed they are not downloaded again until a Frigate release pins a new version.
|
||||
|
||||
| Detector | Version | Files | Source |
|
||||
| ---------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| [Hailo 8 / 8L / 8R](/configuration/object_detectors#hailo) | 4.21.0 | `hailort-debian12-amd64.tar.gz` and `hailort-4.21.0-cp311-cp311-linux_x86_64.whl` on x86, `hailort-debian12-arm64.tar.gz` and `hailort-4.21.0-cp311-cp311-linux_aarch64.whl` on arm64 | [GitHub release](https://github.com/frigate-nvr/hailort/releases/tag/v4.21.0) |
|
||||
| [MemryX MX3](/configuration/object_detectors#memryx-mx3) | 2.1.0 | `mx_accl_frigate-2.1.0.zip` (the release source archive, renamed) | [GitHub archive](https://github.com/memryx/mx_accl_frigate/archive/refs/tags/v2.1.0.zip) |
|
||||
| [AXERA AXEngine](/configuration/object_detectors#axera) | 0.1.3 | `axengine-0.1.3-py3-none-any.whl` | [GitHub release](https://github.com/AXERA-TECH/pyaxengine/releases/tag/0.1.3-frigate) |
|
||||
|
||||
If the container cannot reach GitHub, provide the files yourself:
|
||||
|
||||
1. Download the files for your architecture on a machine with internet access.
|
||||
2. Place them, with exactly the file names listed above, in `/config/model_cache/runtimes/<detector>/`, where `<detector>` is the detector named in your config's `devices` (`hailo`, `memryx`, or `axengine`).
|
||||
3. Start Frigate. Files whose checksum matches are installed without any download; a file with the wrong checksum is discarded and downloaded again, so a failed startup log names the file to replace.
|
||||
|
||||
The `GITHUB_ENDPOINT` mirror variable below applies to these downloads as well.
|
||||
|
||||
### 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:
|
||||
@@ -97,7 +79,7 @@ If your Frigate instance has restricted internet access, you can point model dow
|
||||
| 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, detector runtimes |
|
||||
| `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` | Unset (Keras uses its own default) | Custom classification training |
|
||||
|
||||
@@ -142,6 +124,10 @@ When [notifications](/configuration/notifications) are enabled and users have re
|
||||
|
||||
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:
|
||||
|
||||
@@ -60,7 +60,7 @@ If you’re running Frigate via Docker (recommended method), follow these steps:
|
||||
```bash
|
||||
docker logs frigate
|
||||
```
|
||||
- Visit the Frigate Web UI (default: `http://<your-ip>:5000`) to confirm the new version is running. The version number is displayed at the top of the Health and Metrics page.
|
||||
- Visit the Frigate Web UI (default: `http://<your-ip>:5000`) to confirm the new version is running. The version number is displayed at the top of the System Metrics page.
|
||||
|
||||
### Notes
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ Rockchip models are automatically converted as of 0.17. For 0.16, YOLOv9 onnx mo
|
||||
|
||||
## Supported detector types
|
||||
|
||||
Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVino (`openvino`), ONNX (`onnx`), Hailo (`hailo`), and Rockchip (`rknn`) detectors.
|
||||
Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVino (`openvino`), ONNX (`onnx`), Hailo (`hailo8l`), and Rockchip (`rknn`) detectors.
|
||||
|
||||
| Hardware | Recommended Detector Type | Recommended Model Type |
|
||||
| -------------------------------------------------------------------------------- | ------------------------- | ---------------------- |
|
||||
@@ -50,7 +50,7 @@ Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVi
|
||||
| [Intel](/configuration/object_detectors.md#openvino-detector) | `openvino` | `yolov9` |
|
||||
| [NVidia GPU](/configuration/object_detectors#onnx) | `onnx` | `yolov9` |
|
||||
| [AMD ROCm GPU](/configuration/object_detectors#amdrocm-gpu-detector) | `onnx` | `yolov9` |
|
||||
| [Hailo8/Hailo8L/Hailo8R](/configuration/object_detectors#hailo) | `hailo` | `yolov9` |
|
||||
| [Hailo8/Hailo8L/Hailo8R](/configuration/object_detectors#hailo-8) | `hailo8l` | `yolov9` |
|
||||
| [Rockchip NPU](/configuration/object_detectors#rockchip-platform) | `rknn` | `yolov9` |
|
||||
|
||||
## Improving your model
|
||||
|
||||
@@ -39,20 +39,6 @@ To do this efficiently the following setup is required:
|
||||
|
||||
When this is done correctly, the GPU will do the decoding and scaling which will result in a small increase in CPU usage but with better results.
|
||||
|
||||
### How can I rotate my camera's video feed?
|
||||
|
||||
Rotation is best done in the camera's firmware settings (usually called rotate, flip, or corridor mode) so the video arrives already rotated and no extra processing is needed. Check there first.
|
||||
|
||||
If your camera does not support rotation, go2rtc's ffmpeg module can rotate the stream with the `#rotate` parameter (`90`, `180`, `270`, or `-90`), but this is not recommended: rotation requires transcoding (re-encoding) the video, which significantly increases CPU usage, especially for high resolution streams.
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
my_camera: "ffmpeg:rtsp://user:password@192.168.1.10:554/stream#video=h264#hardware#rotate=90"
|
||||
```
|
||||
|
||||
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
|
||||
|
||||
### My mjpeg stream or snapshots look green and crazy
|
||||
|
||||
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
|
||||
@@ -135,7 +121,7 @@ You can still configure Frigate to use UDP by using ffmpeg input args or the pre
|
||||
|
||||
### 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 Health and Metrics.
|
||||
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:
|
||||
|
||||
@@ -181,9 +167,3 @@ Frigate's object detection relies on a machine learning [model](../frigate/gloss
|
||||
- If the false positive is always in the same fixed spot (like a statue or mailbox that reads as a person), add an [object filter mask](../configuration/masks.md#object-filter-masks) over that location.
|
||||
|
||||
Filters and masks only hide the incorrect result - they don't teach Frigate what the object actually is. For that, fine-tune your own model or use Frigate+.
|
||||
|
||||
### Where do I see problems Frigate has detected?
|
||||
|
||||
Open System > Health. The Notices list keeps a record of problems Frigate has found, and you can dismiss any entry to acknowledge it. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has undismissed entries. On mobile, tap the warning icon in the bottom navigation bar to see them.
|
||||
|
||||
The Hardware section below the notices shows whether the detection hardware, hardware acceleration, and enrichment devices in your config were found and are being used, so a GPU that silently fell back to the CPU shows up as a warning. Run stream checks to probe every camera's streams for the same problems the camera wizard reports.
|
||||
|
||||
@@ -15,7 +15,7 @@ When a stream won't play or behaves oddly, the most important first step is to f
|
||||
|
||||
### 1. Read the go2rtc logs
|
||||
|
||||
Access the go2rtc logs in the Frigate UI under <NavPath path="Logs" /> in the sidebar (select the **go2rtc** tab). If go2rtc cannot connect to your camera you will usually see a clear error here: `401 Unauthorized` (bad or incorrectly encoded credentials), `Connection refused` / `timeout` (wrong IP, port, or the camera is at its connection limit), or `404 Not Found` (wrong RTSP path, or the referenced stream name does not exist).
|
||||
Access the go2rtc logs in the Frigate UI under <NavPath path="System Logs" /> in the sidebar (select the **go2rtc** tab). If go2rtc cannot connect to your camera you will usually see a clear error here: `401 Unauthorized` (bad or incorrectly encoded credentials), `Connection refused` / `timeout` (wrong IP, port, or the camera is at its connection limit), or `404 Not Found` (wrong RTSP path, or the referenced stream name does not exist).
|
||||
|
||||
### 2. Test the stream in the go2rtc web interface
|
||||
|
||||
@@ -78,9 +78,7 @@ go2rtc:
|
||||
|
||||
:::warning
|
||||
|
||||
The transcoding modifiers (`#video=`, `#audio=`, `#hardware`, …) **only take effect on a source that is prefixed with `ffmpeg:`**. Adding them to a bare `rtsp://…#audio=opus` source does nothing: go2rtc ignores them. Likewise, when a source references another stream by name (e.g. `ffmpeg:back#audio=aac`), the name must match the stream key **exactly** (it is case sensitive), or the transcode is silently never produced. This is the single most common configuration mistake. In the Frigate UI, the **Use compatibility mode (ffmpeg)** toggle adds the `ffmpeg:` prefix for you.
|
||||
|
||||
A bare `rtsp://` source reads a different set of modifiers: `#backchannel=`, `#media=`, `#timeout=`, and `#transport=`. These do nothing on an `ffmpeg:` source. Adding **any** modifier to a bare `rtsp://` source also disables the camera's backchannel unless the URL explicitly contains `#backchannel=1`, so a stream dedicated to two-way talk should carry no modifiers at all.
|
||||
The `#`-modifiers (`#video=`, `#audio=`, `#hardware`, `#backchannel=0`, …) **only take effect on a source that is prefixed with `ffmpeg:`**. Adding them to a bare `rtsp://…#audio=opus` source does nothing: go2rtc ignores them. Likewise, when a source references another stream by name (e.g. `ffmpeg:back#audio=aac`), the name must match the stream key **exactly** (it is case sensitive), or the transcode is silently never produced. This is the single most common configuration mistake. In the Frigate UI, the **Use compatibility mode (ffmpeg)** toggle adds the `ffmpeg:` prefix for you.
|
||||
|
||||
:::
|
||||
|
||||
@@ -155,7 +153,7 @@ WebRTC is only attempted when MSE fails or when using a camera's two-way talk fe
|
||||
|
||||
- **Codec mismatch**: WebRTC cannot carry H.265 or AAC. The stream backing the WebRTC view must provide Opus (or PCMA/PCMU) audio and H.264 video. Add an `ffmpeg:back#audio=opus` source as shown above.
|
||||
- **Port `8555` not reachable, or no candidates set**: WebRTC needs port `8555` (both TCP and UDP) open and a reachable candidate advertised. On Docker installs running on a custom/overlay network, go2rtc may advertise unreachable container IPs as ICE candidates; setting `webrtc.filters.candidates: []` and supplying only your host's LAN IP resolves this. See [WebRTC extra configuration](/configuration/live#webrtc-extra-configuration).
|
||||
- **Two-way talk** additionally requires a secure context (HTTPS or the authenticated port `8971`, because browsers block microphone access on plain HTTP). The camera's RTSP backchannel must also be handled correctly: go2rtc seizes the backchannel by default, which blocks two-way audio for other consumers and can inject static. Disable it on the primary stream with `#backchannel=0` and use a separate dedicated stream for talk, carrying no `#` modifiers of any kind, as documented in [preventing go2rtc from blocking two-way audio](/configuration/restream#two-way-talk-restream).
|
||||
- **Two-way talk** additionally requires a secure context (HTTPS or the authenticated port `8971`, because browsers block microphone access on plain HTTP). The camera's RTSP backchannel must also be handled correctly: go2rtc seizes the backchannel by default, which blocks two-way audio for other consumers and can inject static. Disable it on the primary stream with `#backchannel=0` and use a separate dedicated stream for talk, as documented in [preventing go2rtc from blocking two-way audio](/configuration/restream#two-way-talk-restream).
|
||||
|
||||
## High CPU usage
|
||||
|
||||
|
||||
@@ -374,7 +374,7 @@ If segments are only ~1 second instead of ~10 seconds, the camera is sending cor
|
||||
|
||||
:::tip
|
||||
|
||||
You don't have to run `ffprobe` by hand to catch this. Open a camera's **Camera Probe Info** dialog (the info icon on the Health and Metrics → Cameras page) and check the **Keyframe analysis** section. It probes the record stream and flags sparse or variable keyframes, which is what smart/"+" codecs (H.264+/H.265+) and long keyframe intervals produce.
|
||||
You don't have to run `ffprobe` by hand to catch this. Open a camera's **Camera Probe Info** dialog (the info icon on the System → Metrics → Cameras page) and check the **Keyframe analysis** section. It probes the record stream and flags sparse or variable keyframes, which is what smart/"+" codecs (H.264+/H.265+) and long keyframe intervals produce.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
Generated
+3905
-2073
File diff suppressed because it is too large
Load Diff
+10
-10
@@ -18,17 +18,17 @@
|
||||
"write-heading-ids": "docusaurus write-heading-ids"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.10.2",
|
||||
"@docusaurus/plugin-content-docs": "^3.10.2",
|
||||
"@docusaurus/preset-classic": "^3.10.2",
|
||||
"@docusaurus/theme-mermaid": "^3.10.2",
|
||||
"@docusaurus/core": "^3.7.0",
|
||||
"@docusaurus/plugin-content-docs": "^3.7.0",
|
||||
"@docusaurus/preset-classic": "^3.7.0",
|
||||
"@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": "^5.2.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.2.0",
|
||||
"js-yaml": "^4.3.2",
|
||||
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
||||
"docusaurus-theme-openapi-docs": "^4.5.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"marked": "^16.4.2",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
@@ -48,11 +48,11 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.10.2",
|
||||
"@docusaurus/types": "^3.10.2",
|
||||
"@docusaurus/module-type-aliases": "^3.7.0",
|
||||
"@docusaurus/types": "^3.7.0",
|
||||
"@types/react": "^18.3.27"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19"
|
||||
"node": ">=18.0"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2
-236
@@ -62,9 +62,6 @@ paths:
|
||||
type: string
|
||||
'401':
|
||||
description: Authentication Failed
|
||||
'403':
|
||||
description: Access Denied (proxy user resolved to a default role of
|
||||
'none')
|
||||
security: []
|
||||
x-required-role: public
|
||||
/profile:
|
||||
@@ -2283,42 +2280,6 @@ paths:
|
||||
- frigateUserAuth: []
|
||||
x-required-role: camera
|
||||
description: '**Access:** Authenticated user with access to the referenced camera.'
|
||||
/review/{review_id}/regenerate_description:
|
||||
put:
|
||||
tags:
|
||||
- Review
|
||||
summary: Generate a review item description
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Re-runs a review item through the GenAI descriptions process.
|
||||
Frames are always taken from recordings, and both alerts and detections are
|
||||
accepted regardless of the camera's GenAI alerts/detections toggles.
|
||||
operationId:
|
||||
regenerate_review_description_review__review_id__regenerate_description_put
|
||||
parameters:
|
||||
- name: review_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Review 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'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/review/{review_id}/viewed:
|
||||
delete:
|
||||
tags:
|
||||
@@ -2518,25 +2479,6 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/genai/roles:
|
||||
get:
|
||||
tags:
|
||||
- App
|
||||
summary: Get the model assigned to each GenAI role
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Returns the selected model and its context size for each configured GenAI role. Reads only what the client saved when it initialized, so the provider is not queried for its model list.
|
||||
operationId: genai_roles_genai_roles_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/genai/probe:
|
||||
post:
|
||||
tags:
|
||||
@@ -4163,136 +4105,6 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices:
|
||||
get:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Get Notices
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get notices, most severe first.
|
||||
|
||||
Args:
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
|
||||
Returns:
|
||||
The notices
|
||||
operationId: get_notices_notices_get
|
||||
parameters:
|
||||
- name: include_dismissed
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
title: Include Dismissed
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/stats:
|
||||
get:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Get Notice Stats
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get lifetime occurrence counts per notice kind.
|
||||
operationId: get_notice_stats_notices_stats_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/dismissed_checks:
|
||||
get:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Get Dismissed Checks
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get the dismissed config and stream check rows, newest first.
|
||||
operationId: get_dismissed_checks_notices_dismissed_checks_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/dismissed:
|
||||
delete:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Purge Dismissed
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Delete every dismissed notice and check row so each can show again.
|
||||
operationId: purge_dismissed_notices_dismissed_delete
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/{notice_id}/dismiss:
|
||||
post:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Dismiss Notice
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Hide a notice or a config or stream check row.
|
||||
|
||||
It stays hidden if the same problem happens again.
|
||||
operationId: dismiss_notice_notices__notice_id__dismiss_post
|
||||
parameters:
|
||||
- name: notice_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Notice Id
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/events:
|
||||
get:
|
||||
tags:
|
||||
@@ -7382,17 +7194,13 @@ paths:
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: 'null'
|
||||
type: number
|
||||
title: After
|
||||
- name: before
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: 'null'
|
||||
type: number
|
||||
title: Before
|
||||
responses:
|
||||
'200':
|
||||
@@ -7766,14 +7574,6 @@ components:
|
||||
- type: 'null'
|
||||
title: New case description
|
||||
description: Optional description for a newly created export case
|
||||
stream:
|
||||
$ref: '#/components/schemas/ExportStreamEnum'
|
||||
title: Recorded stream to export
|
||||
description: Which recorded stream every item in the batch is exported
|
||||
from. 'auto' uses the merged timeline, preferring the main stream
|
||||
and falling back to the sub stream where main has aged out. 'main'
|
||||
or 'sub' pins the exports to that stream.
|
||||
default: auto
|
||||
type: object
|
||||
required:
|
||||
- items
|
||||
@@ -7955,18 +7755,6 @@ components:
|
||||
description: Per-request thinking toggle. None means use the provider
|
||||
default. Ignored by providers that do not expose a per-request
|
||||
thinking switch.
|
||||
tool_decisions:
|
||||
additionalProperties:
|
||||
type: string
|
||||
enum:
|
||||
- approve
|
||||
- reject
|
||||
type: object
|
||||
title: Tool Decisions
|
||||
description: Decisions for tool calls that paused for approval, keyed
|
||||
by tool call ID. Send these with the conversation chain returned
|
||||
alongside an approval request; rejected calls are reported to the
|
||||
model as declined instead of being executed.
|
||||
type: object
|
||||
required:
|
||||
- messages
|
||||
@@ -8711,14 +8499,6 @@ components:
|
||||
title: Chapter mode
|
||||
description: Optional chapter metadata to embed in the export. When
|
||||
omitted, the camera's configured export chapter mode is used.
|
||||
stream:
|
||||
$ref: '#/components/schemas/ExportStreamEnum'
|
||||
title: Recorded stream to export
|
||||
description: Which recorded stream to export. 'auto' uses the merged
|
||||
timeline, preferring the main stream and falling back to the sub
|
||||
stream where main has aged out. 'main' or 'sub' pins the export to
|
||||
that stream alone.
|
||||
default: auto
|
||||
type: object
|
||||
title: ExportRecordingsBody
|
||||
ExportRecordingsCustomBody:
|
||||
@@ -8773,20 +8553,6 @@ components:
|
||||
required:
|
||||
- name
|
||||
title: ExportRenameBody
|
||||
ExportStreamEnum:
|
||||
type: string
|
||||
enum:
|
||||
- auto
|
||||
- main
|
||||
- sub
|
||||
title: ExportStreamEnum
|
||||
description: |-
|
||||
Which recorded stream an export should be built from.
|
||||
|
||||
``auto`` keeps the merged timeline: main where it exists, sub filling
|
||||
the gaps main has already aged out of. Pinning to one stream trades
|
||||
that coverage for a uniform source, which is always a plain stream
|
||||
copy since nothing hands off mid-export.
|
||||
Extension:
|
||||
type: string
|
||||
enum:
|
||||
|
||||
+9
-16
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
@@ -10,6 +11,7 @@ import urllib
|
||||
from datetime import datetime, timedelta
|
||||
from functools import reduce
|
||||
from io import StringIO
|
||||
from pathlib import Path as FilePath
|
||||
from typing import Any
|
||||
|
||||
import aiofiles
|
||||
@@ -54,7 +56,6 @@ from frigate.jobs.media_sync import (
|
||||
start_media_sync_job,
|
||||
)
|
||||
from frigate.models import Event, Timeline
|
||||
from frigate.plus import load_plus_model_info
|
||||
from frigate.stats.prometheus import get_metrics, update_metrics
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.builtin import (
|
||||
@@ -189,20 +190,6 @@ def genai_models(request: Request):
|
||||
return JSONResponse(content=request.app.genai_manager.list_models())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/genai/roles",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get the model assigned to each GenAI role",
|
||||
description=(
|
||||
"Returns the selected model and its context size for each configured "
|
||||
"GenAI role. Reads only what the client saved when it initialized, so "
|
||||
"the provider is not queried for its model list."
|
||||
),
|
||||
)
|
||||
def genai_roles(request: Request):
|
||||
return JSONResponse(content=request.app.genai_manager.role_info())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/genai/probe",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
@@ -400,7 +387,13 @@ def config(request: Request):
|
||||
model_dict["plus"] = None
|
||||
|
||||
if model.path:
|
||||
model_dict["plus"] = load_plus_model_info(os.path.basename(model.path))
|
||||
model_json_path = FilePath(model.path).with_suffix(".json")
|
||||
|
||||
try:
|
||||
with open(model_json_path) as f:
|
||||
model_dict["plus"] = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
return JSONResponse(content=config)
|
||||
|
||||
|
||||
+3
-81
@@ -8,7 +8,6 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -35,7 +34,6 @@ from frigate.api.media_auth import (
|
||||
from frigate.config import AuthConfig, ProxyConfig
|
||||
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
||||
from frigate.models import User
|
||||
from frigate.notices import raise_notice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -255,58 +253,6 @@ class RateLimiter:
|
||||
|
||||
rateLimiter = RateLimiter()
|
||||
|
||||
# a failed login this long after the user's previous one opens a new burst
|
||||
FAILED_LOGIN_BURST_GAP_S = 300
|
||||
|
||||
# the username comes from the request, so it is cut before it reaches a notice
|
||||
MAX_NOTICE_USERNAME = 64
|
||||
|
||||
# unknown usernames are unbounded, so past this many open bursts a new one only
|
||||
# reaches the log
|
||||
MAX_OPEN_BURSTS = 100
|
||||
|
||||
|
||||
class FailedLoginTracker:
|
||||
"""Groups each user's failed logins into bursts, one notice per burst."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# user -> (burst start, last attempt), stalest attempt first
|
||||
self._bursts: dict[str, tuple[int, float]] = {}
|
||||
|
||||
def record(self, user: str, now: float, *, known: bool = False) -> None:
|
||||
"""Count a failed login toward the user's open burst, or open a new one.
|
||||
|
||||
Once MAX_OPEN_BURSTS are open, only a known user opens another.
|
||||
"""
|
||||
user = user[:MAX_NOTICE_USERNAME]
|
||||
|
||||
with self._lock:
|
||||
# bursts that went quiet are over
|
||||
while self._bursts:
|
||||
stalest = next(iter(self._bursts))
|
||||
|
||||
if now - self._bursts[stalest][1] < FAILED_LOGIN_BURST_GAP_S:
|
||||
break
|
||||
|
||||
del self._bursts[stalest]
|
||||
|
||||
if (
|
||||
not known
|
||||
and user not in self._bursts
|
||||
and len(self._bursts) >= MAX_OPEN_BURSTS
|
||||
):
|
||||
return
|
||||
|
||||
start, _ = self._bursts.pop(user, (int(now), now))
|
||||
self._bursts[user] = (start, now)
|
||||
|
||||
raise_notice("failed_login", scope=f"{user}:{start}", params={"user": user})
|
||||
|
||||
|
||||
failed_logins = FailedLoginTracker()
|
||||
|
||||
|
||||
def get_remote_addr(request: Request):
|
||||
# fall back to the direct TCP peer when no proxy chain is present
|
||||
@@ -551,7 +497,6 @@ def resolve_role(
|
||||
Admin matches short-circuit to admin.
|
||||
- If no role_map is configured, treat the header as role names directly.
|
||||
2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'.
|
||||
The literal value 'none' is a valid default and means access should be denied.
|
||||
|
||||
Args:
|
||||
headers (dict): Incoming request headers (case-insensitive).
|
||||
@@ -564,17 +509,10 @@ def resolve_role(
|
||||
default_role = proxy_config.default_role
|
||||
role_header = proxy_config.header_map.role
|
||||
|
||||
# Validate default_role against config; fallback to 'viewer' if invalid.
|
||||
# "none" is a sentinel meaning "deny access when no mapping matches"; it is
|
||||
# reserved in AuthConfig.validate_roles so it is never a configured role.
|
||||
validated_default = (
|
||||
default_role
|
||||
if default_role in config_roles or default_role == "none"
|
||||
else "viewer"
|
||||
)
|
||||
# Validate default_role against config; fallback to 'viewer' if invalid
|
||||
validated_default = default_role if default_role in config_roles else "viewer"
|
||||
if not config_roles:
|
||||
# Edge case: no roles defined
|
||||
validated_default = "none" if default_role == "none" else "viewer"
|
||||
validated_default = "viewer" # Edge case: no roles defined
|
||||
|
||||
if not role_header:
|
||||
logger.debug(
|
||||
@@ -679,9 +617,6 @@ def resolve_role(
|
||||
},
|
||||
},
|
||||
401: {"description": "Authentication Failed"},
|
||||
403: {
|
||||
"description": "Access Denied (proxy user resolved to a default role of 'none')"
|
||||
},
|
||||
},
|
||||
)
|
||||
def auth(request: Request):
|
||||
@@ -731,10 +666,6 @@ def auth(request: Request):
|
||||
config_roles_set = set(auth_config.roles.keys())
|
||||
role = resolve_role(request.headers, proxy_config, config_roles_set)
|
||||
|
||||
if role == "none":
|
||||
logger.debug("Resolved role is 'none', denying access")
|
||||
return Response("", status_code=403)
|
||||
|
||||
success_response.headers["remote-role"] = role
|
||||
|
||||
deny_status = deny_response_for_media_uri(original_url, role, frigate_config)
|
||||
@@ -786,13 +717,6 @@ def auth(request: Request):
|
||||
|
||||
user = token.claims.get("sub")
|
||||
role = token.claims.get("role")
|
||||
|
||||
# the token keeps the role it was issued with, so a role removed from
|
||||
# the config since then must send the user back through login
|
||||
if role not in auth_config.roles:
|
||||
logger.debug("jwt role %s is not in the config", role)
|
||||
return fail_response
|
||||
|
||||
current_time = int(time.time())
|
||||
|
||||
# if the jwt is expired
|
||||
@@ -941,7 +865,6 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
db_user: User = User.get_by_id(user)
|
||||
except DoesNotExist:
|
||||
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
|
||||
failed_logins.record(user, time.time())
|
||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||
|
||||
password_hash = db_user.password_hash
|
||||
@@ -973,7 +896,6 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
logger.warning(
|
||||
f"Login failed for user '{user}' (invalid password) from {remote_addr}"
|
||||
)
|
||||
failed_logins.record(user, time.time(), known=True)
|
||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||
|
||||
|
||||
|
||||
@@ -302,9 +302,7 @@ def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||
stderr_decoded = str(ffprobe.stderr)
|
||||
|
||||
stderr_lines = [
|
||||
clean_camera_user_pass(line.strip())
|
||||
for line in stderr_decoded.split("\n")
|
||||
if line.strip()
|
||||
line.strip() for line in stderr_decoded.split("\n") if line.strip()
|
||||
]
|
||||
|
||||
result = {
|
||||
@@ -1303,9 +1301,6 @@ async def delete_camera(
|
||||
if request.app.dispatcher is not None:
|
||||
request.app.dispatcher.clear_runtime_state_for_camera(camera_name)
|
||||
|
||||
if request.app.notice_registry is not None:
|
||||
request.app.notice_registry.resolve_camera(camera_name)
|
||||
|
||||
# Publish removal to stop ffmpeg processes and clean up runtime state
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, camera_name),
|
||||
|
||||
+160
-495
@@ -10,7 +10,6 @@ from functools import reduce
|
||||
from typing import Any, Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
@@ -24,7 +23,6 @@ from frigate.api.chat_util import (
|
||||
chunk_content,
|
||||
distance_to_score,
|
||||
format_events_with_local_time,
|
||||
format_local_time,
|
||||
fuse_scores,
|
||||
hydrate_event,
|
||||
parse_iso_to_timestamp,
|
||||
@@ -35,44 +33,29 @@ from frigate.api.defs.response.chat_response import (
|
||||
ChatCompletionResponse,
|
||||
ChatMessageResponse,
|
||||
ToolCall,
|
||||
ToolCallInvocation,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.api.event import _build_attribute_filter_clause, events
|
||||
from frigate.api.export import _build_export_job, _validate_export_source
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import SemanticSearchModelEnum
|
||||
from frigate.genai.prompts import (
|
||||
build_chat_system_prompt,
|
||||
get_attribute_classifications,
|
||||
get_tool_definitions,
|
||||
get_write_tool_names,
|
||||
strip_tool_access,
|
||||
)
|
||||
from frigate.genai.utils import (
|
||||
build_assistant_message_for_conversation,
|
||||
parse_tool_calls_from_message,
|
||||
)
|
||||
from frigate.jobs.export import ExportQueueFullError, start_export_job
|
||||
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, Export, ExportCase
|
||||
from frigate.record.export import PlaybackSourceEnum
|
||||
from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image
|
||||
from frigate.models import Event
|
||||
from frigate.util.object_names import get_categorized_object_names
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.chat])
|
||||
|
||||
# Tool result recorded for a rejected write tool call. Providers require a
|
||||
# result for every requested call; the user's intent is conveyed in a
|
||||
# follow-up user message built by _rejection_message.
|
||||
TOOL_REJECTED_RESULT: dict[str, str] = {"error": "user_rejected"}
|
||||
|
||||
|
||||
class ToolExecuteRequest(BaseModel):
|
||||
"""Request model for tool execution."""
|
||||
@@ -683,39 +666,29 @@ async def _get_live_frame_image_url(
|
||||
frame = frame_processor.get_current_frame(camera, {})
|
||||
if frame is None:
|
||||
return None
|
||||
return _encode_frame_data_url(frame)
|
||||
height, width = frame.shape[:2]
|
||||
target_height = 480
|
||||
if height > target_height:
|
||||
scale = target_height / height
|
||||
frame = cv2.resize(
|
||||
frame,
|
||||
(int(width * scale), target_height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
_, img_encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
b64 = base64.b64encode(img_encoded.tobytes()).decode("utf-8")
|
||||
return f"data:image/jpeg;base64,{b64}"
|
||||
except Exception as e:
|
||||
logger.debug("Failed to get live frame for %s: %s", camera, e)
|
||||
return None
|
||||
|
||||
|
||||
def _encode_frame_data_url(frame: np.ndarray, target_height: int = 480) -> str:
|
||||
"""Downscale a BGR frame and encode it as a JPEG data URL for the model."""
|
||||
height, width = frame.shape[:2]
|
||||
if height > target_height:
|
||||
scale = target_height / height
|
||||
frame = cv2.resize(
|
||||
frame,
|
||||
(int(width * scale), target_height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
_, img_encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
b64 = base64.b64encode(img_encoded.tobytes()).decode("utf-8")
|
||||
return f"data:image/jpeg;base64,{b64}"
|
||||
|
||||
|
||||
def _request_roles(request: Request) -> list[str]:
|
||||
"""Roles from the auth proxy header, split on the configured separator."""
|
||||
separator = request.app.frigate_config.proxy.separator
|
||||
header = request.headers.get("remote-role", "")
|
||||
return [r.strip() for r in header.split(separator) if r.strip()]
|
||||
|
||||
|
||||
async def _execute_set_camera_state(
|
||||
request: Request,
|
||||
arguments: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if "admin" not in _request_roles(request):
|
||||
role = request.headers.get("remote-role", "")
|
||||
if "admin" not in [r.strip() for r in role.split(",")]:
|
||||
return {"error": "Admin privileges required to change camera settings."}
|
||||
|
||||
camera = arguments.get("camera", "").strip()
|
||||
@@ -765,189 +738,6 @@ def _execute_get_categorized_object_names(
|
||||
return {"names": names}
|
||||
|
||||
|
||||
def _execute_get_export_cases(allowed_cameras: list[str]) -> dict[str, Any]:
|
||||
"""List export cases with how many accessible exports each one holds."""
|
||||
from peewee import fn
|
||||
|
||||
count_rows = (
|
||||
Export.select(Export.export_case, fn.COUNT(Export.id))
|
||||
.where(Export.camera << allowed_cameras, Export.export_case.is_null(False))
|
||||
.group_by(Export.export_case)
|
||||
.tuples()
|
||||
)
|
||||
counts = {case_id: count for case_id, count in count_rows}
|
||||
|
||||
cases: list[dict[str, Any]] = []
|
||||
for case in ExportCase.select().order_by(ExportCase.created_at.desc()):
|
||||
created_at = case.created_at
|
||||
cases.append(
|
||||
{
|
||||
"id": case.id,
|
||||
"name": case.name,
|
||||
"description": case.description,
|
||||
"created_at_local": format_local_time(created_at.timestamp())
|
||||
if isinstance(created_at, datetime)
|
||||
else str(created_at),
|
||||
"export_count": counts.get(case.id, 0),
|
||||
}
|
||||
)
|
||||
|
||||
if not cases:
|
||||
return {"cases": [], "message": "No export cases exist yet."}
|
||||
|
||||
return {"cases": cases}
|
||||
|
||||
|
||||
async def _execute_create_export(
|
||||
request: Request,
|
||||
arguments: dict[str, Any],
|
||||
allowed_cameras: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Queue a recording export, optionally attached to an existing case."""
|
||||
config = request.app.frigate_config
|
||||
camera = (arguments.get("camera") or "").strip()
|
||||
start_time = parse_iso_to_timestamp(arguments.get("start_time"))
|
||||
end_time = parse_iso_to_timestamp(arguments.get("end_time"))
|
||||
name = (arguments.get("name") or "").strip() or None
|
||||
|
||||
if not camera or start_time is None or end_time is None:
|
||||
return {"error": "camera, start_time, and end_time are all required."}
|
||||
|
||||
if camera not in config.cameras:
|
||||
return {"error": f"Camera '{camera}' not found."}
|
||||
|
||||
if camera not in allowed_cameras:
|
||||
return {"error": f"Camera '{camera}' not found or access denied"}
|
||||
|
||||
if end_time <= start_time:
|
||||
return {"error": "end_time must be after start_time."}
|
||||
|
||||
try:
|
||||
playback_source = PlaybackSourceEnum(arguments.get("source") or "recordings")
|
||||
except ValueError:
|
||||
return {"error": "source must be 'recordings' or 'preview'."}
|
||||
|
||||
# Mirror the export API: attaching to an existing case is admin-only
|
||||
# until case-level ACLs exist.
|
||||
export_case_id = (arguments.get("export_case_id") or "").strip() or None
|
||||
if export_case_id is not None:
|
||||
if "admin" not in _request_roles(request):
|
||||
return {"error": "Only admins can attach exports to an existing case."}
|
||||
try:
|
||||
ExportCase.get(ExportCase.id == export_case_id)
|
||||
except ExportCase.DoesNotExist:
|
||||
return {"error": f"Export case '{export_case_id}' not found."}
|
||||
|
||||
source_error = _validate_export_source(
|
||||
camera, start_time, end_time, playback_source
|
||||
)
|
||||
if source_error is not None:
|
||||
return {"error": source_error}
|
||||
|
||||
export_job = _build_export_job(
|
||||
camera,
|
||||
start_time,
|
||||
end_time,
|
||||
name,
|
||||
None,
|
||||
playback_source,
|
||||
export_case_id,
|
||||
chapters=config.cameras[camera].record.export.chapters,
|
||||
)
|
||||
try:
|
||||
start_export_job(config, export_job)
|
||||
except ExportQueueFullError:
|
||||
return {"error": "Export queue is full. Try again once current exports finish."}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"export_id": export_job.id,
|
||||
"status": "queued",
|
||||
"camera": camera,
|
||||
"name": name,
|
||||
"source": playback_source.value,
|
||||
"start_time_local": format_local_time(start_time),
|
||||
"end_time_local": format_local_time(end_time),
|
||||
"export_case_id": export_case_id,
|
||||
"message": "Export queued. It will appear on the Export page when finished.",
|
||||
}
|
||||
|
||||
|
||||
async def _execute_get_event_image(
|
||||
request: Request,
|
||||
arguments: dict[str, Any],
|
||||
allowed_cameras: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Attach an event's thumbnail or snapshot for a vision model to view."""
|
||||
event_id = (arguments.get("event_id") or "").strip()
|
||||
if not event_id:
|
||||
return {"error": "event_id is required."}
|
||||
|
||||
image_type = arguments.get("image") or "thumbnail"
|
||||
if image_type not in ("thumbnail", "snapshot"):
|
||||
return {"error": "image must be 'thumbnail' or 'snapshot'."}
|
||||
|
||||
try:
|
||||
event = Event.get(Event.id == event_id)
|
||||
except Event.DoesNotExist:
|
||||
return {"error": f"Could not find event {event_id}."}
|
||||
|
||||
if event.camera not in allowed_cameras:
|
||||
return {"error": f"Event {event_id} not found or access denied"}
|
||||
|
||||
chat_client = request.app.genai_manager.chat_client
|
||||
if chat_client is None or not chat_client.supports_vision:
|
||||
return {
|
||||
"error": (
|
||||
"The configured chat model does not support vision, so images "
|
||||
"cannot be viewed."
|
||||
)
|
||||
}
|
||||
|
||||
note = None
|
||||
frame = None
|
||||
if image_type == "snapshot":
|
||||
if event.has_snapshot:
|
||||
frame, _ = load_event_snapshot_image(event)
|
||||
if frame is None:
|
||||
note = "Snapshot not available; returning the thumbnail instead."
|
||||
image_type = "thumbnail"
|
||||
|
||||
if frame is None:
|
||||
thumbnail = get_event_thumbnail_bytes(event)
|
||||
if thumbnail:
|
||||
frame = cv2.imdecode(
|
||||
np.frombuffer(thumbnail, dtype=np.uint8), cv2.IMREAD_COLOR
|
||||
)
|
||||
|
||||
if frame is None:
|
||||
return {"error": f"No image is available for event {event_id}."}
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"id": event.id,
|
||||
"camera": event.camera,
|
||||
"label": event.label,
|
||||
"sub_label": event.sub_label,
|
||||
"zones": event.zones,
|
||||
"start_time_local": format_local_time(event.start_time),
|
||||
"image": image_type,
|
||||
}
|
||||
if event.end_time is not None:
|
||||
result["end_time_local"] = format_local_time(event.end_time)
|
||||
description = (event.data or {}).get("description")
|
||||
if description:
|
||||
result["description"] = description
|
||||
if note:
|
||||
result["note"] = note
|
||||
|
||||
result["_image_url"] = _encode_frame_data_url(frame)
|
||||
result["_image_text"] = (
|
||||
f"Here is the {image_type} for event {event.id} "
|
||||
f"({event.sub_label or event.label} on {event.camera})."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _execute_tool_internal(
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
@@ -1003,18 +793,11 @@ async def _execute_tool_internal(
|
||||
return _execute_get_profile_status(request)
|
||||
elif tool_name == "get_recap":
|
||||
return _execute_get_recap(arguments, allowed_cameras)
|
||||
elif tool_name == "get_export_cases":
|
||||
return _execute_get_export_cases(allowed_cameras)
|
||||
elif tool_name == "create_export":
|
||||
return await _execute_create_export(request, arguments, allowed_cameras)
|
||||
elif tool_name == "get_event_image":
|
||||
return await _execute_get_event_image(request, arguments, allowed_cameras)
|
||||
else:
|
||||
logger.error(
|
||||
"Tool call failed: unknown tool %r. Expected one of: search_objects, find_similar_objects, "
|
||||
"get_categorized_object_names, get_live_context, start_camera_watch, stop_camera_watch, "
|
||||
"get_profile_status, get_recap, get_export_cases, create_export, get_event_image. "
|
||||
"Arguments received: %s",
|
||||
"get_profile_status, get_recap. Arguments received: %s",
|
||||
tool_name,
|
||||
json.dumps(arguments),
|
||||
)
|
||||
@@ -1243,74 +1026,14 @@ def _execute_get_recap(
|
||||
return {"error": "Failed to fetch recap data."}
|
||||
|
||||
|
||||
def _pending_tool_calls_from_tail(
|
||||
conversation: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Return the tool calls of a trailing assistant message, if any.
|
||||
|
||||
A conversation that ends with an assistant message requesting tools is a
|
||||
resume after an approval pause: the client sends the chain back with its
|
||||
decisions and the loop runs those calls before asking the model again.
|
||||
"""
|
||||
if not conversation:
|
||||
return None
|
||||
tail = conversation[-1]
|
||||
if tail.get("role") != "assistant" or not tail.get("tool_calls"):
|
||||
return None
|
||||
return parse_tool_calls_from_message(tail)
|
||||
|
||||
|
||||
def _tool_calls_awaiting_approval(
|
||||
pending_tool_calls: list[dict[str, Any]],
|
||||
body: ChatCompletionRequest,
|
||||
write_tools: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the write tool calls the user still has to decide on."""
|
||||
return [
|
||||
{
|
||||
"id": tc["id"],
|
||||
"name": tc["name"],
|
||||
"arguments": tc.get("arguments") or {},
|
||||
}
|
||||
for tc in pending_tool_calls
|
||||
if tc["name"] in write_tools and tc["id"] not in body.tool_decisions
|
||||
]
|
||||
|
||||
|
||||
def _rejection_message(tool_names: list[str]) -> dict[str, Any]:
|
||||
"""User message telling the model a rejected call should not proceed.
|
||||
|
||||
Uses list-form content so the UI, which only renders string user
|
||||
content, does not show it as something the user typed.
|
||||
"""
|
||||
names = ", ".join(name.replace("_", " ") for name in tool_names)
|
||||
return {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": (
|
||||
f"I do not want to proceed with the {names} call. Ask me for "
|
||||
"clarification or suggest adjustments instead of running it."
|
||||
),
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _execute_pending_tools(
|
||||
pending_tool_calls: list[dict[str, Any]],
|
||||
request: Request,
|
||||
allowed_cameras: list[str],
|
||||
decisions: dict[str, str] | None = None,
|
||||
) -> tuple[list[ToolCall], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""
|
||||
Execute a list of tool calls.
|
||||
|
||||
Calls the user rejected (per `decisions`) are not executed; they get a
|
||||
placeholder result and a user message saying not to proceed is appended
|
||||
after the tool results.
|
||||
|
||||
Returns:
|
||||
(ToolCall list for API response,
|
||||
tool result dicts for conversation,
|
||||
@@ -1319,28 +1042,10 @@ async def _execute_pending_tools(
|
||||
tool_calls_out: list[ToolCall] = []
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
extra_messages: list[dict[str, Any]] = []
|
||||
rejected_tools: list[str] = []
|
||||
for tool_call in pending_tool_calls:
|
||||
tool_name = tool_call["name"]
|
||||
tool_args = tool_call.get("arguments") or {}
|
||||
tool_call_id = tool_call["id"]
|
||||
if decisions and decisions.get(tool_call_id) == "reject":
|
||||
logger.debug(
|
||||
"Tool %s (id: %s) was rejected by the user", tool_name, tool_call_id
|
||||
)
|
||||
rejected_tools.append(tool_name)
|
||||
rejected_content = json.dumps(TOOL_REJECTED_RESULT)
|
||||
tool_calls_out.append(
|
||||
ToolCall(name=tool_name, arguments=tool_args, response=rejected_content)
|
||||
)
|
||||
tool_results.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": rejected_content,
|
||||
}
|
||||
)
|
||||
continue
|
||||
logger.debug(
|
||||
f"Executing tool: {tool_name} (id: {tool_call_id}) with arguments: {json.dumps(tool_args, indent=2)}"
|
||||
)
|
||||
@@ -1374,21 +1079,17 @@ async def _execute_pending_tools(
|
||||
if isinstance(evt, dict)
|
||||
]
|
||||
|
||||
# Extract _image_url from tool results — images can only be sent
|
||||
# in user messages, not tool results
|
||||
# Extract _image_url from get_live_context results — images can
|
||||
# only be sent in user messages, not tool results
|
||||
if isinstance(tool_result, dict) and "_image_url" in tool_result:
|
||||
image_url = tool_result.pop("_image_url")
|
||||
image_text = tool_result.pop("_image_text", None) or (
|
||||
"Here is the current live image from camera "
|
||||
f"'{tool_result.get('camera', 'unknown')}'."
|
||||
)
|
||||
extra_messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": image_text,
|
||||
"text": f"Here is the current live image from camera '{tool_result.get('camera', 'unknown')}'.",
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
@@ -1432,8 +1133,6 @@ async def _execute_pending_tools(
|
||||
"content": error_content,
|
||||
}
|
||||
)
|
||||
if rejected_tools:
|
||||
extra_messages.append(_rejection_message(rejected_tools))
|
||||
return (tool_calls_out, tool_results, extra_messages)
|
||||
|
||||
|
||||
@@ -1480,8 +1179,6 @@ async def chat_completion(
|
||||
attribute_classifications=attribute_classifications,
|
||||
embeddings_language=_embeddings_language(config),
|
||||
)
|
||||
write_tools = get_write_tool_names(tools)
|
||||
llm_tools = strip_tool_access(tools)
|
||||
conversation = []
|
||||
|
||||
# Build the system message only when the client hasn't already pinned one.
|
||||
@@ -1520,10 +1217,6 @@ async def chat_completion(
|
||||
tool_calls: list[ToolCall] = []
|
||||
max_iterations = body.max_tool_iterations
|
||||
|
||||
# Resume after an approval pause: run the tail's tool calls (honoring the
|
||||
# client's decisions) before asking the model for anything new.
|
||||
resume_pending = _pending_tool_calls_from_tail(conversation)
|
||||
|
||||
logger.debug(
|
||||
f"Starting chat completion with {len(conversation)} message(s), "
|
||||
f"{len(tools)} tool(s) available, max_iterations={max_iterations}"
|
||||
@@ -1535,64 +1228,93 @@ async def chat_completion(
|
||||
|
||||
async def stream_body_llm():
|
||||
nonlocal conversation, stream_iterations
|
||||
pending: list[dict[str, Any]] | None = resume_pending
|
||||
|
||||
def _emit(payload: dict[str, Any]) -> bytes:
|
||||
return json.dumps(payload).encode("utf-8") + b"\n"
|
||||
|
||||
def _emit_chain(extra: list[dict[str, Any]] | None = None) -> bytes:
|
||||
def _emit_chain(extra: list[dict[str, Any]] | None = None):
|
||||
# Return the full conversation (including the system message) so
|
||||
# the client persists and replays it verbatim next turn.
|
||||
return _emit(
|
||||
{"type": "messages", "messages": conversation + (extra or [])}
|
||||
chain = conversation + (extra or [])
|
||||
return (
|
||||
json.dumps({"type": "messages", "messages": chain}).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
|
||||
while stream_iterations < max_iterations:
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected, stopping chat stream")
|
||||
return
|
||||
|
||||
if pending is None:
|
||||
logger.debug(
|
||||
f"Streaming LLM (iteration {stream_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s)"
|
||||
)
|
||||
async for event in genai_client.chat_with_tools_stream(
|
||||
messages=conversation,
|
||||
tools=llm_tools if llm_tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
):
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected, stopping chat stream")
|
||||
return
|
||||
kind, value = event
|
||||
if kind == "content_delta":
|
||||
yield _emit({"type": "content", "delta": value})
|
||||
elif kind == "reasoning_delta":
|
||||
yield _emit({"type": "reasoning", "delta": value})
|
||||
elif kind == "stats":
|
||||
yield _emit({"type": "stats", **value})
|
||||
elif kind == "message":
|
||||
msg = value
|
||||
if msg.get("finish_reason") == "error":
|
||||
yield _emit(
|
||||
logger.debug(
|
||||
f"Streaming LLM (iteration {stream_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s)"
|
||||
)
|
||||
async for event in genai_client.chat_with_tools_stream(
|
||||
messages=conversation,
|
||||
tools=tools if tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
):
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected, stopping chat stream")
|
||||
return
|
||||
kind, value = event
|
||||
if kind == "content_delta":
|
||||
yield (
|
||||
json.dumps({"type": "content", "delta": value}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
elif kind == "reasoning_delta":
|
||||
yield (
|
||||
json.dumps({"type": "reasoning", "delta": value}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
elif kind == "stats":
|
||||
yield (
|
||||
json.dumps({"type": "stats", **value}).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
elif kind == "message":
|
||||
msg = value
|
||||
if msg.get("finish_reason") == "error":
|
||||
yield (
|
||||
json.dumps(
|
||||
{
|
||||
"type": "error",
|
||||
"error": "An error occurred while processing your request.",
|
||||
}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
return
|
||||
pending = msg.get("tool_calls")
|
||||
if pending:
|
||||
stream_iterations += 1
|
||||
conversation.append(
|
||||
build_assistant_message_for_conversation(
|
||||
msg.get("content"), pending
|
||||
)
|
||||
)
|
||||
if await request.is_disconnected():
|
||||
logger.debug(
|
||||
"Client disconnected before tool execution"
|
||||
)
|
||||
return
|
||||
requested = msg.get("tool_calls")
|
||||
if requested:
|
||||
stream_iterations += 1
|
||||
conversation.append(
|
||||
build_assistant_message_for_conversation(
|
||||
msg.get("content"), requested
|
||||
)
|
||||
)
|
||||
pending = requested
|
||||
break
|
||||
(
|
||||
_executed_calls,
|
||||
tool_results,
|
||||
extra_msgs,
|
||||
) = await _execute_pending_tools(
|
||||
pending, request, allowed_cameras
|
||||
)
|
||||
conversation.extend(tool_results)
|
||||
conversation.extend(extra_msgs)
|
||||
# Emit the running chain so the client can render tool
|
||||
# calls live and replay them verbatim next turn.
|
||||
yield _emit_chain()
|
||||
break
|
||||
else:
|
||||
# Streaming never appends the final assistant message
|
||||
# to the conversation, so add it to the chain.
|
||||
yield _emit_chain(
|
||||
@@ -1603,41 +1325,11 @@ async def chat_completion(
|
||||
}
|
||||
]
|
||||
)
|
||||
yield _emit({"type": "done"})
|
||||
yield (json.dumps({"type": "done"}).encode("utf-8") + b"\n")
|
||||
return
|
||||
if pending is None:
|
||||
# The stream ended without a final message; nothing
|
||||
# more to run.
|
||||
break
|
||||
|
||||
awaiting = _tool_calls_awaiting_approval(pending, body, write_tools)
|
||||
if awaiting:
|
||||
# Pause before running write tools. The client shows the
|
||||
# calls, collects decisions, and resends the chain.
|
||||
yield _emit_chain()
|
||||
yield _emit({"type": "approval_required", "tool_calls": awaiting})
|
||||
yield _emit({"type": "done"})
|
||||
return
|
||||
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected before tool execution")
|
||||
return
|
||||
(
|
||||
_executed_calls,
|
||||
tool_results,
|
||||
extra_msgs,
|
||||
) = await _execute_pending_tools(
|
||||
pending, request, allowed_cameras, decisions=body.tool_decisions
|
||||
)
|
||||
conversation.extend(tool_results)
|
||||
conversation.extend(extra_msgs)
|
||||
pending = None
|
||||
# Emit the running chain so the client can render tool
|
||||
# calls live and replay them verbatim next turn.
|
||||
else:
|
||||
yield _emit_chain()
|
||||
|
||||
yield _emit_chain()
|
||||
yield _emit({"type": "done"})
|
||||
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_body_llm(),
|
||||
@@ -1646,129 +1338,102 @@ async def chat_completion(
|
||||
)
|
||||
|
||||
try:
|
||||
pending_tool_calls = resume_pending
|
||||
while tool_iterations < max_iterations:
|
||||
if pending_tool_calls is None:
|
||||
logger.debug(
|
||||
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s) in conversation"
|
||||
)
|
||||
response = genai_client.chat_with_tools(
|
||||
messages=conversation,
|
||||
tools=tools if tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
)
|
||||
|
||||
if response.get("finish_reason") == "error":
|
||||
logger.error("GenAI client returned an error")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"error": "An error occurred while processing your request.",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
conversation.append(
|
||||
build_assistant_message_for_conversation(
|
||||
response.get("content"), response.get("tool_calls")
|
||||
)
|
||||
)
|
||||
|
||||
pending_tool_calls = response.get("tool_calls")
|
||||
if not pending_tool_calls:
|
||||
logger.debug(
|
||||
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s) in conversation"
|
||||
)
|
||||
response = genai_client.chat_with_tools(
|
||||
messages=conversation,
|
||||
tools=llm_tools if llm_tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
f"Chat completion finished with final answer (iterations: {tool_iterations})"
|
||||
)
|
||||
final_content = response.get("content") or ""
|
||||
|
||||
if response.get("finish_reason") == "error":
|
||||
logger.error("GenAI client returned an error")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"error": "An error occurred while processing your request.",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
if body.stream:
|
||||
final_reasoning = response.get("reasoning")
|
||||
|
||||
conversation.append(
|
||||
build_assistant_message_for_conversation(
|
||||
response.get("content"), response.get("tool_calls")
|
||||
)
|
||||
)
|
||||
chain = list(conversation)
|
||||
|
||||
pending_tool_calls = response.get("tool_calls")
|
||||
if not pending_tool_calls:
|
||||
logger.debug(
|
||||
f"Chat completion finished with final answer (iterations: {tool_iterations})"
|
||||
)
|
||||
final_content = response.get("content") or ""
|
||||
|
||||
if body.stream:
|
||||
final_reasoning = response.get("reasoning")
|
||||
|
||||
chain = list(conversation)
|
||||
|
||||
async def stream_body() -> Any:
|
||||
async def stream_body() -> Any:
|
||||
yield (
|
||||
json.dumps({"type": "messages", "messages": chain}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
# Emit the full reasoning trace up front when the
|
||||
# underlying client did not stream it
|
||||
if final_reasoning:
|
||||
yield (
|
||||
json.dumps(
|
||||
{"type": "messages", "messages": chain}
|
||||
{"type": "reasoning", "delta": final_reasoning}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
# Emit the full reasoning trace up front when the
|
||||
# underlying client did not stream it
|
||||
if final_reasoning:
|
||||
yield (
|
||||
json.dumps(
|
||||
{"type": "reasoning", "delta": final_reasoning}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
# Stream content in word-sized chunks for smooth UX
|
||||
for part in chunk_content(final_content):
|
||||
yield (
|
||||
json.dumps({"type": "content", "delta": part}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
# Stream content in word-sized chunks for smooth UX
|
||||
for part in chunk_content(final_content):
|
||||
yield (
|
||||
json.dumps(
|
||||
{"type": "content", "delta": part}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
|
||||
+ b"\n"
|
||||
)
|
||||
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_body(),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content=ChatCompletionResponse(
|
||||
message=ChatMessageResponse(
|
||||
role="assistant",
|
||||
content=final_content,
|
||||
reasoning=response.get("reasoning"),
|
||||
tool_calls=None,
|
||||
),
|
||||
finish_reason=response.get("finish_reason", "stop"),
|
||||
tool_iterations=tool_iterations,
|
||||
tool_calls=tool_calls,
|
||||
messages=list(conversation),
|
||||
).model_dump(),
|
||||
return StreamingResponse(
|
||||
stream_body(),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
tool_iterations += 1
|
||||
logger.debug(
|
||||
f"Tool calls detected (iteration {tool_iterations}/{max_iterations}): "
|
||||
f"{len(pending_tool_calls)} tool(s) to execute"
|
||||
)
|
||||
|
||||
awaiting = _tool_calls_awaiting_approval(
|
||||
pending_tool_calls, body, write_tools
|
||||
)
|
||||
if awaiting:
|
||||
# Pause before running write tools; the client resends the
|
||||
# returned chain with its decisions to continue.
|
||||
return JSONResponse(
|
||||
content=ChatCompletionResponse(
|
||||
message=ChatMessageResponse(
|
||||
role="assistant",
|
||||
content=None,
|
||||
tool_calls=[ToolCallInvocation(**tc) for tc in awaiting],
|
||||
content=final_content,
|
||||
reasoning=response.get("reasoning"),
|
||||
tool_calls=None,
|
||||
),
|
||||
finish_reason="approval_required",
|
||||
finish_reason=response.get("finish_reason", "stop"),
|
||||
tool_iterations=tool_iterations,
|
||||
tool_calls=tool_calls,
|
||||
messages=list(conversation),
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
tool_iterations += 1
|
||||
logger.debug(
|
||||
f"Tool calls detected (iteration {tool_iterations}/{max_iterations}): "
|
||||
f"{len(pending_tool_calls)} tool(s) to execute"
|
||||
)
|
||||
executed_calls, tool_results, extra_msgs = await _execute_pending_tools(
|
||||
pending_tool_calls,
|
||||
request,
|
||||
allowed_cameras,
|
||||
decisions=body.tool_decisions,
|
||||
pending_tool_calls, request, allowed_cameras
|
||||
)
|
||||
tool_calls.extend(executed_calls)
|
||||
conversation.extend(tool_results)
|
||||
conversation.extend(extra_msgs)
|
||||
pending_tool_calls = None
|
||||
logger.debug(
|
||||
f"Added {len(tool_results)} tool result(s) to conversation. "
|
||||
f"Continuing with next LLM call..."
|
||||
|
||||
@@ -44,11 +44,6 @@ def chunk_content(content: str, chunk_size: int = 80) -> Generator[str, None, No
|
||||
yield " ".join(current)
|
||||
|
||||
|
||||
def format_local_time(timestamp: float) -> str:
|
||||
"""Format a unix timestamp as the server-local string quoted to users."""
|
||||
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
|
||||
|
||||
def format_events_with_local_time(
|
||||
events_list: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -63,9 +58,11 @@ def format_events_with_local_time(
|
||||
start_ts = evt.get("start_time")
|
||||
end_ts = evt.get("end_time")
|
||||
if start_ts is not None:
|
||||
copy_evt["start_time_local"] = format_local_time(start_ts)
|
||||
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:
|
||||
copy_evt["end_time_local"] = format_local_time(end_ts)
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from frigate.record.export import ExportStreamEnum
|
||||
|
||||
MAX_BATCH_EXPORT_ITEMS = 50
|
||||
|
||||
|
||||
@@ -55,16 +53,6 @@ class BatchExportBody(BaseModel):
|
||||
title="New case description",
|
||||
description="Optional description for a newly created export case",
|
||||
)
|
||||
stream: ExportStreamEnum = Field(
|
||||
default=ExportStreamEnum.auto,
|
||||
title="Recorded stream to export",
|
||||
description=(
|
||||
"Which recorded stream every item in the batch is exported "
|
||||
"from. 'auto' uses the merged timeline, preferring the main "
|
||||
"stream and falling back to the sub stream where main has "
|
||||
"aged out. 'main' or 'sub' pins the exports to that stream."
|
||||
),
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_case_target(self) -> "BatchExportBody":
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Chat API request models."""
|
||||
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -59,12 +59,3 @@ class ChatCompletionRequest(BaseModel):
|
||||
"Ignored by providers that do not expose a per-request thinking switch."
|
||||
),
|
||||
)
|
||||
tool_decisions: dict[str, Literal["approve", "reject"]] = Field(
|
||||
default_factory=dict,
|
||||
description=(
|
||||
"Decisions for tool calls that paused for approval, keyed by tool "
|
||||
"call ID. Send these with the conversation chain returned alongside "
|
||||
"an approval request; rejected calls are reported to the model as "
|
||||
"declined instead of being executed."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
from frigate.record.export import (
|
||||
ChaptersEnum,
|
||||
ExportStreamEnum,
|
||||
PlaybackSourceEnum,
|
||||
)
|
||||
|
||||
@@ -28,16 +27,6 @@ class ExportRecordingsBody(BaseModel):
|
||||
"the camera's configured export chapter mode is used."
|
||||
),
|
||||
)
|
||||
stream: ExportStreamEnum = Field(
|
||||
default=ExportStreamEnum.auto,
|
||||
title="Recorded stream to export",
|
||||
description=(
|
||||
"Which recorded stream to export. 'auto' uses the merged "
|
||||
"timeline, preferring the main stream and falling back to the "
|
||||
"sub stream where main has aged out. 'main' or 'sub' pins the "
|
||||
"export to that stream alone."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ExportRecordingsCustomBody(BaseModel):
|
||||
|
||||
@@ -13,7 +13,6 @@ class Tags(Enum):
|
||||
logs = "Logs"
|
||||
media = "Media"
|
||||
motion_search = "Motion Search"
|
||||
notices = "Notices"
|
||||
notifications = "Notifications"
|
||||
preview = "Preview"
|
||||
recordings = "Recordings"
|
||||
|
||||
+12
-28
@@ -386,9 +386,7 @@ def events_explore(
|
||||
limit: int = 10,
|
||||
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
if not allowed_cameras:
|
||||
return JSONResponse(content=[])
|
||||
|
||||
# get distinct labels for all events
|
||||
distinct_labels = (
|
||||
Event.select(Event.label)
|
||||
.where(Event.camera << allowed_cameras)
|
||||
@@ -398,31 +396,13 @@ def events_explore(
|
||||
|
||||
label_counts = {}
|
||||
|
||||
explore_columns = (
|
||||
Event.id,
|
||||
Event.camera,
|
||||
Event.label,
|
||||
Event.sub_label,
|
||||
Event.zones,
|
||||
Event.start_time,
|
||||
Event.end_time,
|
||||
Event.has_clip,
|
||||
Event.has_snapshot,
|
||||
Event.plus_id,
|
||||
Event.retain_indefinitely,
|
||||
Event.top_score,
|
||||
Event.false_positive,
|
||||
Event.box,
|
||||
Event.data,
|
||||
)
|
||||
|
||||
def event_generator():
|
||||
for label_obj in distinct_labels.iterator():
|
||||
label = label_obj.label
|
||||
|
||||
# get most recent events for this label
|
||||
label_events = (
|
||||
Event.select(*explore_columns)
|
||||
Event.select()
|
||||
.where((Event.label == label) & (Event.camera << allowed_cameras))
|
||||
.order_by(Event.start_time.desc())
|
||||
.limit(limit)
|
||||
@@ -504,18 +484,22 @@ async def event_ids(ids: str, request: Request):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
for event_id in ids:
|
||||
try:
|
||||
event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
except DoesNotExist:
|
||||
# we should not fail the entire request if an event is not found
|
||||
continue
|
||||
|
||||
try:
|
||||
events = list(Event.select().where(Event.id << ids).dicts().iterator())
|
||||
events = Event.select().where(Event.id << ids).dicts().iterator()
|
||||
return JSONResponse(list(events))
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Events not found"}), status_code=400
|
||||
)
|
||||
|
||||
for event in events:
|
||||
await require_camera_access(event["camera"], request=request)
|
||||
|
||||
return JSONResponse(events)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/search",
|
||||
|
||||
+26
-55
@@ -71,9 +71,7 @@ from frigate.jobs.export import (
|
||||
from frigate.models import Export, ExportCase, Previews, Recordings
|
||||
from frigate.record.export import (
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS,
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS,
|
||||
ChaptersEnum,
|
||||
ExportStreamEnum,
|
||||
PlaybackSourceEnum,
|
||||
export_video_path,
|
||||
validate_ffmpeg_args,
|
||||
@@ -150,23 +148,16 @@ def _sanitize_existing_image(
|
||||
return existing_image, None
|
||||
|
||||
|
||||
def _no_recordings_message(stream: ExportStreamEnum) -> str:
|
||||
if stream == ExportStreamEnum.auto:
|
||||
return "No recordings found for time range"
|
||||
|
||||
return f"No {stream.value} stream recordings found for time range"
|
||||
|
||||
|
||||
def _validate_export_source(
|
||||
camera_name: str,
|
||||
start_time: float,
|
||||
end_time: float,
|
||||
playback_source: PlaybackSourceEnum,
|
||||
stream: ExportStreamEnum = ExportStreamEnum.auto,
|
||||
) -> str | None:
|
||||
if playback_source == PlaybackSourceEnum.recordings:
|
||||
query = Recordings.select().where(
|
||||
(
|
||||
recordings_count = (
|
||||
Recordings.select()
|
||||
.where(
|
||||
Recordings.start_time.between(start_time, end_time)
|
||||
| Recordings.end_time.between(start_time, end_time)
|
||||
| (
|
||||
@@ -174,16 +165,12 @@ def _validate_export_source(
|
||||
& (end_time < Recordings.end_time)
|
||||
)
|
||||
)
|
||||
& (Recordings.camera == camera_name)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.count()
|
||||
)
|
||||
|
||||
# a pinned export reads only that stream, so the other stream's
|
||||
# coverage must not make the range look exportable
|
||||
if stream != ExportStreamEnum.auto:
|
||||
query = query.where(Recordings.stream_type == stream.value)
|
||||
|
||||
if query.count() <= 0:
|
||||
return _no_recordings_message(stream)
|
||||
if recordings_count <= 0:
|
||||
return "No recordings found for time range"
|
||||
|
||||
return None
|
||||
|
||||
@@ -207,7 +194,6 @@ def _validate_export_source(
|
||||
def _get_item_recording_export_errors(
|
||||
request: Request,
|
||||
items: list[BatchExportItem],
|
||||
stream: ExportStreamEnum = ExportStreamEnum.auto,
|
||||
) -> dict[int, str]:
|
||||
"""Return {item_index: error message} for items with invalid state.
|
||||
|
||||
@@ -237,20 +223,20 @@ def _get_item_recording_export_errors(
|
||||
min_start = min(r[1] for r in indexed_ranges)
|
||||
max_end = max(r[2] for r in indexed_ranges)
|
||||
|
||||
query = Recordings.select(Recordings.start_time, Recordings.end_time).where(
|
||||
Recordings.camera == camera_name,
|
||||
Recordings.start_time.between(min_start, max_end)
|
||||
| Recordings.end_time.between(min_start, max_end)
|
||||
| ((min_start > Recordings.start_time) & (max_end < Recordings.end_time)),
|
||||
recording_ranges = list(
|
||||
Recordings.select(Recordings.start_time, Recordings.end_time)
|
||||
.where(
|
||||
Recordings.camera == camera_name,
|
||||
Recordings.start_time.between(min_start, max_end)
|
||||
| Recordings.end_time.between(min_start, max_end)
|
||||
| (
|
||||
(min_start > Recordings.start_time)
|
||||
& (max_end < Recordings.end_time)
|
||||
),
|
||||
)
|
||||
.iterator()
|
||||
)
|
||||
|
||||
# a pinned batch reads only that stream, so the other stream's
|
||||
# coverage must not make an item look exportable
|
||||
if stream != ExportStreamEnum.auto:
|
||||
query = query.where(Recordings.stream_type == stream.value)
|
||||
|
||||
recording_ranges = list(query.iterator())
|
||||
|
||||
for index, start_time, end_time in indexed_ranges:
|
||||
has_recording = any(
|
||||
(
|
||||
@@ -261,7 +247,7 @@ def _get_item_recording_export_errors(
|
||||
for rec in recording_ranges
|
||||
)
|
||||
if not has_recording:
|
||||
errors[index] = _no_recordings_message(stream)
|
||||
errors[index] = "No recordings found for time range"
|
||||
|
||||
return errors
|
||||
|
||||
@@ -278,7 +264,6 @@ def _build_export_job(
|
||||
ffmpeg_output_args: str | None = None,
|
||||
cpu_fallback: bool = False,
|
||||
chapters: ChaptersEnum | None = None,
|
||||
stream: ExportStreamEnum = ExportStreamEnum.auto,
|
||||
) -> ExportJob:
|
||||
return ExportJob(
|
||||
id=_generate_export_id(camera_name),
|
||||
@@ -293,7 +278,6 @@ def _build_export_job(
|
||||
ffmpeg_output_args=ffmpeg_output_args,
|
||||
cpu_fallback=cpu_fallback,
|
||||
chapters=chapters,
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
|
||||
@@ -707,7 +691,7 @@ def export_recordings_batch(
|
||||
return image_validation_error
|
||||
sanitized_images.append(existing_image)
|
||||
|
||||
item_errors = _get_item_recording_export_errors(request, body.items, body.stream)
|
||||
item_errors = _get_item_recording_export_errors(request, body.items)
|
||||
|
||||
queueable_indexes = [
|
||||
index for index in range(len(body.items)) if index not in item_errors
|
||||
@@ -776,7 +760,6 @@ def export_recordings_batch(
|
||||
chapters=request.app.frigate_config.cameras[
|
||||
item.camera
|
||||
].record.export.chapters,
|
||||
stream=body.stream,
|
||||
)
|
||||
try:
|
||||
start_export_job(request.app.frigate_config, export_job)
|
||||
@@ -884,7 +867,6 @@ def export_recording(
|
||||
start_time,
|
||||
end_time,
|
||||
playback_source,
|
||||
body.stream,
|
||||
)
|
||||
if source_error is not None:
|
||||
return JSONResponse(
|
||||
@@ -901,7 +883,6 @@ def export_recording(
|
||||
playback_source,
|
||||
export_case_id,
|
||||
chapters=chapters,
|
||||
stream=body.stream,
|
||||
)
|
||||
try:
|
||||
start_export_job(request.app.frigate_config, export_job)
|
||||
@@ -1038,10 +1019,6 @@ def export_recording_custom(
|
||||
if camera_validation_error is not None:
|
||||
return camera_validation_error
|
||||
|
||||
# Validate user-provided ffmpeg args to prevent injection and add to cases.
|
||||
# Admin users are trusted and skip validation.
|
||||
is_admin = request.headers.get("remote-role", "") == "admin"
|
||||
|
||||
playback_source = body.source
|
||||
friendly_name = body.name
|
||||
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
|
||||
@@ -1052,16 +1029,6 @@ def export_recording_custom(
|
||||
cpu_fallback = body.cpu_fallback
|
||||
|
||||
export_case_id = body.export_case_id
|
||||
|
||||
if export_case_id is not None and not is_admin:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Only admins can attach exports to an existing case.",
|
||||
},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
case_validation_error = _validate_export_case(export_case_id)
|
||||
if case_validation_error is not None:
|
||||
return case_validation_error
|
||||
@@ -1078,6 +1045,10 @@ def export_recording_custom(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate user-provided ffmpeg args to prevent injection.
|
||||
# Admin users are trusted and skip validation.
|
||||
is_admin = request.headers.get("remote-role", "") == "admin"
|
||||
|
||||
if not is_admin:
|
||||
for args_label, args_value in [
|
||||
("input", ffmpeg_input_args),
|
||||
@@ -1098,7 +1069,7 @@ def export_recording_custom(
|
||||
|
||||
# Set default values if not provided (timelapse defaults)
|
||||
if ffmpeg_input_args is None:
|
||||
ffmpeg_input_args = DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS
|
||||
ffmpeg_input_args = ""
|
||||
|
||||
if ffmpeg_output_args is None:
|
||||
ffmpeg_output_args = DEFAULT_TIME_LAPSE_FFMPEG_ARGS
|
||||
|
||||
@@ -24,7 +24,6 @@ from frigate.api import (
|
||||
hardware,
|
||||
media,
|
||||
motion_search,
|
||||
notices,
|
||||
notification,
|
||||
preview,
|
||||
record,
|
||||
@@ -42,7 +41,6 @@ from frigate.config.profile_manager import ProfileManager
|
||||
from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog
|
||||
from frigate.embeddings import EmbeddingsContext
|
||||
from frigate.genai import GenAIClientManager
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
from frigate.storage import StorageMaintainer
|
||||
@@ -79,7 +77,6 @@ def create_fastapi_app(
|
||||
profile_manager: ProfileManager | None = None,
|
||||
enforce_default_admin: bool = True,
|
||||
config_holder: ConfigHolder | None = None,
|
||||
notice_registry: NoticeRegistry | None = None,
|
||||
):
|
||||
logger.info("Starting FastAPI app")
|
||||
app = FastAPI(
|
||||
@@ -150,7 +147,6 @@ def create_fastapi_app(
|
||||
app.include_router(notification.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(hardware.router)
|
||||
app.include_router(notices.router)
|
||||
app.include_router(event.router)
|
||||
app.include_router(media.router)
|
||||
app.include_router(motion_search.router)
|
||||
@@ -167,7 +163,6 @@ def create_fastapi_app(
|
||||
app.camera_error_image = None
|
||||
app.onvif = onvif
|
||||
app.stats_emitter = stats_emitter
|
||||
app.notice_registry = notice_registry
|
||||
app.event_metadata_updater = event_metadata_updater
|
||||
app.config_publisher = config_publisher
|
||||
app.replay_manager = replay_manager
|
||||
|
||||
@@ -189,7 +189,7 @@ async def camera_ptz_info(request: Request, camera_name: str):
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
request.app.onvif.get_camera_info(camera_name), request.app.onvif.loop
|
||||
)
|
||||
result = await asyncio.wrap_future(future)
|
||||
result = future.result()
|
||||
return JSONResponse(content=result)
|
||||
else:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Notice APIs."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.notices])
|
||||
|
||||
|
||||
@router.get("/notices", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_notices(request: Request, include_dismissed: bool = False) -> JSONResponse:
|
||||
"""Get notices, most severe first.
|
||||
|
||||
Args:
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
|
||||
Returns:
|
||||
The notices
|
||||
"""
|
||||
return JSONResponse(
|
||||
content=request.app.notice_registry.active(include_dismissed=include_dismissed)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/notices/stats", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_notice_stats(request: Request) -> JSONResponse:
|
||||
"""Get lifetime occurrence counts per notice kind."""
|
||||
return JSONResponse(content=request.app.notice_registry.stats())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/notices/dismissed_checks", dependencies=[Depends(require_role(["admin"]))]
|
||||
)
|
||||
def get_dismissed_checks(request: Request) -> JSONResponse:
|
||||
"""Get the dismissed config and stream check rows, newest first."""
|
||||
return JSONResponse(content=request.app.notice_registry.dismissed_checks())
|
||||
|
||||
|
||||
@router.delete("/notices/dismissed", dependencies=[Depends(require_role(["admin"]))])
|
||||
def purge_dismissed(request: Request) -> JSONResponse:
|
||||
"""Delete every dismissed notice and check row so each can show again."""
|
||||
request.app.notice_registry.purge_dismissed()
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Dismissed notices cleared"}
|
||||
)
|
||||
|
||||
|
||||
# model notice ids contain a slash, so the id is a path parameter
|
||||
@router.post(
|
||||
"/notices/{notice_id:path}/dismiss",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def dismiss_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide a notice or a config or stream check row.
|
||||
|
||||
It stays hidden if the same problem happens again.
|
||||
"""
|
||||
if not request.app.notice_registry.dismiss(notice_id):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice dismissed"})
|
||||
@@ -358,13 +358,10 @@ async def recordings_coverage(
|
||||
@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)])
|
||||
async def recordings(
|
||||
camera_name: str,
|
||||
after: float | None = None,
|
||||
before: float | None = None,
|
||||
after: float = (datetime.now() - timedelta(hours=1)).timestamp(),
|
||||
before: float = datetime.now().timestamp(),
|
||||
):
|
||||
"""Return specific camera recordings between the given 'after'/'end' times. If not provided the last hour will be used"""
|
||||
now = datetime.now()
|
||||
after = after if after is not None else (now - timedelta(hours=1)).timestamp()
|
||||
before = before if before is not None else now.timestamp()
|
||||
recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
@@ -412,8 +409,11 @@ async def no_recordings(
|
||||
if not camera_list:
|
||||
return JSONResponse(content=[])
|
||||
|
||||
before = params.before or datetime.now().timestamp()
|
||||
after = params.after or (datetime.now() - timedelta(hours=1)).timestamp()
|
||||
before = params.before or datetime.datetime.now().timestamp()
|
||||
after = (
|
||||
params.after
|
||||
or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp()
|
||||
)
|
||||
scale = params.scale
|
||||
|
||||
recordings: list[tuple[float, float]] = []
|
||||
|
||||
+34
-128
@@ -9,7 +9,7 @@ import pandas as pd
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from peewee import Case, DoesNotExist, fn, operator
|
||||
from peewee import Case, DoesNotExist, IntegrityError, fn, operator
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.api.auth import (
|
||||
@@ -173,19 +173,11 @@ async def review_ids(request: Request, ids: str):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
reviews = list(
|
||||
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Review segments not found"}),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
found_ids = {r["id"] for r in reviews}
|
||||
for review_id in ids:
|
||||
if review_id not in found_ids:
|
||||
try:
|
||||
review = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||
await require_camera_access(review.camera, request=request)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": f"Review {review_id} not found"}
|
||||
@@ -193,10 +185,16 @@ async def review_ids(request: Request, ids: str):
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
for review in reviews:
|
||||
await require_camera_access(review["camera"], request=request)
|
||||
|
||||
return JSONResponse(reviews)
|
||||
try:
|
||||
reviews = (
|
||||
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
|
||||
)
|
||||
return JSONResponse(list(reviews))
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Review segments not found"}),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -493,52 +491,27 @@ async def set_multiple_reviewed(
|
||||
|
||||
user_id = current_user["username"]
|
||||
|
||||
reviews = list(
|
||||
ReviewSegment.select(ReviewSegment.id, ReviewSegment.camera).where(
|
||||
ReviewSegment.id << body.ids
|
||||
)
|
||||
)
|
||||
|
||||
for review in reviews:
|
||||
await require_camera_access(review.camera, request=request)
|
||||
|
||||
found_ids = [r.id for r in reviews]
|
||||
|
||||
if found_ids:
|
||||
existing_statuses = list(
|
||||
UserReviewStatus.select().where(
|
||||
(UserReviewStatus.user_id == user_id)
|
||||
& (UserReviewStatus.review_segment << found_ids)
|
||||
for review_id in body.ids:
|
||||
try:
|
||||
review = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||
await require_camera_access(review.camera, request=request)
|
||||
review_status = UserReviewStatus.get(
|
||||
UserReviewStatus.user_id == user_id,
|
||||
UserReviewStatus.review_segment == review_id,
|
||||
)
|
||||
)
|
||||
|
||||
status_by_review = {s.review_segment_id: s for s in existing_statuses}
|
||||
|
||||
to_update = []
|
||||
to_create = []
|
||||
|
||||
for review_id in found_ids:
|
||||
if review_id in status_by_review:
|
||||
status = status_by_review[review_id]
|
||||
if status.has_been_reviewed != body.reviewed:
|
||||
status.has_been_reviewed = body.reviewed
|
||||
to_update.append(status)
|
||||
else:
|
||||
to_create.append(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"review_segment_id": review_id,
|
||||
"has_been_reviewed": body.reviewed,
|
||||
}
|
||||
# Update based on the reviewed parameter
|
||||
if review_status.has_been_reviewed != body.reviewed:
|
||||
review_status.has_been_reviewed = body.reviewed
|
||||
review_status.save()
|
||||
except DoesNotExist:
|
||||
try:
|
||||
UserReviewStatus.create(
|
||||
user_id=user_id,
|
||||
review_segment=ReviewSegment.get(id=review_id),
|
||||
has_been_reviewed=body.reviewed,
|
||||
)
|
||||
|
||||
if to_update:
|
||||
UserReviewStatus.bulk_update(
|
||||
to_update, fields=[UserReviewStatus.has_been_reviewed], batch_size=100
|
||||
)
|
||||
|
||||
if to_create:
|
||||
UserReviewStatus.insert_many(to_create).on_conflict_ignore().execute()
|
||||
except (DoesNotExist, IntegrityError):
|
||||
pass
|
||||
|
||||
return JSONResponse(
|
||||
content=(
|
||||
@@ -734,73 +707,6 @@ async def get_review(request: Request, review_id: str):
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/review/{review_id}/regenerate_description",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Generate a review item description",
|
||||
description="""Re-runs a review item through the GenAI descriptions process.
|
||||
Frames are always taken from recordings, and both alerts and detections are
|
||||
accepted regardless of the camera's GenAI alerts/detections toggles.
|
||||
""",
|
||||
)
|
||||
async def regenerate_review_description(request: Request, review_id: str):
|
||||
try:
|
||||
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Review " + review_id + " not found",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
await require_camera_access(review.camera, request=request)
|
||||
|
||||
if review.end_time is None:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Review " + review_id + " has not ended yet",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
camera_config = request.app.frigate_config.cameras.get(review.camera)
|
||||
|
||||
if camera_config is None or not camera_config.review.genai.enabled:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "GenAI descriptions must be enabled for this camera",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if request.app.genai_manager.description_client is None:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "A GenAI provider with the descriptions role must be configured",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
context.regenerate_review_description(review_id)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": "Review "
|
||||
+ review_id
|
||||
+ " description generation has been requested",
|
||||
},
|
||||
status_code=202,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/review/{review_id}/viewed",
|
||||
response_model=GenericResponse,
|
||||
|
||||
@@ -50,7 +50,6 @@ from frigate.debug_replay import (
|
||||
cleanup_replay_cameras,
|
||||
)
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
from frigate.detectors.detector_types import api_types
|
||||
from frigate.detectors.device import build_detector_config, runner_names
|
||||
from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
|
||||
from frigate.events.audio import AudioProcessor
|
||||
@@ -62,8 +61,6 @@ from frigate.log import _stop_logging
|
||||
from frigate.models import (
|
||||
Event,
|
||||
Export,
|
||||
Notice,
|
||||
NoticeStats,
|
||||
Previews,
|
||||
Recordings,
|
||||
RecordingsToDelete,
|
||||
@@ -73,8 +70,6 @@ from frigate.models import (
|
||||
Trigger,
|
||||
User,
|
||||
)
|
||||
from frigate.notices import install_registry
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.object_detection.base import ObjectDetectProcess
|
||||
from frigate.object_detection.util import detection_frame_size
|
||||
from frigate.output.output import OutputProcess
|
||||
@@ -93,7 +88,6 @@ from frigate.util.builtin import empty_and_close_queue
|
||||
from frigate.util.image import UntrackedSharedMemory
|
||||
from frigate.util.ownership import chown_to_runtime
|
||||
from frigate.util.process import FrigateProcess
|
||||
from frigate.util.runtime_deps import RuntimeDependencyError
|
||||
from frigate.util.services import set_file_limit
|
||||
from frigate.version import VERSION
|
||||
from frigate.watchdog import FrigateWatchdog
|
||||
@@ -298,8 +292,6 @@ class FrigateApp:
|
||||
models = [
|
||||
Event,
|
||||
Export,
|
||||
Notice,
|
||||
NoticeStats,
|
||||
Previews,
|
||||
Recordings,
|
||||
RecordingsToDelete,
|
||||
@@ -322,10 +314,6 @@ class FrigateApp:
|
||||
|
||||
migrate_exports(self.config.ffmpeg, list(self.config.cameras.keys()))
|
||||
|
||||
def install_notice_registry(self) -> None:
|
||||
self.notice_registry = NoticeRegistry()
|
||||
install_registry(self.notice_registry)
|
||||
|
||||
def init_embeddings_client(self) -> None:
|
||||
# Create a client for other processes to use
|
||||
self.embeddings = EmbeddingsContext(self.db)
|
||||
@@ -364,7 +352,6 @@ class FrigateApp:
|
||||
self.onvif_controller,
|
||||
self.ptz_metrics,
|
||||
comms,
|
||||
notice_registry=self.notice_registry,
|
||||
)
|
||||
self.dispatcher.start_communicators()
|
||||
|
||||
@@ -374,26 +361,6 @@ class FrigateApp:
|
||||
)
|
||||
self.dispatcher.profile_manager = self.profile_manager
|
||||
|
||||
def ensure_detector_dependencies(self) -> None:
|
||||
"""Install runtimes for the configured detector types.
|
||||
|
||||
Runs before any detector process starts so one install serves them
|
||||
all and the user site is on sys.path before the forkserver copies it.
|
||||
A failure is logged and startup continues; the detector process then
|
||||
fails on its own with a clear import error.
|
||||
"""
|
||||
detector_types = {
|
||||
spec.detector
|
||||
for model in self.config.models
|
||||
for spec in self.config.devices_for_model(model)
|
||||
}
|
||||
|
||||
for detector_type in sorted(detector_types):
|
||||
try:
|
||||
api_types[detector_type].ensure_dependencies()
|
||||
except RuntimeDependencyError as err:
|
||||
logger.error("Unable to prepare the %s runtime: %s", detector_type, err)
|
||||
|
||||
def start_detectors(self) -> None:
|
||||
model_cameras: dict[SceneEnum, list[str]] = {
|
||||
model.scene: [] for model in self.config.models
|
||||
@@ -523,7 +490,6 @@ class FrigateApp:
|
||||
self.embeddings_metrics,
|
||||
self.detectors,
|
||||
self.processes,
|
||||
self.storage_maintainer,
|
||||
),
|
||||
self.stop_event,
|
||||
)
|
||||
@@ -624,7 +590,6 @@ class FrigateApp:
|
||||
|
||||
# Ensure global state.
|
||||
self.ensure_dirs()
|
||||
self.ensure_detector_dependencies()
|
||||
|
||||
# Set soft file limits.
|
||||
set_file_limit()
|
||||
@@ -641,7 +606,6 @@ class FrigateApp:
|
||||
self.init_embeddings_manager()
|
||||
self.bind_database()
|
||||
self.check_db_data_migrations()
|
||||
self.install_notice_registry()
|
||||
|
||||
# Clean up any stale replay camera artifacts (filesystem + DB)
|
||||
cleanup_replay_cameras()
|
||||
@@ -699,7 +663,6 @@ class FrigateApp:
|
||||
self.dispatcher,
|
||||
self.profile_manager,
|
||||
config_holder=self.config_holder,
|
||||
notice_registry=self.notice_registry,
|
||||
),
|
||||
host="127.0.0.1",
|
||||
port=5001,
|
||||
|
||||
@@ -11,7 +11,6 @@ from peewee import IntegrityError
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
|
||||
from frigate.comms.base_communicator import Communicator
|
||||
from frigate.comms.mqtt import MqttClient
|
||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||
from frigate.comms.webpush import WebPushClient
|
||||
from frigate.config import (
|
||||
@@ -41,12 +40,10 @@ from frigate.const import (
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
UPDATE_JOB_STATE,
|
||||
UPDATE_MODEL_STATE,
|
||||
UPDATE_NOTICE,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
)
|
||||
from frigate.models import Event, Previews, Recordings, ReviewSegment
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.ptz.onvif import OnvifCommandEnum, OnvifController
|
||||
from frigate.types import ModelStatusTypesEnum, TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.object import get_camera_regions_grid
|
||||
@@ -70,18 +67,12 @@ class Dispatcher:
|
||||
onvif: OnvifController,
|
||||
ptz_metrics: dict[str, PTZMetrics],
|
||||
communicators: list[Communicator],
|
||||
notice_registry: NoticeRegistry | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.config_updater = config_updater
|
||||
self.onvif = onvif
|
||||
self.ptz_metrics = ptz_metrics
|
||||
self.comms = communicators
|
||||
self.notice_registry = notice_registry
|
||||
|
||||
if notice_registry is not None:
|
||||
notice_registry.subscribe(self._publish_notices)
|
||||
|
||||
self.camera_activity = CameraActivityManager(config, self.publish)
|
||||
self.audio_activity = AudioActivityManager(config, self.publish)
|
||||
self.model_state: dict[str, ModelStatusTypesEnum] = {}
|
||||
@@ -221,10 +212,6 @@ class Dispatcher:
|
||||
False,
|
||||
)
|
||||
publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()), False)
|
||||
|
||||
if self.notice_registry is not None:
|
||||
publish("notices", json.dumps(self.notice_registry.active()), False)
|
||||
|
||||
publish("audio_detections", json.dumps(audio_detections), False)
|
||||
publish(
|
||||
"profile/state",
|
||||
@@ -352,16 +339,6 @@ class Dispatcher:
|
||||
self.model_state[model] = ModelStatusTypesEnum[state]
|
||||
self.publish("model_state", json.dumps(self.model_state))
|
||||
|
||||
def handle_update_notice() -> None:
|
||||
if self.notice_registry is None or not isinstance(payload, dict):
|
||||
return
|
||||
|
||||
try:
|
||||
self.notice_registry.apply(payload)
|
||||
except Exception:
|
||||
# a raise here would kill the REP thread for every process
|
||||
logger.exception("Failed to apply notice update")
|
||||
|
||||
def handle_model_state() -> None:
|
||||
self.publish("model_state", json.dumps(self.model_state.copy()))
|
||||
|
||||
@@ -429,7 +406,6 @@ class Dispatcher:
|
||||
UPDATE_REVIEW_DESCRIPTION: handle_update_review_description,
|
||||
UPDATE_MODEL_STATE: handle_update_model_state,
|
||||
UPDATE_JOB_STATE: handle_update_job_state,
|
||||
UPDATE_NOTICE: handle_update_notice,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
|
||||
UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE: handle_update_audio_transcription_state,
|
||||
@@ -488,23 +464,6 @@ class Dispatcher:
|
||||
for comm in self.comms:
|
||||
comm.publish(topic, payload, retain)
|
||||
|
||||
def publish_local(self, topic: str, payload: Any) -> None:
|
||||
"""Publish to every communicator except MQTT.
|
||||
|
||||
Used for topics whose external schema is not settled yet.
|
||||
"""
|
||||
for comm in self.comms:
|
||||
if isinstance(comm, MqttClient):
|
||||
continue
|
||||
|
||||
comm.publish(topic, payload, False)
|
||||
|
||||
def _publish_notices(self) -> None:
|
||||
if self.notice_registry is None:
|
||||
return
|
||||
|
||||
self.publish_local("notices", json.dumps(self.notice_registry.active()))
|
||||
|
||||
def stop(self) -> None:
|
||||
self.camera_activity.stop()
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ class EmbeddingsRequestEnum(Enum):
|
||||
reprocess_plate = "reprocess_plate"
|
||||
# Review Descriptions
|
||||
summarize_review = "summarize_review"
|
||||
regenerate_review_description = "regenerate_review_description"
|
||||
|
||||
|
||||
class EmbeddingsResponder:
|
||||
|
||||
@@ -31,7 +31,6 @@ from frigate.const import (
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
UPDATE_MODEL_STATE,
|
||||
UPDATE_NOTICE,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
)
|
||||
@@ -57,7 +56,6 @@ _WS_BLOCKED_TOPICS = frozenset(
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_BIRDSEYE_LAYOUT,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
UPDATE_NOTICE,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -160,16 +158,6 @@ _WS_UNRESTRICTED_ONLY_TOPICS = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Topics only an admin connection may receive. unrestricted_only is not
|
||||
# enough: the built-in viewer role has an empty camera allow-list, which the
|
||||
# camera policy treats as full access, while the REST side of these topics
|
||||
# is admin-only.
|
||||
_WS_ADMIN_ONLY_TOPICS = frozenset(
|
||||
{
|
||||
"notices",
|
||||
}
|
||||
)
|
||||
|
||||
# Topics whose payload (parsed as JSON) names a single owning camera at the
|
||||
# given key path. Used to scope events, reviews, triggers, etc.
|
||||
_WS_PAYLOAD_CAMERA_TOPICS: dict[str, tuple[str, ...]] = {
|
||||
@@ -292,7 +280,6 @@ def _classify_outbound(
|
||||
- "global" : send to every authenticated client
|
||||
- "drop" : send to nobody (fail-closed for unknowns)
|
||||
- "unrestricted_only" : send only to admin/full-access roles
|
||||
- "admin_only" : send only to connections with the admin role
|
||||
- "camera" : extra is the owning camera name
|
||||
- "payload_camera" : extra is the JSON key path to the camera name
|
||||
- "reshape_by_camera_key"
|
||||
@@ -301,8 +288,6 @@ def _classify_outbound(
|
||||
"""
|
||||
if topic in _WS_GLOBAL_OUTBOUND_TOPICS:
|
||||
return ("global", None)
|
||||
if topic in _WS_ADMIN_ONLY_TOPICS:
|
||||
return ("admin_only", None)
|
||||
if topic in _WS_UNRESTRICTED_ONLY_TOPICS:
|
||||
return ("unrestricted_only", None)
|
||||
if topic in _WS_RESHAPE_BY_CAMERA_KEY_TOPICS:
|
||||
@@ -406,9 +391,6 @@ def _materialize_for_ws(
|
||||
if kind == "unrestricted_only":
|
||||
return full_message if _ws_is_unrestricted(ws, config) else None
|
||||
|
||||
if kind == "admin_only":
|
||||
return full_message if "admin" in _ws_valid_roles(ws, config) else None
|
||||
|
||||
if kind == "camera":
|
||||
return full_message if ws_has_camera_access(ws, extra, config) else None
|
||||
|
||||
|
||||
@@ -78,14 +78,11 @@ class AuthConfig(FrigateBaseModel):
|
||||
f"Invalid role name '{role}'. Must be alphanumeric with underscores."
|
||||
)
|
||||
|
||||
# 'none' is the deny sentinel for proxy.default_role, where it is matched
|
||||
# case-insensitively, so every casing of it has to be reserved here
|
||||
used_reserved = sorted(
|
||||
r for r in v if r in ("admin", "viewer") or r.lower() == "none"
|
||||
)
|
||||
if used_reserved:
|
||||
# Ensure 'admin' and 'viewer' are not used as custom role names
|
||||
reserved_roles = {"admin", "viewer"}
|
||||
if v.keys() & reserved_roles:
|
||||
raise ValueError(
|
||||
f"Reserved role name(s) {', '.join(used_reserved)} cannot be used as custom roles."
|
||||
f"Reserved roles {reserved_roles} cannot be used as custom roles."
|
||||
)
|
||||
|
||||
# Ensure no role has an empty camera list
|
||||
|
||||
@@ -4,13 +4,7 @@ from pydantic import Field, field_validator
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
|
||||
__all__ = [
|
||||
"ReviewConfig",
|
||||
"DetectionsConfig",
|
||||
"AlertsConfig",
|
||||
"ImageSourceEnum",
|
||||
"ReviewResponseStyleEnum",
|
||||
]
|
||||
__all__ = ["ReviewConfig", "DetectionsConfig", "AlertsConfig", "ImageSourceEnum"]
|
||||
|
||||
|
||||
class ImageSourceEnum(str, Enum):
|
||||
@@ -20,15 +14,6 @@ class ImageSourceEnum(str, Enum):
|
||||
recordings = "recordings"
|
||||
|
||||
|
||||
class ReviewResponseStyleEnum(str, Enum):
|
||||
"""Writing style presets for GenAI review descriptions."""
|
||||
|
||||
default = "default"
|
||||
natural = "natural"
|
||||
concise = "concise"
|
||||
detailed = "detailed"
|
||||
|
||||
|
||||
DEFAULT_ALERT_OBJECTS = ["person", "car"]
|
||||
|
||||
|
||||
@@ -153,11 +138,6 @@ class GenAIReviewConfig(FrigateBaseModel):
|
||||
description="Preferred language to request from the GenAI provider for generated responses.",
|
||||
default=None,
|
||||
)
|
||||
response_style: ReviewResponseStyleEnum = Field(
|
||||
default=ReviewResponseStyleEnum.default,
|
||||
title="Response style",
|
||||
description="Writing style preset for generated review descriptions. Presets adjust the tone and level of detail of the user-facing title, summary, and scene description; 'default' leaves the built-in prompt unchanged.",
|
||||
)
|
||||
activity_context_prompt: str = Field(
|
||||
default="""### Normal Activity Indicators (Level 0)
|
||||
- Known/verified people in any zone at any time
|
||||
|
||||
+1
-11
@@ -43,7 +43,7 @@ class ProxyConfig(FrigateBaseModel):
|
||||
default_role: str | None = Field(
|
||||
default="viewer",
|
||||
title="Default role",
|
||||
description="Default role assigned to proxy-authenticated users when no role mapping applies. Set to 'none' to deny access to unmapped users.",
|
||||
description="Default role assigned to proxy-authenticated users when no role mapping applies.",
|
||||
)
|
||||
separator: str | None = Field(
|
||||
default=",",
|
||||
@@ -51,16 +51,6 @@ class ProxyConfig(FrigateBaseModel):
|
||||
description="Character used to split multiple values provided in proxy headers.",
|
||||
)
|
||||
|
||||
@field_validator("default_role", mode="before")
|
||||
@classmethod
|
||||
def normalize_deny_sentinel(cls, v):
|
||||
# Fail closed on capitalization: an unnormalized "None" would miss the
|
||||
# sentinel and fall back to viewer, granting the access it was meant to
|
||||
# deny. Other role names stay case-sensitive.
|
||||
if isinstance(v, str) and v.strip().lower() == "none":
|
||||
return "none"
|
||||
return v
|
||||
|
||||
@field_validator("separator", mode="before")
|
||||
@classmethod
|
||||
def validate_separator_length(cls, v):
|
||||
|
||||
@@ -155,7 +155,6 @@ UPDATE_MODEL_STATE = "update_model_state"
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress"
|
||||
UPDATE_BIRDSEYE_LAYOUT = "update_birdseye_layout"
|
||||
UPDATE_JOB_STATE = "update_job_state"
|
||||
UPDATE_NOTICE = "update_notice"
|
||||
NOTIFICATION_TEST = "notification_test"
|
||||
|
||||
# IO Nice Values
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
"""Handle face detection."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.log import redirect_output_to_logger
|
||||
from frigate.util.image import area
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_DETECTION_HEIGHT = 1080
|
||||
|
||||
FACE_DET_DIR = os.path.join(MODEL_CACHE_DIR, "facedet")
|
||||
|
||||
# 5 point template the arcface models are trained on, defined against a 112x112
|
||||
# crop and scaled to whatever size the embedding model takes
|
||||
FACE_TEMPLATE_SIZE = 112
|
||||
FACE_TEMPLATE = np.array(
|
||||
[
|
||||
[38.2946, 51.6963],
|
||||
[73.5318, 51.5014],
|
||||
[56.0252, 71.7366],
|
||||
[41.5493, 92.3655],
|
||||
[70.7299, 92.2041],
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
|
||||
# landmarks further than this from a plausible face shape are not trusted. on a
|
||||
# sample of camera face crops every set that failed a basic eye, nose, and mouth
|
||||
# ordering check scored above 9.5 and every set that passed scored below 9.2
|
||||
MAX_LANDMARK_FIT_ERROR = 9.0
|
||||
|
||||
|
||||
def landmark_fit_error(landmarks: tuple[tuple[float, float], ...]) -> float:
|
||||
"""Mean distance in template pixels once landmarks are fit to the template.
|
||||
|
||||
Scale, rotation, and position are fit out, so this measures only how far
|
||||
the landmarks are from a plausible face shape.
|
||||
"""
|
||||
src = np.array(landmarks, dtype=np.float32)
|
||||
matrix, _ = cv2.estimateAffinePartial2D(src, FACE_TEMPLATE, method=cv2.LMEDS)
|
||||
|
||||
if matrix is None:
|
||||
return float("inf") # type: ignore[unreachable]
|
||||
|
||||
fit = src @ matrix[:, :2].T + matrix[:, 2]
|
||||
return float(np.linalg.norm(fit - FACE_TEMPLATE, axis=1).mean())
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectionResult:
|
||||
"""A face detected by the face detector."""
|
||||
|
||||
# (x1, y1, x2, y2)
|
||||
face: tuple[int, int, int, int]
|
||||
|
||||
# eyes, nose tip, and mouth corners as (x, y) pairs, each pair ordered left
|
||||
# to right in image coordinates to match the arcface template. kept as
|
||||
# floats for sub pixel alignment accuracy
|
||||
landmarks: tuple[tuple[float, float], ...]
|
||||
|
||||
|
||||
class FaceDetector:
|
||||
"""Face detection runner."""
|
||||
|
||||
def __init__(self, on_ready: Callable[[], None] | None = None) -> None:
|
||||
self.detector: cv2.FaceDetectorYN | None = None
|
||||
self.landmark_detector: cv2.face.Facemark | None = None
|
||||
self.on_ready = on_ready
|
||||
|
||||
# both models hold internal state across a call, and the recognizer
|
||||
# builds its class means on a background thread while frames are
|
||||
# still being processed, so calls into them are serialized
|
||||
self.lock = threading.Lock()
|
||||
|
||||
GITHUB_ENDPOINT = os.environ.get("GITHUB_ENDPOINT", "https://github.com")
|
||||
|
||||
self.model_files = {
|
||||
"facedet.onnx": f"{GITHUB_ENDPOINT}/NickM-27/facenet-onnx/releases/download/v1.0/facedet.onnx",
|
||||
"landmarkdet.yaml": f"{GITHUB_ENDPOINT}/NickM-27/facenet-onnx/releases/download/v1.0/landmarkdet.yaml",
|
||||
}
|
||||
|
||||
if not all(
|
||||
os.path.exists(os.path.join(FACE_DET_DIR, n))
|
||||
for n in self.model_files.keys()
|
||||
):
|
||||
# conditionally import ModelDownloader
|
||||
from frigate.util.downloader import ModelDownloader
|
||||
|
||||
self.downloader = ModelDownloader(
|
||||
model_name="facedet",
|
||||
download_path=FACE_DET_DIR,
|
||||
file_names=list(self.model_files.keys()),
|
||||
download_func=self.__download_models,
|
||||
complete_func=self.__build_detector,
|
||||
)
|
||||
self.downloader.ensure_model_files()
|
||||
else:
|
||||
self.__build_detector()
|
||||
|
||||
def __download_models(self, path: str) -> None:
|
||||
try:
|
||||
file_name = os.path.basename(path)
|
||||
# conditionally import ModelDownloader
|
||||
from frigate.util.downloader import ModelDownloader
|
||||
|
||||
ModelDownloader.download_from_url(self.model_files[file_name], path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download {path}: {e}")
|
||||
|
||||
def __build_detector(self) -> None:
|
||||
self.detector = cv2.FaceDetectorYN.create(
|
||||
os.path.join(FACE_DET_DIR, "facedet.onnx"),
|
||||
config="",
|
||||
input_size=(320, 320),
|
||||
score_threshold=0.5,
|
||||
nms_threshold=0.3,
|
||||
)
|
||||
self.__init_landmark_detector()
|
||||
|
||||
if self.on_ready is not None:
|
||||
self.on_ready()
|
||||
|
||||
@property
|
||||
def is_ready(self) -> bool:
|
||||
"""Whether both the detection and landmark models are loaded."""
|
||||
return self.detector is not None and self.landmark_detector is not None
|
||||
|
||||
@redirect_output_to_logger(logger, logging.DEBUG)
|
||||
def __init_landmark_detector(self) -> None:
|
||||
landmark_model = os.path.join(FACE_DET_DIR, "landmarkdet.yaml")
|
||||
|
||||
if os.path.exists(landmark_model):
|
||||
landmark_detector = cv2.face.createFacemarkLBF()
|
||||
landmark_detector.loadModel(landmark_model)
|
||||
self.landmark_detector = landmark_detector
|
||||
|
||||
def detect(self, input: np.ndarray, threshold: float) -> DetectionResult | None:
|
||||
"""Detect the largest face in the input image.
|
||||
|
||||
Args:
|
||||
input: The image to run detection on
|
||||
threshold: Minimum detection confidence to accept a face
|
||||
|
||||
Returns:
|
||||
The largest detected face with its landmarks, or None
|
||||
"""
|
||||
if not self.detector:
|
||||
return None
|
||||
|
||||
height, width = input.shape[:2]
|
||||
|
||||
# YN face detector fails at extreme definitions
|
||||
# this rescales to a size that can properly detect faces
|
||||
# still retaining plenty of detail
|
||||
if height > MAX_DETECTION_HEIGHT:
|
||||
scale_factor = MAX_DETECTION_HEIGHT / height
|
||||
new_width = int(scale_factor * width)
|
||||
input = cv2.resize(input, (new_width, MAX_DETECTION_HEIGHT))
|
||||
else:
|
||||
scale_factor = 1
|
||||
|
||||
with self.lock:
|
||||
self.detector.setInputSize((input.shape[1], input.shape[0]))
|
||||
faces = self.detector.detect(input)
|
||||
|
||||
if faces is None or faces[1] is None:
|
||||
return None # type: ignore[unreachable]
|
||||
|
||||
best: DetectionResult | None = None
|
||||
best_area = 0
|
||||
|
||||
for potential_face in faces[1]:
|
||||
if potential_face[-1] < threshold:
|
||||
continue
|
||||
|
||||
# YuNet reports floats outside of the image for cut off faces, the
|
||||
# far edges are derived before clamping so they don't move with the
|
||||
# clamped near edges
|
||||
raw_x = float(potential_face[0]) / scale_factor
|
||||
raw_y = float(potential_face[1]) / scale_factor
|
||||
bbox = (
|
||||
max(int(raw_x), 0),
|
||||
max(int(raw_y), 0),
|
||||
min(int(raw_x + float(potential_face[2]) / scale_factor), width),
|
||||
min(int(raw_y + float(potential_face[3]) / scale_factor), height),
|
||||
)
|
||||
bbox_area = area(bbox)
|
||||
|
||||
if bbox_area <= best_area:
|
||||
continue
|
||||
|
||||
# landmarks are left unclamped for a more accurate alignment fit
|
||||
best = DetectionResult(
|
||||
face=bbox,
|
||||
landmarks=tuple(
|
||||
(float(x) / scale_factor, float(y) / scale_factor)
|
||||
for x, y in potential_face[4:14].reshape(5, 2)
|
||||
),
|
||||
)
|
||||
best_area = bbox_area
|
||||
|
||||
return best
|
||||
|
||||
def get_face_landmarks(
|
||||
self, input: np.ndarray, threshold: float = 0.5
|
||||
) -> tuple[tuple[float, float], ...] | None:
|
||||
"""Get the alignment landmarks for an image that is already a face crop.
|
||||
|
||||
Args:
|
||||
input: The face crop to get landmarks for
|
||||
threshold: Minimum detection confidence to accept a face
|
||||
|
||||
Returns:
|
||||
Eye, nose, and mouth landmarks, or None
|
||||
"""
|
||||
detection = self.detect(input, threshold)
|
||||
|
||||
if (
|
||||
detection is not None
|
||||
and landmark_fit_error(detection.landmarks) <= MAX_LANDMARK_FIT_ERROR
|
||||
):
|
||||
return detection.landmarks
|
||||
|
||||
# detection either failed, which is common on a crop that is already
|
||||
# tight around the face, or returned landmarks that are not shaped like
|
||||
# a face, so the landmark model is given the whole crop as the face
|
||||
landmarks = self.__fit_landmarks(input)
|
||||
|
||||
if landmarks is None or landmark_fit_error(landmarks) > MAX_LANDMARK_FIT_ERROR:
|
||||
return None
|
||||
|
||||
return landmarks
|
||||
|
||||
def __fit_landmarks(
|
||||
self, input: np.ndarray
|
||||
) -> tuple[tuple[float, float], ...] | None:
|
||||
"""Derive the 5 alignment landmarks from the 68 point landmark model."""
|
||||
if self.landmark_detector is None:
|
||||
return None
|
||||
|
||||
# the landmark model runs on grayscale
|
||||
gray = cv2.cvtColor(input, cv2.COLOR_BGR2GRAY) if input.ndim == 3 else input
|
||||
|
||||
try:
|
||||
with self.lock:
|
||||
success, faces = self.landmark_detector.fit(
|
||||
gray, np.array([(0, 0, gray.shape[1], gray.shape[0])])
|
||||
)
|
||||
except cv2.error:
|
||||
logger.debug("Failed to fit landmarks")
|
||||
return None
|
||||
|
||||
if not success or not len(faces):
|
||||
return None
|
||||
|
||||
points = faces[0][0]
|
||||
|
||||
# each eye is the mean of the 6 points around it
|
||||
return tuple(
|
||||
(float(p[0]), float(p[1]))
|
||||
for p in (
|
||||
points[36:42].mean(axis=0),
|
||||
points[42:48].mean(axis=0),
|
||||
points[30],
|
||||
points[48],
|
||||
points[54],
|
||||
)
|
||||
)
|
||||
+87
-68
@@ -9,18 +9,9 @@ import numpy as np
|
||||
from scipy import stats
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import FACE_DIR
|
||||
from frigate.data_processing.common.face.detector import (
|
||||
FACE_TEMPLATE,
|
||||
FACE_TEMPLATE_SIZE,
|
||||
FaceDetector,
|
||||
)
|
||||
from frigate.embeddings.onnx.face_embedding import (
|
||||
ARCFACE_INPUT_SIZE,
|
||||
FACENET_INPUT_SIZE,
|
||||
ArcfaceEmbedding,
|
||||
FaceNetEmbedding,
|
||||
)
|
||||
from frigate.const import FACE_DIR, MODEL_CACHE_DIR
|
||||
from frigate.embeddings.onnx.face_embedding import ArcfaceEmbedding, FaceNetEmbedding
|
||||
from frigate.log import redirect_output_to_logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,9 +19,10 @@ logger = logging.getLogger(__name__)
|
||||
class FaceRecognizer(ABC):
|
||||
"""Face recognition runner."""
|
||||
|
||||
def __init__(self, config: FrigateConfig, detector: FaceDetector) -> None:
|
||||
def __init__(self, config: FrigateConfig) -> None:
|
||||
self.config = config
|
||||
self.detector = detector
|
||||
self.landmark_detector: cv2.face.Facemark | None = None
|
||||
self.init_landmark_detector()
|
||||
|
||||
@abstractmethod
|
||||
def build(self) -> None:
|
||||
@@ -46,38 +38,79 @@ class FaceRecognizer(ABC):
|
||||
def classify(self, face_image: np.ndarray) -> tuple[str, float] | None:
|
||||
pass
|
||||
|
||||
def align_face(self, image: np.ndarray, output_size: int) -> np.ndarray | None:
|
||||
"""Warp a face onto the template the embedding model was trained on.
|
||||
@redirect_output_to_logger(logger, logging.DEBUG) # type: ignore[misc]
|
||||
def init_landmark_detector(self) -> None:
|
||||
landmark_model = os.path.join(MODEL_CACHE_DIR, "facedet/landmarkdet.yaml")
|
||||
|
||||
Args:
|
||||
image: The face crop to align
|
||||
output_size: Width and height of the model input
|
||||
if os.path.exists(landmark_model):
|
||||
landmark_detector = cv2.face.createFacemarkLBF()
|
||||
landmark_detector.loadModel(landmark_model)
|
||||
self.landmark_detector = landmark_detector
|
||||
|
||||
Returns:
|
||||
The aligned face, or None if it could not be aligned
|
||||
"""
|
||||
landmarks = self.detector.get_face_landmarks(image)
|
||||
def align_face(
|
||||
self,
|
||||
image: np.ndarray,
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
) -> np.ndarray:
|
||||
if not self.landmark_detector:
|
||||
raise ValueError("Landmark detector not initialized")
|
||||
|
||||
if landmarks is None:
|
||||
return None
|
||||
# landmark is run on grayscale images
|
||||
if image.ndim == 3:
|
||||
land_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
else:
|
||||
land_image = image
|
||||
|
||||
# fitting all 5 points constrains rotation, scale, and position, an eye
|
||||
# line alone leaves them free to slip on the small faces from a camera
|
||||
matrix, _ = cv2.estimateAffinePartial2D(
|
||||
np.array(landmarks, dtype=np.float32),
|
||||
FACE_TEMPLATE * (output_size / FACE_TEMPLATE_SIZE),
|
||||
method=cv2.LMEDS,
|
||||
_, lands = self.landmark_detector.fit(
|
||||
land_image, np.array([(0, 0, land_image.shape[1], land_image.shape[0])])
|
||||
)
|
||||
landmarks: np.ndarray = lands[0][0]
|
||||
|
||||
# the fit fails on degenerate landmarks even though the stub says
|
||||
# otherwise, for example when every point collapses onto one pixel
|
||||
if matrix is None:
|
||||
return None # type: ignore[unreachable]
|
||||
# get landmarks for eyes
|
||||
leftEyePts = landmarks[42:48]
|
||||
rightEyePts = landmarks[36:42]
|
||||
|
||||
# the output is already the model input size, so the embedder's resize
|
||||
# and letterbox padding are a no op
|
||||
# compute the center of mass for each eye
|
||||
leftEyeCenter = leftEyePts.mean(axis=0).astype("int")
|
||||
rightEyeCenter = rightEyePts.mean(axis=0).astype("int")
|
||||
|
||||
# compute the angle between the eye centroids
|
||||
dY = rightEyeCenter[1] - leftEyeCenter[1]
|
||||
dX = rightEyeCenter[0] - leftEyeCenter[0]
|
||||
angle = np.degrees(np.arctan2(dY, dX)) - 180
|
||||
|
||||
# compute the desired right eye x-coordinate based on the
|
||||
# desired x-coordinate of the left eye
|
||||
desiredRightEyeX = 1.0 - 0.35
|
||||
|
||||
# determine the scale of the new resulting image by taking
|
||||
# the ratio of the distance between eyes in the *current*
|
||||
# image to the ratio of distance between eyes in the
|
||||
# *desired* image
|
||||
dist = np.sqrt((dX**2) + (dY**2))
|
||||
desiredDist = desiredRightEyeX - 0.35
|
||||
desiredDist *= output_width
|
||||
scale = desiredDist / dist
|
||||
|
||||
# compute center (x, y)-coordinates (i.e., the median point)
|
||||
# between the two eyes in the input image
|
||||
# grab the rotation matrix for rotating and scaling the face
|
||||
eyesCenter = (
|
||||
int((leftEyeCenter[0] + rightEyeCenter[0]) // 2),
|
||||
int((leftEyeCenter[1] + rightEyeCenter[1]) // 2),
|
||||
)
|
||||
M = cv2.getRotationMatrix2D(eyesCenter, angle, scale)
|
||||
|
||||
# update the translation component of the matrix
|
||||
tX = output_width * 0.5
|
||||
tY = output_height * 0.35
|
||||
M[0, 2] += tX - eyesCenter[0]
|
||||
M[1, 2] += tY - eyesCenter[1]
|
||||
|
||||
# apply the affine transformation
|
||||
return cv2.warpAffine(
|
||||
image, matrix, (output_size, output_size), flags=cv2.INTER_CUBIC
|
||||
image, M, (output_width, output_height), flags=cv2.INTER_CUBIC
|
||||
)
|
||||
|
||||
def get_blur_confidence_reduction(self, input: np.ndarray) -> float:
|
||||
@@ -184,8 +217,8 @@ def similarity_to_confidence(
|
||||
|
||||
|
||||
class FaceNetRecognizer(FaceRecognizer):
|
||||
def __init__(self, config: FrigateConfig, detector: FaceDetector):
|
||||
super().__init__(config, detector)
|
||||
def __init__(self, config: FrigateConfig):
|
||||
super().__init__(config)
|
||||
self.mean_embs: dict[str, np.ndarray] = {}
|
||||
self.face_embedder: FaceNetEmbedding = FaceNetEmbedding()
|
||||
self.model_builder_queue: queue.Queue | None = None
|
||||
@@ -217,12 +250,8 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
if img is None:
|
||||
continue # type: ignore[unreachable]
|
||||
|
||||
aligned = self.align_face(img, FACENET_INPUT_SIZE)
|
||||
|
||||
if aligned is None:
|
||||
continue
|
||||
|
||||
emb = self.face_embedder([aligned])[0].squeeze()
|
||||
img = self.align_face(img, img.shape[1], img.shape[0])
|
||||
emb = self.face_embedder([img])[0].squeeze()
|
||||
face_embeddings_map[name].append(emb)
|
||||
|
||||
idx += 1
|
||||
@@ -234,7 +263,8 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
thread.start()
|
||||
|
||||
def build(self) -> None:
|
||||
if not self.detector.is_ready:
|
||||
if not self.landmark_detector:
|
||||
self.init_landmark_detector()
|
||||
return None
|
||||
|
||||
if self.model_builder_queue is not None:
|
||||
@@ -259,7 +289,7 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
logger.debug("Finished building ArcFace model")
|
||||
|
||||
def classify(self, face_image: np.ndarray) -> tuple[str, float] | None:
|
||||
if not self.detector.is_ready:
|
||||
if not self.landmark_detector:
|
||||
return None
|
||||
|
||||
if not self.mean_embs:
|
||||
@@ -274,11 +304,7 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
blur_reduction = self.get_blur_confidence_reduction(face_image)
|
||||
|
||||
# align face and run recognition
|
||||
img = self.align_face(face_image, FACENET_INPUT_SIZE)
|
||||
|
||||
if img is None:
|
||||
return None
|
||||
|
||||
img = self.align_face(face_image, face_image.shape[1], face_image.shape[0])
|
||||
embedding = self.face_embedder([img])[0].squeeze()
|
||||
|
||||
score: float = 0
|
||||
@@ -302,8 +328,8 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
|
||||
|
||||
class ArcFaceRecognizer(FaceRecognizer):
|
||||
def __init__(self, config: FrigateConfig, detector: FaceDetector):
|
||||
super().__init__(config, detector)
|
||||
def __init__(self, config: FrigateConfig):
|
||||
super().__init__(config)
|
||||
self.mean_embs: dict[str, np.ndarray] = {}
|
||||
self.face_embedder: ArcfaceEmbedding = ArcfaceEmbedding(config.face_recognition)
|
||||
self.model_builder_queue: queue.Queue | None = None
|
||||
@@ -335,12 +361,8 @@ class ArcFaceRecognizer(FaceRecognizer):
|
||||
if img is None:
|
||||
continue # type: ignore[unreachable]
|
||||
|
||||
aligned = self.align_face(img, ARCFACE_INPUT_SIZE)
|
||||
|
||||
if aligned is None:
|
||||
continue
|
||||
|
||||
emb = self.face_embedder([aligned])[0].squeeze() # type: ignore[arg-type]
|
||||
img = self.align_face(img, img.shape[1], img.shape[0])
|
||||
emb = self.face_embedder([img])[0].squeeze() # type: ignore[arg-type]
|
||||
face_embeddings_map[name].append(emb)
|
||||
|
||||
idx += 1
|
||||
@@ -352,7 +374,8 @@ class ArcFaceRecognizer(FaceRecognizer):
|
||||
thread.start()
|
||||
|
||||
def build(self) -> None:
|
||||
if not self.detector.is_ready:
|
||||
if not self.landmark_detector:
|
||||
self.init_landmark_detector()
|
||||
return None
|
||||
|
||||
if self.model_builder_queue is not None:
|
||||
@@ -377,7 +400,7 @@ class ArcFaceRecognizer(FaceRecognizer):
|
||||
logger.debug("Finished building ArcFace model")
|
||||
|
||||
def classify(self, face_image: np.ndarray) -> tuple[str, float] | None:
|
||||
if not self.detector.is_ready:
|
||||
if not self.landmark_detector:
|
||||
return None
|
||||
|
||||
if not self.mean_embs:
|
||||
@@ -392,11 +415,7 @@ class ArcFaceRecognizer(FaceRecognizer):
|
||||
blur_reduction = self.get_blur_confidence_reduction(face_image)
|
||||
|
||||
# align face and run recognition
|
||||
img = self.align_face(face_image, ARCFACE_INPUT_SIZE)
|
||||
|
||||
if img is None:
|
||||
return None
|
||||
|
||||
img = self.align_face(face_image, face_image.shape[1], face_image.shape[0])
|
||||
embedding = self.face_embedder([img])[0].squeeze() # type: ignore[arg-type]
|
||||
|
||||
score: float = 0
|
||||
@@ -253,7 +253,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
# Crop snapshot based on region
|
||||
# provide full image if region doesn't exist (manual events)
|
||||
height, width = img.shape[:2]
|
||||
x1_rel, y1_rel, width_rel, height_rel = event.data.get(
|
||||
x1_rel, y1_rel, width_rel, height_rel = event.data.get( # type: ignore[attr-defined]
|
||||
"region", [0, 0, 1, 1]
|
||||
)
|
||||
x1, y1 = int(x1_rel * width), int(y1_rel * height)
|
||||
|
||||
@@ -8,11 +8,10 @@ import os
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
from peewee import DoesNotExist
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
from titlecase import titlecase
|
||||
|
||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
||||
@@ -168,9 +167,17 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
image_source = camera_config.review.genai.image_source
|
||||
|
||||
if image_source == ImageSourceEnum.recordings:
|
||||
buffer_extension = get_recording_buffer_extension(
|
||||
final_data["end_time"] - final_data["start_time"]
|
||||
)
|
||||
duration = final_data["end_time"] - final_data["start_time"]
|
||||
buffer_extension = min(5, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
|
||||
|
||||
# Ensure minimum total duration for short review items
|
||||
# This provides better context for brief events
|
||||
total_duration = duration + (2 * buffer_extension)
|
||||
if total_duration < MIN_RECORDING_DURATION:
|
||||
# Expand buffer to reach minimum duration, still respecting max of 5s per side
|
||||
additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2
|
||||
buffer_extension = min(5, additional_buffer_per_side)
|
||||
|
||||
final_data["start_time"] -= buffer_extension
|
||||
final_data["end_time"] += buffer_extension
|
||||
|
||||
@@ -195,7 +202,16 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
camera_config.review.genai.debug_save_thumbnails,
|
||||
)
|
||||
elif camera_config.review.genai.debug_save_thumbnails:
|
||||
self.save_debug_recording_frames(id, thumbs)
|
||||
# Save debug thumbnails for recordings
|
||||
Path(os.path.join(CLIPS_DIR, "genai-requests", id)).mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
for idx, frame_bytes in enumerate(thumbs):
|
||||
with open(
|
||||
os.path.join(CLIPS_DIR, f"genai-requests/{id}/{idx}.jpg"),
|
||||
"wb",
|
||||
) as f:
|
||||
f.write(frame_bytes)
|
||||
else:
|
||||
# Use preview frames
|
||||
thumbs = self.get_preview_frames_as_bytes(
|
||||
@@ -207,23 +223,25 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
camera_config.review.genai.debug_save_thumbnails,
|
||||
)
|
||||
|
||||
self.start_analysis(camera_config, final_data, thumbs)
|
||||
# kickoff analysis
|
||||
self.review_desc_dps.update()
|
||||
threading.Thread(
|
||||
target=run_analysis,
|
||||
args=(
|
||||
self.requestor,
|
||||
self.genai_manager.description_client,
|
||||
self.review_desc_speed,
|
||||
camera_config,
|
||||
final_data,
|
||||
thumbs,
|
||||
camera_config.review.genai,
|
||||
sorted(self.config.all_labels),
|
||||
self.config.all_attributes,
|
||||
),
|
||||
).start()
|
||||
|
||||
def handle_request(self, topic: str, request_data: dict[str, Any]) -> str | None:
|
||||
if topic == EmbeddingsRequestEnum.regenerate_review_description.value:
|
||||
review_id = request_data["review_id"]
|
||||
logger.debug("Found GenAI Review description request for %s", review_id)
|
||||
|
||||
# frame extraction shells out to ffmpeg once per frame, so run the
|
||||
# whole thing off the maintainer loop and answer the caller now
|
||||
threading.Thread(
|
||||
target=self.regenerate_description,
|
||||
name=f"regenerate_review_description_{review_id}",
|
||||
daemon=True,
|
||||
args=(review_id,),
|
||||
).start()
|
||||
return "started"
|
||||
elif topic == EmbeddingsRequestEnum.summarize_review.value:
|
||||
if topic == EmbeddingsRequestEnum.summarize_review.value:
|
||||
start_ts = request_data["start_ts"]
|
||||
end_ts = request_data["end_ts"]
|
||||
logger.debug(
|
||||
@@ -342,104 +360,6 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
else:
|
||||
return None
|
||||
|
||||
def regenerate_description(self, review_id: str) -> None:
|
||||
"""Re-run a finished review item through the description process.
|
||||
|
||||
Frames always come from recordings: preview frames only live in the
|
||||
cache briefly and are much harder to sample from once they have been
|
||||
compressed into a preview clip. Alerts and detections are both accepted
|
||||
regardless of the per-camera alerts/detections toggles, since the run
|
||||
was asked for explicitly.
|
||||
"""
|
||||
client = self.genai_manager.description_client
|
||||
|
||||
if client is None:
|
||||
logger.error("No GenAI provider is assigned the descriptions role")
|
||||
return
|
||||
|
||||
try:
|
||||
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||
except DoesNotExist:
|
||||
logger.error(
|
||||
"Review item %s not found for description generation", review_id
|
||||
)
|
||||
return
|
||||
|
||||
camera_config = self.config.cameras.get(str(review.camera))
|
||||
|
||||
if camera_config is None:
|
||||
logger.error("Camera %s no longer exists", review.camera)
|
||||
return
|
||||
|
||||
if not camera_config.review.genai.enabled:
|
||||
logger.error(
|
||||
"GenAI review descriptions are not enabled for %s", review.camera
|
||||
)
|
||||
return
|
||||
|
||||
final_data = model_to_dict(review)
|
||||
|
||||
if final_data["end_time"] is None:
|
||||
logger.error("Review item %s has not ended yet", review_id)
|
||||
return
|
||||
|
||||
buffer_extension = get_recording_buffer_extension(
|
||||
final_data["end_time"] - final_data["start_time"]
|
||||
)
|
||||
thumbs = self.get_recording_frames(
|
||||
str(review.camera),
|
||||
final_data["start_time"] - buffer_extension,
|
||||
final_data["end_time"] + buffer_extension,
|
||||
height=480,
|
||||
)
|
||||
|
||||
if not thumbs:
|
||||
logger.error(
|
||||
"No recording frames are available for review item %s", review_id
|
||||
)
|
||||
return
|
||||
|
||||
if camera_config.review.genai.debug_save_thumbnails:
|
||||
self.save_debug_recording_frames(review_id, thumbs)
|
||||
|
||||
self.start_analysis(camera_config, final_data, thumbs)
|
||||
|
||||
def start_analysis(
|
||||
self,
|
||||
camera_config: CameraConfig,
|
||||
final_data: dict[str, Any],
|
||||
thumbs: list[bytes],
|
||||
) -> None:
|
||||
"""Kick off description generation for a review item in the background."""
|
||||
self.review_desc_dps.update()
|
||||
threading.Thread(
|
||||
target=run_analysis,
|
||||
args=(
|
||||
self.requestor,
|
||||
self.genai_manager.description_client,
|
||||
self.review_desc_speed,
|
||||
camera_config,
|
||||
final_data,
|
||||
thumbs,
|
||||
camera_config.review.genai,
|
||||
sorted(self.config.all_labels),
|
||||
self.config.all_attributes,
|
||||
),
|
||||
).start()
|
||||
|
||||
def save_debug_recording_frames(self, review_id: str, thumbs: list[bytes]) -> None:
|
||||
"""Write the recording frames sent to the provider out for debugging."""
|
||||
Path(os.path.join(CLIPS_DIR, "genai-requests", review_id)).mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
|
||||
for idx, frame_bytes in enumerate(thumbs):
|
||||
with open(
|
||||
os.path.join(CLIPS_DIR, f"genai-requests/{review_id}/{idx}.jpg"),
|
||||
"wb",
|
||||
) as f:
|
||||
f.write(frame_bytes)
|
||||
|
||||
def get_cache_frames(
|
||||
self,
|
||||
camera: str,
|
||||
@@ -528,8 +448,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
.get()
|
||||
)
|
||||
|
||||
# start_time is a DateTimeField holding a unix timestamp
|
||||
time_in_segment = ts - cast(float, recording.start_time)
|
||||
time_in_segment = ts - recording.start_time
|
||||
return get_image_from_recording(
|
||||
self.config.ffmpeg,
|
||||
recording.path,
|
||||
@@ -620,20 +539,6 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
return thumbs
|
||||
|
||||
|
||||
def get_recording_buffer_extension(duration: float) -> float:
|
||||
"""Seconds of padding to add to each side of a review item when pulling
|
||||
recording frames, so brief items still carry enough context."""
|
||||
buffer_extension = min(5, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
|
||||
|
||||
# Ensure minimum total duration for short review items
|
||||
# This provides better context for brief events
|
||||
if duration + (2 * buffer_extension) < MIN_RECORDING_DURATION:
|
||||
# Expand buffer to reach minimum duration, still respecting max of 5s per side
|
||||
buffer_extension = min(5, (MIN_RECORDING_DURATION - duration) / 2)
|
||||
|
||||
return buffer_extension
|
||||
|
||||
|
||||
def run_analysis(
|
||||
requestor: InterProcessRequestor,
|
||||
genai_client: GenAIClient,
|
||||
@@ -696,7 +601,6 @@ def run_analysis(
|
||||
genai_config.preferred_language,
|
||||
genai_config.debug_save_thumbnails,
|
||||
genai_config.activity_context_prompt,
|
||||
genai_config.response_style,
|
||||
)
|
||||
review_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ class SemanticTriggerProcessor(PostProcessorApi):
|
||||
return
|
||||
|
||||
# Skip the event if not an object
|
||||
if event.data.get("type") != "object":
|
||||
if event.data.get("type") != "object": # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
thumbnail_bytes = get_event_thumbnail_bytes(event)
|
||||
|
||||
@@ -16,7 +16,6 @@ from frigate.config.classification import CustomClassificationConfig
|
||||
from frigate.const import CLIPS_DIR, MODEL_CACHE_DIR
|
||||
from frigate.log import suppress_stderr_during
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, load_labels
|
||||
from frigate.util.file import trim_oldest_files
|
||||
from frigate.util.image import calculate_region
|
||||
from frigate.util.object import box_overlaps
|
||||
|
||||
@@ -730,4 +729,16 @@ def write_classification_attempt(
|
||||
file = os.path.join(folder, f"{event_id}-{timestamp}-{label}-{score}.webp")
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
cv2.imwrite(file, frame)
|
||||
trim_oldest_files(folder, max_files)
|
||||
|
||||
# delete oldest face image if maximum is reached
|
||||
try:
|
||||
files = sorted(
|
||||
filter(lambda f: f.endswith(".webp"), os.listdir(folder)),
|
||||
key=lambda f: os.path.getctime(os.path.join(folder, f)),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
if len(files) > max_files:
|
||||
os.unlink(os.path.join(folder, files[-1]))
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
@@ -18,16 +19,14 @@ from frigate.comms.event_metadata_updater import (
|
||||
)
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import FACE_DIR
|
||||
from frigate.data_processing.common.face.detector import FaceDetector
|
||||
from frigate.data_processing.common.face.recognizer import (
|
||||
from frigate.const import FACE_DIR, MODEL_CACHE_DIR
|
||||
from frigate.data_processing.common.face.model import (
|
||||
ArcFaceRecognizer,
|
||||
FaceNetRecognizer,
|
||||
FaceRecognizer,
|
||||
)
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
|
||||
from frigate.util.file import trim_oldest_files
|
||||
from frigate.util.image import area
|
||||
from frigate.util.path import safe_join, sanitize_path_component
|
||||
|
||||
@@ -37,6 +36,7 @@ from .api import RealTimeProcessorApi
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
MAX_DETECTION_HEIGHT = 1080
|
||||
MAX_FACES_ATTEMPTS_AFTER_REC = 6
|
||||
MAX_FACE_ATTEMPTS = 12
|
||||
|
||||
@@ -53,6 +53,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.face_config = config.face_recognition
|
||||
self.requestor = requestor
|
||||
self.sub_label_publisher = sub_label_publisher
|
||||
self.face_detector: cv2.FaceDetectorYN | None = None
|
||||
self.requires_face_detection = "face" not in self.config.objects.all_objects
|
||||
self.person_face_history: dict[str, list[tuple[str, float, int]]] = {}
|
||||
self.camera_current_people: dict[str, list[str]] = {}
|
||||
@@ -60,14 +61,38 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.faces_per_second = EventsPerSecond()
|
||||
self.inference_speed = InferenceSpeed(self.metrics.face_rec_speed)
|
||||
|
||||
self.face_detector = FaceDetector(on_ready=self.faces_per_second.start)
|
||||
GITHUB_ENDPOINT = os.environ.get("GITHUB_ENDPOINT", "https://github.com")
|
||||
|
||||
download_path = os.path.join(MODEL_CACHE_DIR, "facedet")
|
||||
self.model_files = {
|
||||
"facedet.onnx": f"{GITHUB_ENDPOINT}/NickM-27/facenet-onnx/releases/download/v1.0/facedet.onnx",
|
||||
"landmarkdet.yaml": f"{GITHUB_ENDPOINT}/NickM-27/facenet-onnx/releases/download/v1.0/landmarkdet.yaml",
|
||||
}
|
||||
|
||||
if not all(
|
||||
os.path.exists(os.path.join(download_path, n))
|
||||
for n in self.model_files.keys()
|
||||
):
|
||||
# conditionally import ModelDownloader
|
||||
from frigate.util.downloader import ModelDownloader
|
||||
|
||||
self.downloader = ModelDownloader(
|
||||
model_name="facedet",
|
||||
download_path=download_path,
|
||||
file_names=list(self.model_files.keys()),
|
||||
download_func=self.__download_models,
|
||||
complete_func=self.__build_detector,
|
||||
)
|
||||
self.downloader.ensure_model_files()
|
||||
else:
|
||||
self.__build_detector()
|
||||
|
||||
self.label_map: dict[int, str] = {}
|
||||
|
||||
if self.face_config.model_size == "small":
|
||||
self.recognizer = FaceNetRecognizer(self.config, self.face_detector)
|
||||
self.recognizer = FaceNetRecognizer(self.config)
|
||||
else:
|
||||
self.recognizer = ArcFaceRecognizer(self.config, self.face_detector)
|
||||
self.recognizer = ArcFaceRecognizer(self.config)
|
||||
|
||||
self.recognizer.build()
|
||||
|
||||
@@ -88,6 +113,67 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
logger.debug("Face recognition config updated dynamically")
|
||||
|
||||
def __download_models(self, path: str) -> None:
|
||||
try:
|
||||
file_name = os.path.basename(path)
|
||||
# conditionally import ModelDownloader
|
||||
from frigate.util.downloader import ModelDownloader
|
||||
|
||||
ModelDownloader.download_from_url(self.model_files[file_name], path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download {path}: {e}")
|
||||
|
||||
def __build_detector(self) -> None:
|
||||
self.face_detector = cv2.FaceDetectorYN.create(
|
||||
os.path.join(MODEL_CACHE_DIR, "facedet/facedet.onnx"),
|
||||
config="",
|
||||
input_size=(320, 320),
|
||||
score_threshold=0.5,
|
||||
nms_threshold=0.3,
|
||||
)
|
||||
self.faces_per_second.start()
|
||||
|
||||
def __detect_face(
|
||||
self, input: np.ndarray, threshold: float
|
||||
) -> tuple[int, int, int, int] | None:
|
||||
"""Detect faces in input image."""
|
||||
if not self.face_detector:
|
||||
return None
|
||||
|
||||
# YN face detector fails at extreme definitions
|
||||
# this rescales to a size that can properly detect faces
|
||||
# still retaining plenty of detail
|
||||
if input.shape[0] > MAX_DETECTION_HEIGHT:
|
||||
scale_factor = MAX_DETECTION_HEIGHT / input.shape[0]
|
||||
new_width = int(scale_factor * input.shape[1])
|
||||
input = cv2.resize(input, (new_width, MAX_DETECTION_HEIGHT))
|
||||
else:
|
||||
scale_factor = 1
|
||||
|
||||
self.face_detector.setInputSize((input.shape[1], input.shape[0]))
|
||||
faces = self.face_detector.detect(input)
|
||||
|
||||
if faces is None or faces[1] is None:
|
||||
return None # type: ignore[unreachable]
|
||||
|
||||
face = None
|
||||
|
||||
for _, potential_face in enumerate(faces[1]):
|
||||
if potential_face[-1] < threshold:
|
||||
continue
|
||||
|
||||
raw_bbox = potential_face[0:4].astype(np.uint16)
|
||||
x: int = int(max(raw_bbox[0], 0) / scale_factor)
|
||||
y: int = int(max(raw_bbox[1], 0) / scale_factor)
|
||||
w: int = int(raw_bbox[2] / scale_factor)
|
||||
h: int = int(raw_bbox[3] / scale_factor)
|
||||
bbox = (x, y, x + w, y + h)
|
||||
|
||||
if face is None or area(bbox) > area(face): # type: ignore[unreachable]
|
||||
face = bbox
|
||||
|
||||
return face
|
||||
|
||||
def __update_metrics(self, duration: float) -> None:
|
||||
self.faces_per_second.update()
|
||||
self.inference_speed.update(duration)
|
||||
@@ -135,7 +221,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
return
|
||||
|
||||
face: dict[str, Any] | None = None
|
||||
face_box: tuple[int, int, int, int]
|
||||
|
||||
if self.requires_face_detection:
|
||||
logger.debug("Running manual face detection.")
|
||||
@@ -149,15 +234,12 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
bgr = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
|
||||
left, top, right, bottom = person_box
|
||||
person = bgr[top:bottom, left:right]
|
||||
detection = self.face_detector.detect(
|
||||
person, self.face_config.detection_threshold
|
||||
)
|
||||
face_box = self.__detect_face(person, self.face_config.detection_threshold)
|
||||
|
||||
if detection is None:
|
||||
if not face_box:
|
||||
logger.debug("Detected no faces for person object.")
|
||||
return
|
||||
|
||||
face_box = detection.face
|
||||
face_frame = person[
|
||||
max(0, face_box[1]) : min(frame.shape[0], face_box[3]),
|
||||
max(0, face_box[0]) : min(frame.shape[1], face_box[2]),
|
||||
@@ -189,19 +271,17 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
logger.debug(f"No face attributes found for {id}")
|
||||
return
|
||||
|
||||
attr_box = face.get("box")
|
||||
face_box = face.get("box")
|
||||
|
||||
# check that face is valid
|
||||
if (
|
||||
not attr_box
|
||||
or area(attr_box)
|
||||
not face_box
|
||||
or area(face_box)
|
||||
< self.config.cameras[camera].face_recognition.min_area
|
||||
):
|
||||
logger.debug(f"Invalid face box {face}")
|
||||
return
|
||||
|
||||
face_box = attr_box
|
||||
|
||||
face_frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
|
||||
|
||||
face_frame = face_frame[
|
||||
@@ -284,12 +364,11 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
# detect faces with lower confidence since we expect the face
|
||||
# to be visible in uploaded images
|
||||
detection = self.face_detector.detect(img, 0.5)
|
||||
face_box = self.__detect_face(img, 0.5)
|
||||
|
||||
if detection is None:
|
||||
if not face_box:
|
||||
return {"message": "No face was detected.", "success": False}
|
||||
|
||||
face_box = detection.face
|
||||
face = img[face_box[1] : face_box[3], face_box[0] : face_box[2]]
|
||||
res = self.recognizer.classify(face)
|
||||
|
||||
@@ -317,15 +396,14 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
# detect faces with lower confidence since we expect the face
|
||||
# to be visible in uploaded images
|
||||
detection = self.face_detector.detect(img, 0.5)
|
||||
face_box = self.__detect_face(img, 0.5)
|
||||
|
||||
if detection is None:
|
||||
if not face_box:
|
||||
return {
|
||||
"message": "No face was detected.",
|
||||
"success": False,
|
||||
}
|
||||
|
||||
face_box = detection.face
|
||||
face = img[face_box[1] : face_box[3], face_box[0] : face_box[2]]
|
||||
_, thumbnail = cv2.imencode(
|
||||
".webp", face, [int(cv2.IMWRITE_WEBP_QUALITY), 100]
|
||||
@@ -489,4 +567,13 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
cv2.imwrite(file, frame)
|
||||
trim_oldest_files(folder, self.config.face_recognition.save_attempts)
|
||||
|
||||
files = sorted(
|
||||
filter(lambda f: f.endswith(".webp"), os.listdir(folder)),
|
||||
key=lambda f: os.path.getctime(os.path.join(folder, f)),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# delete oldest face image if maximum is reached
|
||||
if len(files) > self.config.face_recognition.save_attempts:
|
||||
Path(os.path.join(folder, files[-1])).unlink(missing_ok=True)
|
||||
|
||||
@@ -28,7 +28,6 @@ class DataProcessorMetrics:
|
||||
object_desc_dps: ValueProxy[float]
|
||||
classification_speeds: DictProxy[str, ValueProxy[float]]
|
||||
classification_cps: DictProxy[str, ValueProxy[float]]
|
||||
runtime_devices: DictProxy[str, str]
|
||||
|
||||
def __init__(self, manager: SyncManager, custom_classification_models: list[str]):
|
||||
self.image_embeddings_speed = manager.Value("d", 0.0)
|
||||
@@ -47,7 +46,6 @@ class DataProcessorMetrics:
|
||||
self.object_desc_dps = manager.Value("d", 0.0)
|
||||
self.classification_speeds = manager.dict()
|
||||
self.classification_cps = manager.dict()
|
||||
self.runtime_devices = manager.dict()
|
||||
|
||||
if custom_classification_models:
|
||||
for key in custom_classification_models:
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import ClassVar
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
|
||||
from frigate.util.runtime_deps import RuntimeManifest, activate, ensure_installed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,10 +12,6 @@ class DetectionApi(ABC):
|
||||
type_key: str
|
||||
supported_models: list[ModelTypeEnum]
|
||||
|
||||
# pinned SDK artifacts that are installed at runtime instead of being
|
||||
# shipped in the image; None when the runtime is already available
|
||||
runtime_manifest: ClassVar[RuntimeManifest | None] = None
|
||||
|
||||
@abstractmethod
|
||||
def __init__(self, detector_config: BaseDetectorConfig):
|
||||
self.detector_config = detector_config
|
||||
@@ -29,23 +23,6 @@ class DetectionApi(ABC):
|
||||
def detect_raw(self, tensor_input):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def ensure_dependencies(cls) -> None:
|
||||
"""Download and install this detector's runtime if it is not present.
|
||||
|
||||
Runs once in the main process before detector processes start, so a
|
||||
single install serves every process and the user site is on sys.path
|
||||
before it is inherited.
|
||||
"""
|
||||
if cls.runtime_manifest is not None:
|
||||
ensure_installed(cls.runtime_manifest)
|
||||
|
||||
@classmethod
|
||||
def activate_dependencies(cls) -> None:
|
||||
"""Make the installed runtime importable in the current process."""
|
||||
if cls.runtime_manifest is not None:
|
||||
activate(cls.runtime_manifest)
|
||||
|
||||
def calculate_grids_strides(self, expanded=True) -> None:
|
||||
grids = []
|
||||
expanded_strides = []
|
||||
|
||||
@@ -18,36 +18,6 @@ logger = logging.getLogger(__name__)
|
||||
# Process-wide lock serializing all OpenVINO compile/inference calls
|
||||
_OPENVINO_LOCK = threading.Lock()
|
||||
|
||||
# model file path -> (model type, device the runner actually loaded on); the
|
||||
# embeddings maintainer folds this per enrichment for the stats endpoint.
|
||||
# Models load on several threads (reindex, lazy first use), so writes and
|
||||
# snapshots go through the lock.
|
||||
loaded_devices: dict[str, tuple[str, str]] = {}
|
||||
_loaded_devices_lock = threading.Lock()
|
||||
|
||||
|
||||
def record_loaded_device(model_path: str, model_type: str, device: str) -> None:
|
||||
"""Record the device a model loaded on, for the enrichment stats."""
|
||||
with _loaded_devices_lock:
|
||||
loaded_devices[model_path] = (model_type, device)
|
||||
|
||||
logger.info("Loaded %s model on %s", model_type, device)
|
||||
|
||||
|
||||
def snapshot_loaded_devices() -> dict[str, tuple[str, str]]:
|
||||
"""A copy that is safe to iterate while other threads load models."""
|
||||
with _loaded_devices_lock:
|
||||
return dict(loaded_devices)
|
||||
|
||||
|
||||
_PROVIDER_LABELS = {
|
||||
"CUDAExecutionProvider": "CUDA",
|
||||
"TensorrtExecutionProvider": "TensorRT",
|
||||
"MIGraphXExecutionProvider": "MIGraphX",
|
||||
"OpenVINOExecutionProvider": "OpenVINO",
|
||||
"CPUExecutionProvider": "CPU",
|
||||
}
|
||||
|
||||
|
||||
def is_arm64_platform() -> bool:
|
||||
"""Check if we're running on an ARM platform."""
|
||||
@@ -147,12 +117,6 @@ class BaseModelRunner(ABC):
|
||||
"""Run inference with the model."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def device_name(self) -> str:
|
||||
"""Short label of the device the model actually loaded on."""
|
||||
pass
|
||||
|
||||
|
||||
class ONNXModelRunner(BaseModelRunner):
|
||||
"""Run ONNX models using ONNX Runtime."""
|
||||
@@ -210,18 +174,6 @@ class ONNXModelRunner(BaseModelRunner):
|
||||
|
||||
return self.ort.run(None, input)
|
||||
|
||||
@property
|
||||
def device_name(self) -> str:
|
||||
providers = self.ort.get_providers()
|
||||
|
||||
if not providers:
|
||||
return "CPU"
|
||||
|
||||
provider = providers[0]
|
||||
return _PROVIDER_LABELS.get(
|
||||
provider, provider.removesuffix("ExecutionProvider")
|
||||
)
|
||||
|
||||
|
||||
class CudaGraphRunner(BaseModelRunner):
|
||||
"""Encapsulates CUDA Graph capture and replay using ONNX Runtime IOBinding.
|
||||
@@ -247,20 +199,15 @@ class CudaGraphRunner(BaseModelRunner):
|
||||
EnrichmentModelTypeEnum.yolov9_license_plate.value,
|
||||
]
|
||||
|
||||
# ORT performs two regular runs before it starts capturing, but on some
|
||||
# driver / cuDNN combinations the arena still has to extend on the run that
|
||||
# captures, and cudaMalloc is not allowed during capture. Running with
|
||||
# capture disabled first keeps those allocations outside of the capture.
|
||||
GRAPH_FREE_WARMUP_RUNS = 2
|
||||
|
||||
def __init__(self, session: ort.InferenceSession, cuda_device_id: int):
|
||||
self._session = session
|
||||
self._cuda_device_id = cuda_device_id
|
||||
self._prepared = False
|
||||
self._captured = False
|
||||
self._io_binding: ort.IOBinding | None = None
|
||||
self._input_name: str | None = None
|
||||
self._output_names: list[str] | None = None
|
||||
self._input_ortvalue: ort.OrtValue | None = None
|
||||
self._output_ortvalues: ort.OrtValue | None = None
|
||||
|
||||
def get_input_names(self) -> list[str]:
|
||||
"""Get input names for the model."""
|
||||
@@ -270,49 +217,39 @@ class CudaGraphRunner(BaseModelRunner):
|
||||
"""Get the input width of the model."""
|
||||
return self._session.get_inputs()[0].shape[3]
|
||||
|
||||
def _prepare(self, input_name: str, tensor_input: np.ndarray) -> None:
|
||||
"""Bind CUDA buffers and warm the session up with capture disabled."""
|
||||
self._io_binding = self._session.io_binding()
|
||||
self._input_name = input_name
|
||||
self._output_names = [o.name for o in self._session.get_outputs()]
|
||||
|
||||
self._input_ortvalue = ort.OrtValue.ortvalue_from_numpy(
|
||||
tensor_input, "cuda", self._cuda_device_id
|
||||
)
|
||||
self._io_binding.bind_ortvalue_input(self._input_name, self._input_ortvalue)
|
||||
|
||||
for name in self._output_names:
|
||||
# Bind outputs to CUDA and allow ORT to allocate appropriately
|
||||
self._io_binding.bind_output(name, "cuda", self._cuda_device_id)
|
||||
|
||||
# gpu_graph_id -1 disables capture and replay for the run
|
||||
warmup_options = ort.RunOptions()
|
||||
warmup_options.add_run_config_entry("gpu_graph_id", "-1")
|
||||
|
||||
for _ in range(self.GRAPH_FREE_WARMUP_RUNS):
|
||||
self._session.run_with_iobinding(self._io_binding, warmup_options)
|
||||
|
||||
self._prepared = True
|
||||
|
||||
def run(self, input: dict[str, Any]):
|
||||
# Extract the single tensor input (assuming one input)
|
||||
input_name = list(input.keys())[0]
|
||||
tensor_input = np.ascontiguousarray(input[input_name])
|
||||
tensor_input = input[input_name]
|
||||
tensor_input = np.ascontiguousarray(tensor_input)
|
||||
|
||||
if not self._prepared:
|
||||
self._prepare(input_name, tensor_input)
|
||||
else:
|
||||
# Replay using updated input
|
||||
self._input_ortvalue.update_inplace(tensor_input)
|
||||
if not self._captured:
|
||||
# Prepare IOBinding with CUDA buffers and let ORT allocate outputs on device
|
||||
self._io_binding = self._session.io_binding()
|
||||
self._input_name = input_name
|
||||
self._output_names = [o.name for o in self._session.get_outputs()]
|
||||
|
||||
self._input_ortvalue = ort.OrtValue.ortvalue_from_numpy(
|
||||
tensor_input, "cuda", self._cuda_device_id
|
||||
)
|
||||
self._io_binding.bind_ortvalue_input(self._input_name, self._input_ortvalue)
|
||||
|
||||
for name in self._output_names:
|
||||
# Bind outputs to CUDA and allow ORT to allocate appropriately
|
||||
self._io_binding.bind_output(name, "cuda", self._cuda_device_id)
|
||||
|
||||
# First IOBinding run to allocate, execute, and capture CUDA Graph
|
||||
ro = ort.RunOptions()
|
||||
self._session.run_with_iobinding(self._io_binding, ro)
|
||||
self._captured = True
|
||||
return self._io_binding.copy_outputs_to_cpu()
|
||||
|
||||
# Replay using updated input, copy results to CPU
|
||||
self._input_ortvalue.update_inplace(tensor_input)
|
||||
ro = ort.RunOptions()
|
||||
self._session.run_with_iobinding(self._io_binding, ro)
|
||||
return self._io_binding.copy_outputs_to_cpu()
|
||||
|
||||
@property
|
||||
def device_name(self) -> str:
|
||||
return "CUDA"
|
||||
|
||||
|
||||
class OpenVINOModelRunner(BaseModelRunner):
|
||||
"""OpenVINO model runner that handles inference efficiently."""
|
||||
@@ -389,8 +326,6 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
if device == "NPU" and OpenVINOModelRunner.is_detection_model(model_type):
|
||||
compile_config["NPU_TURBO"] = "YES"
|
||||
|
||||
self.compiled_device = device
|
||||
|
||||
# Compile model under the shared lock
|
||||
with _OPENVINO_LOCK:
|
||||
try:
|
||||
@@ -422,22 +357,6 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
# model is complex and has dynamic shape
|
||||
pass
|
||||
|
||||
@property
|
||||
def device_name(self) -> str:
|
||||
device = self.compiled_device
|
||||
|
||||
if device == "AUTO":
|
||||
try:
|
||||
resolved = self.compiled_model.get_property("EXECUTION_DEVICES")
|
||||
|
||||
if resolved:
|
||||
device = ",".join(str(d) for d in resolved)
|
||||
except Exception:
|
||||
# older OpenVINO builds do not expose the property
|
||||
pass
|
||||
|
||||
return f"OpenVINO {device}"
|
||||
|
||||
def get_input_names(self) -> list[str]:
|
||||
"""Get input names for the model."""
|
||||
return [input.get_any_name() for input in self.compiled_model.inputs]
|
||||
@@ -585,10 +504,6 @@ class RKNNModelRunner(BaseModelRunner):
|
||||
logger.error(f"Error loading RKNN model: {e}")
|
||||
raise
|
||||
|
||||
@property
|
||||
def device_name(self) -> str:
|
||||
return "RKNN"
|
||||
|
||||
def get_input_names(self) -> list[str]:
|
||||
"""Get input names for the model."""
|
||||
# For detection models, we typically use "input" as the default input name
|
||||
@@ -669,14 +584,6 @@ class RKNNModelRunner(BaseModelRunner):
|
||||
pass
|
||||
|
||||
|
||||
def _record_runner(
|
||||
model_path: str, model_type: str, runner: BaseModelRunner
|
||||
) -> BaseModelRunner:
|
||||
"""Record the device a freshly loaded runner ended up on."""
|
||||
record_loaded_device(model_path, model_type, runner.device_name)
|
||||
return runner
|
||||
|
||||
|
||||
def get_optimized_runner(
|
||||
model_path: str, device: str | None, model_type: str, **kwargs
|
||||
) -> BaseModelRunner:
|
||||
@@ -687,7 +594,7 @@ def get_optimized_runner(
|
||||
rknn_path = auto_convert_model(model_path)
|
||||
|
||||
if rknn_path:
|
||||
return _record_runner(model_path, model_type, RKNNModelRunner(rknn_path))
|
||||
return RKNNModelRunner(rknn_path)
|
||||
|
||||
providers, options = get_ort_providers(device == "CPU", device, **kwargs)
|
||||
|
||||
@@ -696,11 +603,7 @@ def get_optimized_runner(
|
||||
# In other images we will get CUDA / ROCm which are preferred over OpenVINO
|
||||
# There is currently no way to prioritize OpenVINO over CUDA / ROCm in these images
|
||||
if device != "CPU" and is_openvino_gpu_npu_available():
|
||||
return _record_runner(
|
||||
model_path,
|
||||
model_type,
|
||||
OpenVINOModelRunner(model_path, device, model_type, **kwargs),
|
||||
)
|
||||
return OpenVINOModelRunner(model_path, device, model_type, **kwargs)
|
||||
|
||||
if (
|
||||
CudaGraphRunner.is_model_supported(model_type)
|
||||
@@ -710,17 +613,13 @@ def get_optimized_runner(
|
||||
**options[0],
|
||||
"enable_cuda_graph": True,
|
||||
}
|
||||
return _record_runner(
|
||||
model_path,
|
||||
model_type,
|
||||
CudaGraphRunner(
|
||||
ort.InferenceSession(
|
||||
model_path,
|
||||
providers=providers,
|
||||
provider_options=options,
|
||||
),
|
||||
options[0]["device_id"],
|
||||
return CudaGraphRunner(
|
||||
ort.InferenceSession(
|
||||
model_path,
|
||||
providers=providers,
|
||||
provider_options=options,
|
||||
),
|
||||
options[0]["device_id"],
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -732,16 +631,12 @@ def get_optimized_runner(
|
||||
providers.pop(0)
|
||||
options.pop(0)
|
||||
|
||||
return _record_runner(
|
||||
model_path,
|
||||
model_type,
|
||||
ONNXModelRunner(
|
||||
ort.InferenceSession(
|
||||
model_path,
|
||||
sess_options=get_ort_session_options(model_type),
|
||||
providers=providers,
|
||||
provider_options=options,
|
||||
),
|
||||
model_type=model_type,
|
||||
return ONNXModelRunner(
|
||||
ort.InferenceSession(
|
||||
model_path,
|
||||
sess_options=get_ort_session_options(model_type),
|
||||
providers=providers,
|
||||
provider_options=options,
|
||||
),
|
||||
model_type=model_type,
|
||||
)
|
||||
|
||||
@@ -3,14 +3,14 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import ClassVar
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import requests
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic.fields import PrivateAttr
|
||||
|
||||
from frigate.const import DEFAULT_ATTRIBUTE_LABEL_MAP, MODEL_CACHE_DIR
|
||||
from frigate.plus import PlusApi, load_plus_model_info
|
||||
from frigate.plus import PlusApi
|
||||
from frigate.util.builtin import generate_color_palette, load_labels
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -190,13 +190,12 @@ class ModelConfig(BaseModel):
|
||||
|
||||
# download the model info if it doesn't exist
|
||||
if not os.path.isfile(model_info_path):
|
||||
model_info = plus_api.get_model_info(model_id)
|
||||
with open(model_info_path, "w") as f:
|
||||
json.dump(plus_api.get_model_info(model_id), f)
|
||||
|
||||
model_info = load_plus_model_info(model_id)
|
||||
|
||||
if model_info is None:
|
||||
raise ValueError(f"Unable to read the model info for {model_id}")
|
||||
json.dump(model_info, f)
|
||||
else:
|
||||
with open(model_info_path) as f:
|
||||
model_info: dict[str, Any] = json.load(f)
|
||||
|
||||
if detector and detector not in model_info["supportedDetectors"]:
|
||||
raise ValueError(f"Model does not support detector type of {detector}")
|
||||
|
||||
@@ -259,8 +259,8 @@ def detect_hailo() -> DetectionHardware | None:
|
||||
|
||||
# the hailo runtime schedules across every attached device itself, so there
|
||||
# is nothing to address individually
|
||||
units = [HardwareUnit(device="hailo:PCIe", label=os.path.basename(nodes[0]))]
|
||||
return _hardware("hailo", "hailo", "Hailo", units)
|
||||
units = [HardwareUnit(device="hailo8l:PCIe", label=os.path.basename(nodes[0]))]
|
||||
return _hardware("hailo8l", "hailo8l", "Hailo", units)
|
||||
|
||||
|
||||
def detect_memryx() -> DetectionHardware | None:
|
||||
|
||||
@@ -10,28 +10,11 @@ from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.detectors.detection_api import DetectionApi
|
||||
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
|
||||
from frigate.util.model import post_process_yolo
|
||||
from frigate.util.runtime_deps import Artifact, ArtifactKind, RuntimeManifest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DETECTOR_KEY = "axengine"
|
||||
|
||||
# The AXEngine python package is installed at first start rather than shipped
|
||||
# in the image; its native libraries are bind mounted from the host.
|
||||
AXENGINE_VERSION = "0.1.3"
|
||||
AXENGINE_MANIFEST = RuntimeManifest(
|
||||
name=DETECTOR_KEY,
|
||||
version=AXENGINE_VERSION,
|
||||
artifacts=(
|
||||
Artifact(
|
||||
url=f"https://github.com/AXERA-TECH/pyaxengine/releases/download/{AXENGINE_VERSION}-frigate/axengine-{AXENGINE_VERSION}-py3-none-any.whl",
|
||||
sha256="e995b8a887b067dc3456512aae2fa9c84f70e708c28b11caf184efdc254c64ae",
|
||||
kind=ArtifactKind.wheel,
|
||||
),
|
||||
),
|
||||
import_check="axengine",
|
||||
)
|
||||
|
||||
supported_models = {
|
||||
ModelTypeEnum.yologeneric: "frigate-yolov9-.*$",
|
||||
}
|
||||
@@ -51,18 +34,12 @@ class AxengineDetectorConfig(BaseDetectorConfig):
|
||||
|
||||
class Axengine(DetectionApi):
|
||||
type_key = DETECTOR_KEY
|
||||
runtime_manifest = AXENGINE_MANIFEST
|
||||
|
||||
def __init__(self, config: AxengineDetectorConfig):
|
||||
self.activate_dependencies()
|
||||
|
||||
try:
|
||||
import axengine as axe
|
||||
except ModuleNotFoundError:
|
||||
raise ImportError(
|
||||
"AXEngine is not installed. Frigate installs it at startup when an "
|
||||
"axengine detector is configured; check the startup log for errors."
|
||||
) from None
|
||||
raise ImportError("AXEngine is not installed.") from None
|
||||
|
||||
logger.info("__init__ axengine")
|
||||
super().__init__(config)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import io
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from frigate.detectors.detection_api import DetectionApi
|
||||
from frigate.detectors.detector_config import BaseDetectorConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DETECTOR_KEY = "deepstack"
|
||||
|
||||
|
||||
class DeepstackDetectorConfig(BaseDetectorConfig):
|
||||
"""DeepStack/CodeProject.AI detector that sends images to a remote DeepStack HTTP API for inference. Not recommended."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
title="DeepStack",
|
||||
)
|
||||
|
||||
type: Literal[DETECTOR_KEY]
|
||||
api_url: str = Field(
|
||||
default="http://localhost:80/v1/vision/detection",
|
||||
title="DeepStack API URL",
|
||||
description="The URL of the DeepStack API.",
|
||||
)
|
||||
api_timeout: float = Field(
|
||||
default=0.1,
|
||||
title="DeepStack API timeout (in seconds)",
|
||||
description="Maximum time allowed for a DeepStack API request.",
|
||||
)
|
||||
api_key: str = Field(
|
||||
default="",
|
||||
title="DeepStack API key (if required)",
|
||||
description="Optional API key for authenticated DeepStack services.",
|
||||
)
|
||||
|
||||
|
||||
class DeepStack(DetectionApi):
|
||||
type_key = DETECTOR_KEY
|
||||
|
||||
def __init__(self, detector_config: DeepstackDetectorConfig):
|
||||
self.api_url = detector_config.api_url
|
||||
self.api_timeout = detector_config.api_timeout
|
||||
self.api_key = detector_config.api_key
|
||||
self.labels = detector_config.model.merged_labelmap
|
||||
self.session = requests.Session()
|
||||
|
||||
def get_label_index(self, label_value):
|
||||
if label_value.lower() == "truck":
|
||||
label_value = "car"
|
||||
for index, value in self.labels.items():
|
||||
if value == label_value.lower():
|
||||
return index
|
||||
return -1
|
||||
|
||||
def detect_raw(self, tensor_input):
|
||||
image_data = np.squeeze(tensor_input).astype(np.uint8)
|
||||
image = Image.fromarray(image_data)
|
||||
self.w, self.h = image.size
|
||||
with io.BytesIO() as output:
|
||||
image.save(output, format="JPEG")
|
||||
image_bytes = output.getvalue()
|
||||
data = {"api_key": self.api_key}
|
||||
|
||||
try:
|
||||
response = self.session.post(
|
||||
self.api_url,
|
||||
data=data,
|
||||
files={"image": image_bytes},
|
||||
timeout=self.api_timeout,
|
||||
)
|
||||
except requests.exceptions.RequestException as ex:
|
||||
logger.error("Error calling deepstack API: %s", ex)
|
||||
return np.zeros((20, 6), np.float32)
|
||||
|
||||
response_json = response.json()
|
||||
detections = np.zeros((20, 6), np.float32)
|
||||
if response_json.get("predictions") is None:
|
||||
logger.debug(f"Error in parsing response json: {response_json}")
|
||||
return detections
|
||||
|
||||
for i, detection in enumerate(response_json.get("predictions")):
|
||||
logger.debug(f"Response: {detection}")
|
||||
if detection["confidence"] < 0.4:
|
||||
logger.debug("Break due to confidence < 0.4")
|
||||
break
|
||||
label = self.get_label_index(detection["label"])
|
||||
if label < 0:
|
||||
logger.debug("Break due to unknown label")
|
||||
break
|
||||
detections[i] = [
|
||||
label,
|
||||
float(detection["confidence"]),
|
||||
detection["y_min"] / self.h,
|
||||
detection["x_min"] / self.w,
|
||||
detection["y_max"] / self.h,
|
||||
detection["x_max"] / self.w,
|
||||
]
|
||||
|
||||
return detections
|
||||
@@ -16,14 +16,6 @@ from frigate.detectors.detector_config import (
|
||||
BaseDetectorConfig,
|
||||
)
|
||||
from frigate.object_detection.util import RequestStore, ResponseStore
|
||||
from frigate.util.runtime_deps import (
|
||||
ArchiveDest,
|
||||
ArchiveMapping,
|
||||
Artifact,
|
||||
ArtifactKind,
|
||||
RuntimeManifest,
|
||||
find_tool,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,65 +45,18 @@ def preprocess_tensor(image: np.ndarray, model_w: int, model_h: int) -> np.ndarr
|
||||
|
||||
|
||||
# ----------------- Global Constants ----------------- #
|
||||
DETECTOR_KEY = "hailo"
|
||||
DETECTOR_KEY = "hailo8l"
|
||||
ARCH = None
|
||||
H8_DEFAULT_MODEL = "yolov6n.hef"
|
||||
H8L_DEFAULT_MODEL = "yolov6n.hef"
|
||||
H8_DEFAULT_URL = "https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.14.0/hailo8/yolov6n.hef"
|
||||
H8L_DEFAULT_URL = "https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.14.0/hailo8l/yolov6n.hef"
|
||||
|
||||
# HailoRT is installed at first start rather than shipped in the image. The
|
||||
# tarball carries libhailort and hailortcli, the wheel the python bindings.
|
||||
HAILORT_VERSION = "4.21.0"
|
||||
HAILORT_RELEASE = (
|
||||
f"https://github.com/frigate-nvr/hailort/releases/download/v{HAILORT_VERSION}"
|
||||
)
|
||||
HAILORT_MAPPINGS = (
|
||||
ArchiveMapping("rootfs/usr/local/lib/", ArchiveDest.lib),
|
||||
ArchiveMapping("rootfs/usr/local/bin/", ArchiveDest.bin),
|
||||
)
|
||||
HAILORT_MANIFEST = RuntimeManifest(
|
||||
name=DETECTOR_KEY,
|
||||
version=HAILORT_VERSION,
|
||||
artifacts=(
|
||||
Artifact(
|
||||
url=f"{HAILORT_RELEASE}/hailort-debian12-amd64.tar.gz",
|
||||
sha256="0a57ac5f7cc8c2c3668133189d9285b55f498e8cb219797e203f6f5015fec4b3",
|
||||
kind=ArtifactKind.archive,
|
||||
mappings=HAILORT_MAPPINGS,
|
||||
machines=("x86_64",),
|
||||
),
|
||||
Artifact(
|
||||
url=f"{HAILORT_RELEASE}/hailort-debian12-arm64.tar.gz",
|
||||
sha256="dd840548eb5d0d147c99aee2cb013d39d64be09c5bc63061171fcfacf4547b3f",
|
||||
kind=ArtifactKind.archive,
|
||||
mappings=HAILORT_MAPPINGS,
|
||||
machines=("aarch64",),
|
||||
),
|
||||
Artifact(
|
||||
url=f"{HAILORT_RELEASE}/hailort-{HAILORT_VERSION}-cp311-cp311-linux_x86_64.whl",
|
||||
sha256="8112a973ab48095399b29d883f31987828df5861b8553f614c89f098a67b3fb6",
|
||||
kind=ArtifactKind.wheel,
|
||||
machines=("x86_64",),
|
||||
),
|
||||
Artifact(
|
||||
url=f"{HAILORT_RELEASE}/hailort-{HAILORT_VERSION}-cp311-cp311-linux_aarch64.whl",
|
||||
sha256="658432a43573280d472f6402d7934669effe7f163ba3dffa31c50bbeeaa7c01d",
|
||||
kind=ArtifactKind.wheel,
|
||||
machines=("aarch64",),
|
||||
),
|
||||
),
|
||||
preload=(f"libhailort.so.{HAILORT_VERSION}",),
|
||||
import_check="hailo_platform",
|
||||
)
|
||||
|
||||
|
||||
def detect_hailo_arch():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[find_tool("hailortcli"), "fw-control", "identify"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
["hailortcli", "fw-control", "identify"], capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Inference error: {result.stderr}")
|
||||
@@ -151,10 +96,7 @@ class HailoAsyncInference:
|
||||
VDevice,
|
||||
)
|
||||
except ModuleNotFoundError:
|
||||
raise ImportError(
|
||||
"HailoRT is not installed. Frigate installs it at startup when a "
|
||||
"Hailo detector is configured; check the startup log for errors."
|
||||
) from None
|
||||
pass
|
||||
|
||||
self.input_store = input_store
|
||||
self.output_store = output_store
|
||||
@@ -260,11 +202,9 @@ class HailoAsyncInference:
|
||||
# ----------------- HailoDetector Class ----------------- #
|
||||
class HailoDetector(DetectionApi):
|
||||
type_key = DETECTOR_KEY
|
||||
runtime_manifest = HAILORT_MANIFEST
|
||||
|
||||
def __init__(self, detector_config: "HailoDetectorConfig"):
|
||||
global ARCH
|
||||
self.activate_dependencies()
|
||||
ARCH = detect_hailo_arch()
|
||||
self.cache_dir = MODEL_CACHE_DIR
|
||||
self.device_type = detector_config.device
|
||||
@@ -469,10 +409,10 @@ class HailoDetector(DetectionApi):
|
||||
|
||||
# ----------------- HailoDetectorConfig Class ----------------- #
|
||||
class HailoDetectorConfig(BaseDetectorConfig):
|
||||
"""Hailo detector using HEF models and the HailoRT SDK for inference on Hailo hardware."""
|
||||
"""Hailo-8/Hailo-8L detector using HEF models and the HailoRT SDK for inference on Hailo hardware."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
title="Hailo",
|
||||
title="Hailo-8/Hailo-8L",
|
||||
)
|
||||
|
||||
type: Literal[DETECTOR_KEY]
|
||||
@@ -18,58 +18,11 @@ from frigate.detectors.detector_config import (
|
||||
)
|
||||
from frigate.util.file import FileLock
|
||||
from frigate.util.model import xyxy_to_xywh_for_nms
|
||||
from frigate.util.runtime_deps import (
|
||||
ArchiveDest,
|
||||
ArchiveMapping,
|
||||
Artifact,
|
||||
ArtifactKind,
|
||||
RuntimeManifest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DETECTOR_KEY = "memryx"
|
||||
|
||||
# The MemryX SDK is installed at first start rather than shipped in the image.
|
||||
# The release archive holds the python package with its compiled extensions
|
||||
# and, per architecture, the shared libraries they link against. libmemx has
|
||||
# no SONAME, so the libraries are resolved through LD_LIBRARY_PATH.
|
||||
MEMRYX_VERSION = "2.1.0"
|
||||
MEMRYX_ARCHIVE_ROOT = f"mx_accl_frigate-{MEMRYX_VERSION}/memryx/"
|
||||
MEMRYX_LIBS = ("libmemx.so*", "libmx_accl.so*")
|
||||
MEMRYX_MANIFEST = RuntimeManifest(
|
||||
name=DETECTOR_KEY,
|
||||
version=MEMRYX_VERSION,
|
||||
artifacts=(
|
||||
Artifact(
|
||||
url=f"https://github.com/memryx/mx_accl_frigate/archive/refs/tags/v{MEMRYX_VERSION}.zip",
|
||||
sha256="54d1971dee54688541561b7bd9136342be51707b68c11a2a5e575fa19e69bc51",
|
||||
kind=ArtifactKind.archive,
|
||||
filename=f"mx_accl_frigate-{MEMRYX_VERSION}.zip",
|
||||
mappings=(
|
||||
ArchiveMapping(
|
||||
MEMRYX_ARCHIVE_ROOT, ArchiveDest.site_packages, subdir="memryx"
|
||||
),
|
||||
ArchiveMapping(
|
||||
f"{MEMRYX_ARCHIVE_ROOT}x86/",
|
||||
ArchiveDest.lib,
|
||||
include=MEMRYX_LIBS,
|
||||
machines=("x86_64",),
|
||||
),
|
||||
ArchiveMapping(
|
||||
f"{MEMRYX_ARCHIVE_ROOT}arm/",
|
||||
ArchiveDest.lib,
|
||||
include=MEMRYX_LIBS,
|
||||
machines=("aarch64",),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
preload=("libmemx.so", "libmx_accl.so.2"),
|
||||
import_check="memryx",
|
||||
needs_ld_library_path=True,
|
||||
)
|
||||
|
||||
|
||||
# Configuration class for model settings
|
||||
class ModelConfig(BaseModel):
|
||||
@@ -103,20 +56,17 @@ class MemryXDetector(DetectionApi):
|
||||
ModelTypeEnum.yologeneric, # Treated as yolov9 in MemryX implementation
|
||||
ModelTypeEnum.yolox,
|
||||
]
|
||||
runtime_manifest = MEMRYX_MANIFEST
|
||||
|
||||
def __init__(self, detector_config):
|
||||
"""Initialize MemryX detector with the provided configuration."""
|
||||
self.activate_dependencies()
|
||||
|
||||
try:
|
||||
# Import MemryX SDK
|
||||
from memryx import AsyncAccl
|
||||
except ModuleNotFoundError:
|
||||
raise ImportError(
|
||||
"MemryX SDK is not installed. Frigate installs it at startup when "
|
||||
"a MemryX detector is configured; check the startup log for errors."
|
||||
"MemryX SDK is not installed. Install it and set up MIX environment."
|
||||
) from None
|
||||
return
|
||||
|
||||
# Initialize stop_event as None, will be set later by set_stop_event()
|
||||
self.stop_event = None
|
||||
@@ -128,7 +78,7 @@ class MemryXDetector(DetectionApi):
|
||||
detector_config.model.model_type = model_cfg.model_type
|
||||
else:
|
||||
logger.info(
|
||||
"model_type not set in config, defaulting to yolonas for MemryX"
|
||||
"model_type not set in config — defaulting to yolonas for MemryX."
|
||||
)
|
||||
detector_config.model.model_type = ModelTypeEnum.yolonas
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from frigate.util.classification import kickoff_model_training
|
||||
from frigate.util.path import safe_join
|
||||
from frigate.util.process import FrigateProcess
|
||||
|
||||
from .maintainer import EmbeddingMaintainer
|
||||
from .util import ZScoreNormalization
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,10 +47,6 @@ class EmbeddingProcess(FrigateProcess):
|
||||
self.metrics = metrics
|
||||
|
||||
def run(self) -> None:
|
||||
# imported here so that importing this package does not pull in the
|
||||
# processors, which import back into it and form a cycle
|
||||
from .maintainer import EmbeddingMaintainer
|
||||
|
||||
self.pre_run_setup(self.config.logger)
|
||||
maintainer = EmbeddingMaintainer(
|
||||
self.config,
|
||||
@@ -337,9 +334,3 @@ class EmbeddingsContext:
|
||||
EmbeddingsRequestEnum.summarize_review.value,
|
||||
{"start_ts": start_ts, "end_ts": end_ts},
|
||||
)
|
||||
|
||||
def regenerate_review_description(self, review_id: str) -> None:
|
||||
self.requestor.send_data(
|
||||
EmbeddingsRequestEnum.regenerate_review_description.value,
|
||||
{"review_id": review_id},
|
||||
)
|
||||
|
||||
@@ -60,8 +60,6 @@ from frigate.data_processing.real_time.license_plate import (
|
||||
)
|
||||
from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataEnum
|
||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
from frigate.detectors.detection_runners import snapshot_loaded_devices
|
||||
from frigate.embeddings.types import fold_runtime_devices
|
||||
from frigate.events.types import (
|
||||
EventStateEnum,
|
||||
EventTypeEnum,
|
||||
@@ -103,7 +101,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
super().__init__(name="embeddings_maintainer")
|
||||
self.config = config
|
||||
self.metrics = metrics
|
||||
self._published_devices: dict[str, str] = {}
|
||||
self.embeddings = None
|
||||
self.config_updater = CameraConfigUpdateSubscriber(
|
||||
self.config,
|
||||
@@ -340,7 +337,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
self._expire_dedicated_lpr()
|
||||
self._process_finalized()
|
||||
self._process_event_metadata()
|
||||
self._publish_runtime_devices()
|
||||
|
||||
# Shutdown deferred processors
|
||||
for processor in self.realtime_processors:
|
||||
@@ -742,19 +738,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
if isinstance(processor, ReviewDescriptionProcessor):
|
||||
processor.process_data(review_updates, PostProcessDataEnum.review)
|
||||
|
||||
def _publish_runtime_devices(self) -> None:
|
||||
"""Push each enrichment's loaded device to the shared metrics dict.
|
||||
|
||||
Runs every loop iteration, so only changed entries cross the manager
|
||||
boundary.
|
||||
"""
|
||||
for enrichment, device in fold_runtime_devices(
|
||||
snapshot_loaded_devices()
|
||||
).items():
|
||||
if self._published_devices.get(enrichment) != device:
|
||||
self.metrics.runtime_devices[enrichment] = device
|
||||
self._published_devices[enrichment] = device
|
||||
|
||||
def _process_event_metadata(self):
|
||||
# Check for regenerate description requests
|
||||
(topic, payload) = self.event_metadata_subscriber.check_for_update()
|
||||
|
||||
@@ -6,10 +6,7 @@ import os
|
||||
import numpy as np
|
||||
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.detectors.detection_runners import (
|
||||
get_optimized_runner,
|
||||
record_loaded_device,
|
||||
)
|
||||
from frigate.detectors.detection_runners import get_optimized_runner
|
||||
from frigate.embeddings.types import EnrichmentModelTypeEnum
|
||||
from frigate.log import suppress_stderr_during
|
||||
from frigate.util.downloader import ModelDownloader
|
||||
@@ -65,18 +62,13 @@ class FaceNetEmbedding(BaseEmbedding):
|
||||
if self.downloader:
|
||||
self.downloader.wait_for_download()
|
||||
|
||||
model_path = os.path.join(MODEL_CACHE_DIR, "facedet/facenet.tflite")
|
||||
|
||||
# Suppress TFLite delegate creation messages that bypass Python logging
|
||||
with suppress_stderr_during("tflite_interpreter_init"):
|
||||
self.runner = Interpreter(model_path=model_path, num_threads=2)
|
||||
self.runner = Interpreter(
|
||||
model_path=os.path.join(MODEL_CACHE_DIR, "facedet/facenet.tflite"),
|
||||
num_threads=2,
|
||||
)
|
||||
self.runner.allocate_tensors()
|
||||
|
||||
# tflite never goes through get_optimized_runner, so the small face
|
||||
# model would otherwise never report a device
|
||||
record_loaded_device(
|
||||
model_path, EnrichmentModelTypeEnum.facenet.value, "CPU"
|
||||
)
|
||||
self.tensor_input_details = self.runner.get_input_details()
|
||||
self.tensor_output_details = self.runner.get_output_details()
|
||||
|
||||
|
||||
@@ -13,37 +13,3 @@ class EnrichmentModelTypeEnum(str, Enum):
|
||||
jina_v2 = "jina_v2"
|
||||
paddleocr = "paddleocr"
|
||||
yolov9_license_plate = "yolov9_license_plate"
|
||||
|
||||
|
||||
# which enrichment each model type belongs to; the single place this lives
|
||||
ENRICHMENT_FOR_MODEL_TYPE: dict[str, str] = {
|
||||
EnrichmentModelTypeEnum.arcface.value: "face_recognition",
|
||||
EnrichmentModelTypeEnum.facenet.value: "face_recognition",
|
||||
EnrichmentModelTypeEnum.jina_v1.value: "semantic_search",
|
||||
EnrichmentModelTypeEnum.jina_v2.value: "semantic_search",
|
||||
EnrichmentModelTypeEnum.paddleocr.value: "lpr",
|
||||
EnrichmentModelTypeEnum.yolov9_license_plate.value: "lpr",
|
||||
}
|
||||
|
||||
|
||||
def fold_runtime_devices(loaded: dict[str, tuple[str, str]]) -> dict[str, str]:
|
||||
"""One device per enrichment from the per model registry.
|
||||
|
||||
A non CPU device wins so a model pinned to the CPU on purpose (the Jina V1
|
||||
text model) does not hide the accelerator its sibling loaded on, while an
|
||||
enrichment whose models all fell back to the CPU still reports CPU.
|
||||
"""
|
||||
folded: dict[str, str] = {}
|
||||
|
||||
for model_type, device in loaded.values():
|
||||
enrichment = ENRICHMENT_FOR_MODEL_TYPE.get(model_type)
|
||||
|
||||
if enrichment is None:
|
||||
continue
|
||||
|
||||
current = folded.get(enrichment)
|
||||
|
||||
if current is None or ("CPU" in current and "CPU" not in device):
|
||||
folded[enrichment] = device
|
||||
|
||||
return folded
|
||||
|
||||
@@ -37,7 +37,7 @@ class EventCleanup(threading.Thread):
|
||||
if self.removed_camera_labels is None:
|
||||
self.removed_camera_labels = list(
|
||||
Event.select(Event.label)
|
||||
.where(Event.camera.not_in(self.camera_keys))
|
||||
.where(Event.camera.not_in(self.camera_keys)) # type: ignore[arg-type,call-arg,misc]
|
||||
.distinct()
|
||||
.execute()
|
||||
)
|
||||
@@ -89,7 +89,7 @@ class EventCleanup(threading.Thread):
|
||||
Event.thumbnail,
|
||||
)
|
||||
.where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
@@ -111,7 +111,7 @@ class EventCleanup(threading.Thread):
|
||||
|
||||
# update the clips attribute for the db entry
|
||||
query = Event.select(Event.id).where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
@@ -218,7 +218,7 @@ class EventCleanup(threading.Thread):
|
||||
Event.camera,
|
||||
)
|
||||
.where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.start_time < expire_after,
|
||||
Event.retain_indefinitely == False,
|
||||
)
|
||||
@@ -249,7 +249,7 @@ class EventCleanup(threading.Thread):
|
||||
|
||||
# update the clips attribute for the db entry
|
||||
query = Event.select(Event.id).where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.start_time < expire_after,
|
||||
Event.retain_indefinitely == False,
|
||||
)
|
||||
|
||||
@@ -104,7 +104,6 @@ class GenAIClient:
|
||||
preferred_language: str | None,
|
||||
debug_save: bool,
|
||||
activity_context_prompt: str,
|
||||
response_style: str = "default",
|
||||
) -> ReviewMetadata | None:
|
||||
"""Generate a description for the review item activity."""
|
||||
context_prompt = build_review_description_prompt(
|
||||
@@ -113,7 +112,6 @@ class GenAIClient:
|
||||
concerns,
|
||||
preferred_language,
|
||||
activity_context_prompt,
|
||||
response_style,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
|
||||
@@ -110,28 +110,6 @@ class GenAIClientManager:
|
||||
name = self._role_map.get(GenAIRoleEnum.embeddings)
|
||||
return self._get_client(name) if name else None
|
||||
|
||||
def role_info(self) -> dict[str, dict[str, Any]]:
|
||||
"""Return the model selected for each configured role and its context size.
|
||||
|
||||
Only reads state the client saved when it initialized, so unlike
|
||||
list_models() this does not ask the provider for its catalog.
|
||||
"""
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for role, name in self._role_map.items():
|
||||
client = self._get_client(name)
|
||||
|
||||
if not client:
|
||||
continue
|
||||
|
||||
result[role.value] = {
|
||||
"name": name,
|
||||
"model": self._configs[name].model,
|
||||
"context_size": client.get_context_size(),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def list_models(self) -> dict[str, dict[str, Any]]:
|
||||
"""Return per-entry model lists and capabilities, keyed by config entry name."""
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
|
||||
+5
-174
@@ -16,48 +16,6 @@ from frigate.config.ui import UnitSystemEnum
|
||||
from frigate.data_processing.post.types import ReviewMetadata
|
||||
from frigate.models import Event
|
||||
|
||||
# Base guidance per response field. `observations` is a reasoning scaffold,
|
||||
# not user-facing, so style presets never override it.
|
||||
REVIEW_DESCRIPTION_FIELD_GUIDELINES: dict[str, str] = {
|
||||
"observations": "Include the very start of the activity — for example, a vehicle entering the frame or pulling into the driveway — even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence.",
|
||||
"scene": 'Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name — do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.',
|
||||
"title": "Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles.",
|
||||
"shortSummary": "Briefly summarize the primary activity across the observations.",
|
||||
"potential_threat_level": "Must be consistent with your scene description and the activity patterns above.",
|
||||
}
|
||||
|
||||
# Style presets keyed by ReviewResponseStyleEnum value. Presets replace the
|
||||
# base guidance rather than append to it, so the prompt never carries
|
||||
# competing style instructions.
|
||||
REVIEW_RESPONSE_STYLES: dict[str, dict[str, str]] = {
|
||||
"natural": {
|
||||
"scene": 'Recount what happened the way a person would describe it to a neighbor, in plain everyday language: how the sequence begins, then each significant movement and action in the order it happens. Use flowing sentences that connect related actions, written the way people actually talk rather than like a surveillance report. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name rather than a generic term. Refer to unnamed objects naturally with articles. Stay factual, and keep the description consistent with the threat level you assign.',
|
||||
"title": 'Write the title as a short, sentence-case headline in present tense: the subject, then what they do, phrased the way you would text it to the homeowner. Describe only the action you see; do not assign the person a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens. For named subjects, always use their name.',
|
||||
"shortSummary": "Sum up the primary activity in one short, natural sentence, as if mentioning it to someone in passing.",
|
||||
},
|
||||
"concise": {
|
||||
"scene": 'Cover each significant movement and action in order using as few short, direct sentences as possible, omitting environmental and cosmetic detail unless it affects the assessment. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign.',
|
||||
"title": 'Write the title as a terse label of two to four words naming the specific activity observed and where it happened. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. For named subjects, always use their name.',
|
||||
"shortSummary": "Summarize the primary activity in one short sentence.",
|
||||
},
|
||||
"detailed": {
|
||||
"scene": 'Describe how the sequence begins, then the progression of events — all significant movements and actions in order, including the specifics that best identify the subjects: colors, clothing, carried items, positions, and paths of movement, plus environmental details like lighting changes when they stand out. Favor the most identifying details over exhaustive coverage, and keep every added detail observational rather than speculative. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name rather than a generic term. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign.',
|
||||
"title": 'Write the title as a specific description of who did what and where, in under roughly twelve words, including the most distinguishing visible detail of the subject, such as clothing or vehicle color. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens. For named subjects, always use their name.',
|
||||
"shortSummary": "Briefly summarize the primary activity across the observations, including the most identifying visible detail, such as vehicle color or clothing.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_review_field_guidelines(response_style: str = "default") -> dict[str, str]:
|
||||
"""Return per-field response guidance with the style preset applied.
|
||||
|
||||
"default" (or an unknown value) applies no overrides.
|
||||
"""
|
||||
return {
|
||||
**REVIEW_DESCRIPTION_FIELD_GUIDELINES,
|
||||
**REVIEW_RESPONSE_STYLES.get(response_style, {}),
|
||||
}
|
||||
|
||||
|
||||
def build_review_description_prompt(
|
||||
review_data: dict[str, Any],
|
||||
@@ -65,7 +23,6 @@ def build_review_description_prompt(
|
||||
concerns: list[str],
|
||||
preferred_language: str | None,
|
||||
activity_context_prompt: str,
|
||||
response_style: str = "default",
|
||||
) -> str:
|
||||
"""Build the prompt for review activity description generation."""
|
||||
|
||||
@@ -92,8 +49,6 @@ def build_review_description_prompt(
|
||||
else:
|
||||
return "\n- (No objects detected)"
|
||||
|
||||
fields = get_review_field_guidelines(response_style)
|
||||
|
||||
return f"""
|
||||
Your task is to analyze a sequence of images taken in chronological order from a security camera.
|
||||
|
||||
@@ -120,11 +75,11 @@ When forming your description:
|
||||
## Response Field Guidelines
|
||||
|
||||
Respond with a JSON object matching the provided schema. Field-specific guidance:
|
||||
- `observations`: {fields["observations"]}
|
||||
- `scene`: {fields["scene"]}
|
||||
- `title`: {fields["title"]}
|
||||
- `shortSummary`: {fields["shortSummary"]}
|
||||
- `potential_threat_level`: {fields["potential_threat_level"]}
|
||||
- `observations`: Include the very start of the activity — for example, a vehicle entering the frame or pulling into the driveway — even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence.
|
||||
- `scene`: Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name — do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.
|
||||
- `title`: Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles.
|
||||
- `shortSummary`: Briefly summarize the primary activity across the observations.
|
||||
- `potential_threat_level`: Must be consistent with your scene description and the activity patterns above.
|
||||
{get_concern_prompt()}
|
||||
|
||||
## Sequence Details
|
||||
@@ -311,10 +266,6 @@ def get_tool_definitions(
|
||||
Descriptions here stay mechanical: which tool to reach for, and how the
|
||||
filters relate to each other, is stated once in the system prompt so the
|
||||
guidance is not paid for twice on every request.
|
||||
|
||||
Each definition carries a Frigate-only `access` field ("read" or "write");
|
||||
write tools pause for user approval in the chat loop. Strip it with
|
||||
`strip_tool_access` before sending the list to a provider.
|
||||
"""
|
||||
search_objects_properties: dict[str, Any] = {
|
||||
"camera": {
|
||||
@@ -386,7 +337,6 @@ def get_tool_definitions(
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "search_objects",
|
||||
"description": search_objects_description,
|
||||
@@ -399,7 +349,6 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_categorized_object_names",
|
||||
"description": (
|
||||
@@ -417,7 +366,6 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "find_similar_objects",
|
||||
"description": (
|
||||
@@ -481,7 +429,6 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "write",
|
||||
"function": {
|
||||
"name": "set_camera_state",
|
||||
"description": (
|
||||
@@ -537,7 +484,6 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_live_context",
|
||||
"description": (
|
||||
@@ -562,7 +508,6 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "write",
|
||||
"function": {
|
||||
"name": "start_camera_watch",
|
||||
"description": (
|
||||
@@ -606,7 +551,6 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "write",
|
||||
"function": {
|
||||
"name": "stop_camera_watch",
|
||||
"description": "Cancel the currently running watch job.",
|
||||
@@ -619,7 +563,6 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_profile_status",
|
||||
"description": (
|
||||
@@ -636,7 +579,6 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_recap",
|
||||
"description": (
|
||||
@@ -669,120 +611,9 @@ def get_tool_definitions(
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_export_cases",
|
||||
"description": (
|
||||
"List the export cases (named groups of exported clips) with "
|
||||
"their IDs, descriptions, and how many exports each holds. "
|
||||
"Call this before create_export when the user wants a clip "
|
||||
"added to an existing case."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "write",
|
||||
"function": {
|
||||
"name": "create_export",
|
||||
"description": (
|
||||
"Export a camera's recording for a time range to a "
|
||||
"downloadable file, optionally attached to an existing export "
|
||||
"case. Only call this when the user explicitly asks to export "
|
||||
"or save a clip."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera ID to export from.",
|
||||
},
|
||||
"start_time": {
|
||||
"type": "string",
|
||||
"description": "Start of the clip in ISO 8601 format (e.g. '2025-03-15T08:00:00').",
|
||||
},
|
||||
"end_time": {
|
||||
"type": "string",
|
||||
"description": "End of the clip in ISO 8601 format (e.g. '2025-03-15T08:05:00').",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Friendly name for the export (optional).",
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["recordings", "preview"],
|
||||
"description": (
|
||||
"'recordings' (default) exports full-quality footage; "
|
||||
"'preview' builds a low-resolution timelapse."
|
||||
),
|
||||
"default": "recordings",
|
||||
},
|
||||
"export_case_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"ID of an existing export case to attach the export "
|
||||
"to. Use get_export_cases to find it."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["camera", "start_time", "end_time"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_event_image",
|
||||
"description": (
|
||||
"View the thumbnail or snapshot image of a specific tracked "
|
||||
"object so you can describe what it shows. Use the event id "
|
||||
"from search_objects, find_similar_objects, or an attached "
|
||||
"event."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the tracked object to view.",
|
||||
},
|
||||
"image": {
|
||||
"type": "string",
|
||||
"enum": ["thumbnail", "snapshot"],
|
||||
"description": (
|
||||
"'thumbnail' (default) is a small crop of the object; "
|
||||
"'snapshot' is the full camera frame."
|
||||
),
|
||||
"default": "thumbnail",
|
||||
},
|
||||
},
|
||||
"required": ["event_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_write_tool_names(tools: list[dict[str, Any]]) -> set[str]:
|
||||
"""Names of the tools whose `access` is "write" (they change state)."""
|
||||
return {tool["function"]["name"] for tool in tools if tool.get("access") == "write"}
|
||||
|
||||
|
||||
def strip_tool_access(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Drop the Frigate-only `access` field before handing tools to a provider."""
|
||||
return [{k: v for k, v in tool.items() if k != "access"} for tool in tools]
|
||||
|
||||
|
||||
def build_chat_system_prompt(
|
||||
config: FrigateConfig,
|
||||
allowed_cameras: list[str],
|
||||
|
||||
@@ -216,11 +216,11 @@ class ExportDebugReplaySource(DebugReplaySource):
|
||||
"""
|
||||
|
||||
def __init__(self, export: Export, duration: float) -> None:
|
||||
self._camera = export.camera
|
||||
self._camera = cast(str, export.camera)
|
||||
# Export.date is declared DateTimeField but Frigate writes raw unix
|
||||
# timestamps to the column.
|
||||
self._start_ts = float(cast(Any, export.date))
|
||||
self._video_path = export.video_path
|
||||
self._video_path = cast(str, export.video_path)
|
||||
self._duration = duration
|
||||
|
||||
@property
|
||||
|
||||
@@ -18,11 +18,7 @@ from frigate.config.camera.record import ChaptersEnum
|
||||
from frigate.const import UPDATE_JOB_STATE
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.models import Export
|
||||
from frigate.record.export import (
|
||||
ExportStreamEnum,
|
||||
PlaybackSourceEnum,
|
||||
RecordingExporter,
|
||||
)
|
||||
from frigate.record.export import PlaybackSourceEnum, RecordingExporter
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -62,7 +58,6 @@ class ExportJob(Job):
|
||||
ffmpeg_output_args: str | None = None
|
||||
cpu_fallback: bool = False
|
||||
chapters: ChaptersEnum | None = None
|
||||
stream: ExportStreamEnum = ExportStreamEnum.auto
|
||||
current_step: str = "queued"
|
||||
progress_percent: float = 0.0
|
||||
|
||||
@@ -352,7 +347,6 @@ class ExportJobManager:
|
||||
job.ffmpeg_output_args,
|
||||
job.cpu_fallback,
|
||||
job.chapters,
|
||||
job.stream,
|
||||
on_progress=self._make_progress_callback(job),
|
||||
)
|
||||
|
||||
|
||||
+4
-7
@@ -13,13 +13,10 @@ from functools import wraps
|
||||
from logging.handlers import QueueHandler, QueueListener
|
||||
from multiprocessing.managers import SyncManager
|
||||
from queue import Empty, Queue
|
||||
from typing import Any, TypeVar, cast
|
||||
from typing import Any
|
||||
|
||||
from frigate.util.builtin import clean_camera_user_pass
|
||||
|
||||
# lets a decorator keep the signature of the function it wraps
|
||||
_F = TypeVar("_F", bound=Callable[..., Any])
|
||||
|
||||
LOG_HANDLER = logging.StreamHandler()
|
||||
LOG_HANDLER.setFormatter(
|
||||
logging.Formatter(
|
||||
@@ -245,10 +242,10 @@ def __redirect_fd_to_queue(queue: Queue[str]) -> Generator[None, None, None]:
|
||||
pass
|
||||
|
||||
|
||||
def redirect_output_to_logger(logger: logging.Logger, level: int) -> Callable[[_F], _F]:
|
||||
def redirect_output_to_logger(logger: logging.Logger, level: int) -> Any:
|
||||
"""Decorator to redirect both Python sys.stdout/stderr and C-level stdout to logger."""
|
||||
|
||||
def decorator(func: _F) -> _F:
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
queue: Queue[str] = Queue()
|
||||
@@ -278,7 +275,7 @@ def redirect_output_to_logger(logger: logging.Logger, level: int) -> Callable[[_
|
||||
|
||||
return result
|
||||
|
||||
return cast(_F, wrapper)
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -109,8 +109,6 @@ class Export(Model):
|
||||
backref="exports",
|
||||
column_name="export_case_id",
|
||||
)
|
||||
# peewee adds this accessor for the export_case column at runtime
|
||||
export_case_id: str | None
|
||||
|
||||
|
||||
class ReviewSegment(Model):
|
||||
@@ -185,25 +183,3 @@ class Trigger(Model):
|
||||
|
||||
class Meta:
|
||||
primary_key = CompositeKey("camera", "name")
|
||||
|
||||
|
||||
class Notice(Model):
|
||||
id = CharField(null=False, primary_key=True, max_length=150)
|
||||
kind = CharField(index=True, max_length=50)
|
||||
scope = CharField(max_length=100, null=True)
|
||||
params = JSONField()
|
||||
first_seen = DateTimeField()
|
||||
last_seen = DateTimeField()
|
||||
count = IntegerField(default=1)
|
||||
dismissed_at = DateTimeField(null=True)
|
||||
|
||||
|
||||
class NoticeStats(Model):
|
||||
kind = CharField(null=False, primary_key=True, max_length=50)
|
||||
occurrences = IntegerField(default=0)
|
||||
dismissals = IntegerField(default=0)
|
||||
first_seen = DateTimeField()
|
||||
last_seen = DateTimeField()
|
||||
# watermarks for a future analytics reporter; unused until then
|
||||
reported_occurrences = IntegerField(default=0)
|
||||
reported_dismissals = IntegerField(default=0)
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Raise and resolve notices from any Frigate process or thread.
|
||||
|
||||
A notice records something worth seeing later, such as an event or a setup
|
||||
problem only the backend can detect. A condition that comes and goes belongs in
|
||||
stats and the status bar, and a config problem belongs in a settings message
|
||||
flagged for health.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.const import UPDATE_NOTICE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_registry: "NoticeRegistry | None" = None
|
||||
|
||||
# zmq sockets are not thread safe, so every thread in a process shares one
|
||||
# requestor behind a lock
|
||||
_requestor: InterProcessRequestor | None = None
|
||||
_requestor_lock = threading.Lock()
|
||||
|
||||
|
||||
def install_registry(registry: "NoticeRegistry | None") -> None:
|
||||
"""Send this process's notices straight to the registry.
|
||||
|
||||
Only the main process installs one. Children start from the forkserver,
|
||||
so they never inherit it and send over IPC instead.
|
||||
"""
|
||||
global _registry
|
||||
_registry = registry
|
||||
|
||||
|
||||
def raise_notice(
|
||||
kind: str, *, scope: str | None = None, params: dict[str, Any] | None = None
|
||||
) -> None:
|
||||
"""Record a notice, or count another occurrence of it. Never raises."""
|
||||
_send({"action": "raise", "kind": kind, "scope": scope, "params": params or {}})
|
||||
|
||||
|
||||
def resolve_notice(kind: str, scope: str | None = None) -> None:
|
||||
"""Delete a notice once its problem is fixed. Never raises."""
|
||||
_send({"action": "resolve", "kind": kind, "scope": scope})
|
||||
|
||||
|
||||
def resolve_kind(kind: str) -> None:
|
||||
"""Delete every notice of a kind. Never raises."""
|
||||
_send({"action": "resolve_kind", "kind": kind})
|
||||
|
||||
|
||||
def flush_notices() -> None:
|
||||
"""Write repeats the registry held back. Only the main process has any."""
|
||||
if _registry is None:
|
||||
return
|
||||
|
||||
try:
|
||||
_registry.flush()
|
||||
except Exception:
|
||||
logger.exception("Failed to flush notices")
|
||||
|
||||
|
||||
def _send(update: dict[str, Any]) -> None:
|
||||
global _requestor
|
||||
|
||||
try:
|
||||
if _registry is not None:
|
||||
_registry.apply(update)
|
||||
elif mp.parent_process() is not None:
|
||||
with _requestor_lock:
|
||||
if _requestor is None:
|
||||
_requestor = InterProcessRequestor()
|
||||
|
||||
_requestor.send_data(UPDATE_NOTICE, update)
|
||||
else:
|
||||
# the main process before startup installs the registry, or a test
|
||||
logger.debug("No notice registry for %s", update["kind"])
|
||||
except Exception:
|
||||
logger.exception("Failed to update notice %s", update["kind"])
|
||||
@@ -1,372 +0,0 @@
|
||||
"""Registry of notices and per-kind occurrence counts."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import Notice, NoticeStats
|
||||
from frigate.notices.types import CHECK_KINDS, NOTICE_KINDS, SEVERITY_ORDER, notice_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _HeldRepeats:
|
||||
kind: str
|
||||
count: int
|
||||
last_seen: float
|
||||
params: dict[str, Any]
|
||||
|
||||
|
||||
class NoticeRegistry:
|
||||
"""Owns the notice tables. Lives in the main process only.
|
||||
|
||||
Producers call the functions in frigate.notices, which reach this object
|
||||
directly in the main process and through the dispatcher everywhere else.
|
||||
It holds no rules for particular kinds; those live in NOTICE_KINDS.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._listeners: list[Callable[[], None]] = []
|
||||
|
||||
# row id -> repeats of a batch_repeats kind that flush() writes
|
||||
self._held: dict[str, _HeldRepeats] = {}
|
||||
|
||||
def subscribe(self, listener: Callable[[], None]) -> None:
|
||||
"""Call listener after every change to the notices."""
|
||||
self._listeners.append(listener)
|
||||
|
||||
def _notify(self) -> None:
|
||||
for listener in self._listeners:
|
||||
try:
|
||||
listener()
|
||||
except Exception:
|
||||
logger.exception("Notice listener failed")
|
||||
|
||||
def apply(self, update: dict[str, Any]) -> None:
|
||||
"""Apply one update message built by frigate.notices."""
|
||||
kind = update.get("kind")
|
||||
|
||||
if not isinstance(kind, str):
|
||||
logger.warning("Ignoring notice update without a kind")
|
||||
return
|
||||
|
||||
match update.get("action"):
|
||||
case "raise":
|
||||
self.raise_notice(
|
||||
kind, scope=update.get("scope"), params=update.get("params")
|
||||
)
|
||||
case "resolve":
|
||||
self.resolve(kind, update.get("scope"))
|
||||
case "resolve_kind":
|
||||
self.resolve_kind(kind)
|
||||
case action:
|
||||
logger.warning("Ignoring notice update with action %s", action)
|
||||
|
||||
def raise_notice(
|
||||
self,
|
||||
kind: str,
|
||||
*,
|
||||
scope: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Insert a notice or count another occurrence of it.
|
||||
|
||||
A dismissed notice stays dismissed when it is raised again, unless its
|
||||
kind sets reopen_at_count. A kind that should come back after a
|
||||
dismissal gives each episode its own scope.
|
||||
"""
|
||||
definition = NOTICE_KINDS.get(kind)
|
||||
|
||||
if definition is None:
|
||||
logger.warning("Ignoring notice of unknown kind %s", kind)
|
||||
return
|
||||
|
||||
if (
|
||||
definition.category == "camera"
|
||||
and scope
|
||||
and scope.startswith(REPLAY_CAMERA_PREFIX)
|
||||
):
|
||||
return
|
||||
|
||||
now = datetime.now().timestamp()
|
||||
row_id = notice_id(kind, scope)
|
||||
params = params or {}
|
||||
|
||||
with self._lock:
|
||||
existing = Notice.get_or_none(Notice.id == row_id)
|
||||
|
||||
if existing is None:
|
||||
# repeats held for a row that has since been deleted are stale
|
||||
self._held.pop(row_id, None)
|
||||
Notice.create(
|
||||
id=row_id,
|
||||
kind=kind,
|
||||
scope=scope,
|
||||
params=params,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
count=1,
|
||||
dismissed_at=None,
|
||||
)
|
||||
self._bump_occurrences(kind, 1, now)
|
||||
|
||||
if definition.keep_latest is not None:
|
||||
self._prune(kind, definition.keep_latest)
|
||||
elif not definition.counts_repeats:
|
||||
return
|
||||
elif definition.batch_repeats:
|
||||
held = self._held.setdefault(row_id, _HeldRepeats(kind, 0, now, params))
|
||||
held.count += 1
|
||||
held.last_seen = now
|
||||
held.params = params
|
||||
return
|
||||
else:
|
||||
self._write_repeats(existing, kind, 1, now, params)
|
||||
|
||||
self._notify()
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Write the repeats that batch_repeats kinds held back."""
|
||||
with self._lock:
|
||||
held, self._held = self._held, {}
|
||||
written = False
|
||||
|
||||
for row_id, repeats in held.items():
|
||||
row = Notice.get_or_none(Notice.id == row_id)
|
||||
|
||||
# resolved while its repeats waited
|
||||
if row is None:
|
||||
continue
|
||||
|
||||
self._write_repeats(
|
||||
row,
|
||||
repeats.kind,
|
||||
repeats.count,
|
||||
repeats.last_seen,
|
||||
repeats.params,
|
||||
)
|
||||
written = True
|
||||
|
||||
if written:
|
||||
self._notify()
|
||||
|
||||
def resolve(self, kind: str, scope: str | None = None) -> None:
|
||||
"""Delete a notice if present. Safe to call when it is absent."""
|
||||
with self._lock:
|
||||
deleted = (
|
||||
Notice.delete().where(Notice.id == notice_id(kind, scope)).execute()
|
||||
)
|
||||
|
||||
if deleted:
|
||||
self._notify()
|
||||
|
||||
def resolve_kind(self, kind: str) -> None:
|
||||
"""Delete every notice of a kind."""
|
||||
with self._lock:
|
||||
deleted = Notice.delete().where(Notice.kind == kind).execute()
|
||||
|
||||
if deleted:
|
||||
self._notify()
|
||||
|
||||
def resolve_camera(self, camera: str) -> None:
|
||||
"""Drop the notices and check dismissals of a camera being deleted."""
|
||||
camera_kinds = [
|
||||
key
|
||||
for key, definition in NOTICE_KINDS.items()
|
||||
if definition.category == "camera"
|
||||
]
|
||||
|
||||
with self._lock:
|
||||
deleted = (
|
||||
Notice.delete()
|
||||
.where(
|
||||
Notice.kind.in_(camera_kinds),
|
||||
Notice.scope == camera,
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
|
||||
# a stream id names its camera first; a config id ends with camera.<name>
|
||||
for check in self.dismissed_checks():
|
||||
check_id = check["id"]
|
||||
|
||||
if check_id.startswith(f"stream:{camera}:") or (
|
||||
check_id.startswith("config:")
|
||||
and check_id.endswith(f":camera.{camera}")
|
||||
):
|
||||
Notice.delete_by_id(check_id)
|
||||
|
||||
if deleted:
|
||||
self._notify()
|
||||
|
||||
def purge_dismissed(self) -> int:
|
||||
"""Delete every dismissed row so each can show again. Returns how many."""
|
||||
with self._lock:
|
||||
return int(
|
||||
Notice.delete().where(Notice.dismissed_at.is_null(False)).execute()
|
||||
)
|
||||
|
||||
def dismiss(self, row_id: str) -> bool:
|
||||
"""Hide a notice or check row for good. Returns False for an unknown id."""
|
||||
with self._lock:
|
||||
existing = Notice.get_or_none(Notice.id == row_id)
|
||||
|
||||
if existing is None:
|
||||
# a check row gets a notice row only once it is dismissed
|
||||
kind, _, scope = row_id.partition(":")
|
||||
|
||||
if kind not in CHECK_KINDS or not scope:
|
||||
return False
|
||||
|
||||
now = datetime.now().timestamp()
|
||||
Notice.create(
|
||||
id=row_id,
|
||||
kind=kind,
|
||||
scope=scope,
|
||||
params={},
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
count=1,
|
||||
dismissed_at=now,
|
||||
)
|
||||
return True
|
||||
|
||||
if existing.dismissed_at is not None:
|
||||
return True
|
||||
|
||||
if existing.kind not in NOTICE_KINDS:
|
||||
return False
|
||||
|
||||
Notice.update(dismissed_at=datetime.now().timestamp()).where(
|
||||
Notice.id == row_id
|
||||
).execute()
|
||||
NoticeStats.update(dismissals=NoticeStats.dismissals + 1).where(
|
||||
NoticeStats.kind == existing.kind
|
||||
).execute()
|
||||
|
||||
self._notify()
|
||||
return True
|
||||
|
||||
def dismissed_checks(self) -> list[dict[str, Any]]:
|
||||
"""Dismissed config and stream check rows, newest first."""
|
||||
rows = (
|
||||
Notice.select()
|
||||
.where(Notice.kind.in_(list(CHECK_KINDS)))
|
||||
.order_by(Notice.dismissed_at.desc())
|
||||
)
|
||||
return [{"id": row.id, "dismissed_at": row.dismissed_at} for row in rows]
|
||||
|
||||
def active(self, include_dismissed: bool = False) -> list[dict[str, Any]]:
|
||||
"""Notices most severe first, then most recent first.
|
||||
|
||||
Args:
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
"""
|
||||
rows = []
|
||||
|
||||
for row in Notice.select():
|
||||
definition = NOTICE_KINDS.get(row.kind)
|
||||
|
||||
if definition is None:
|
||||
continue
|
||||
|
||||
if row.dismissed_at is not None and not include_dismissed:
|
||||
continue
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"id": row.id,
|
||||
"kind": row.kind,
|
||||
"severity": definition.severity.value,
|
||||
"category": definition.category,
|
||||
"scope": row.scope,
|
||||
"params": row.params,
|
||||
"link": definition.link_for(row.params),
|
||||
"first_seen": row.first_seen,
|
||||
"last_seen": row.last_seen,
|
||||
"count": row.count,
|
||||
"dismissed_at": row.dismissed_at,
|
||||
}
|
||||
)
|
||||
|
||||
rows.sort(
|
||||
key=lambda n: (
|
||||
SEVERITY_ORDER[NOTICE_KINDS[n["kind"]].severity],
|
||||
-n["last_seen"],
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
def stats(self) -> list[dict[str, Any]]:
|
||||
"""Lifetime counts per kind, for later analytics."""
|
||||
return [
|
||||
{
|
||||
"kind": row.kind,
|
||||
"occurrences": row.occurrences,
|
||||
"dismissals": row.dismissals,
|
||||
"first_seen": row.first_seen,
|
||||
"last_seen": row.last_seen,
|
||||
"reported_occurrences": row.reported_occurrences,
|
||||
"reported_dismissals": row.reported_dismissals,
|
||||
}
|
||||
for row in NoticeStats.select()
|
||||
if row.kind in NOTICE_KINDS
|
||||
]
|
||||
|
||||
def _write_repeats(
|
||||
self,
|
||||
row: Notice,
|
||||
kind: str,
|
||||
count: int,
|
||||
last_seen: float,
|
||||
params: dict[str, Any],
|
||||
) -> None:
|
||||
# called with the lock held
|
||||
fields: dict[str, Any] = {
|
||||
"count": row.count + count,
|
||||
"last_seen": last_seen,
|
||||
"params": params,
|
||||
}
|
||||
reopen_at = NOTICE_KINDS[kind].reopen_at_count
|
||||
|
||||
if reopen_at is not None and row.count < reopen_at <= row.count + count:
|
||||
fields["dismissed_at"] = None
|
||||
|
||||
Notice.update(**fields).where(Notice.id == row.id).execute()
|
||||
self._bump_occurrences(kind, count, last_seen)
|
||||
|
||||
def _prune(self, kind: str, keep: int) -> None:
|
||||
# called with the lock held
|
||||
newest = (
|
||||
Notice.select(Notice.id)
|
||||
.where(Notice.kind == kind)
|
||||
.order_by(Notice.first_seen.desc())
|
||||
.limit(keep)
|
||||
)
|
||||
Notice.delete().where(
|
||||
Notice.kind == kind,
|
||||
Notice.id.not_in(newest),
|
||||
).execute()
|
||||
|
||||
def _bump_occurrences(self, kind: str, count: int, now: float) -> None:
|
||||
# called with the lock held
|
||||
stats = NoticeStats.get_or_none(NoticeStats.kind == kind)
|
||||
|
||||
if stats is None:
|
||||
NoticeStats.create(
|
||||
kind=kind,
|
||||
occurrences=count,
|
||||
dismissals=0,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
else:
|
||||
NoticeStats.update(
|
||||
occurrences=NoticeStats.occurrences + count, last_seen=now
|
||||
).where(NoticeStats.kind == kind).execute()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user