mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-06 14:52:47 +03:00
Compare 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
|
||||
@@ -54,10 +54,10 @@ jobs:
|
||||
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
|
||||
@@ -86,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
|
||||
@@ -109,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
|
||||
|
||||
+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,20 +1,20 @@
|
||||
aiofiles == 25.1.*
|
||||
click == 8.5.*
|
||||
aiofiles == 24.1.*
|
||||
click == 8.1.*
|
||||
# FastAPI
|
||||
aiohttp == 3.12.*
|
||||
starlette == 0.47.*
|
||||
starlette-context == 0.4.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
|
||||
uvicorn == 0.46.*
|
||||
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.21.* ; platform_machine == 'x86_64'
|
||||
tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
|
||||
# General
|
||||
mypy == 1.6.1
|
||||
onvif-zeep-async == 4.0.*
|
||||
@@ -26,10 +26,10 @@ 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.*
|
||||
@@ -51,25 +51,32 @@ 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.*
|
||||
importlib-metadata==5.1.*
|
||||
importlib-resources==5.1.*
|
||||
netaddr==0.8.*
|
||||
netifaces==0.10.*
|
||||
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.12.*
|
||||
faster-whisper==1.2.*
|
||||
faster-whisper==1.1.*
|
||||
librosa==0.11.*
|
||||
soundfile==0.13.*
|
||||
# Memory profiling
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -297,12 +297,6 @@ 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**.
|
||||
@@ -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`.
|
||||
|
||||
@@ -124,8 +124,6 @@ Additionally, the USB Coral draws a considerable amount of power. If using any o
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
#### Installation
|
||||
|
||||
:::warning
|
||||
@@ -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**
|
||||
|
||||
|
||||
@@ -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](/configuration/object_detectors#hailo-8) | 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 `type` from your config (`hailo8l`, `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
|
||||
|
||||
|
||||
@@ -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
+377
-554
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -28,7 +28,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
||||
"docusaurus-theme-openapi-docs": "^4.5.1",
|
||||
"js-yaml": "^4.3.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"marked": "^16.4.2",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
|
||||
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:
|
||||
|
||||
@@ -190,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"]))],
|
||||
|
||||
+3
-74
@@ -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)
|
||||
@@ -934,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
|
||||
@@ -966,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",
|
||||
|
||||
+22
-41
@@ -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)
|
||||
@@ -1088,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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
|
||||
@@ -12,7 +12,6 @@ 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,
|
||||
@@ -619,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,
|
||||
@@ -695,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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -60,58 +52,11 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -183,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), # type: ignore[call-arg, arg-type, misc]
|
||||
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))) # type: ignore[call-arg, arg-type, misc]
|
||||
.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), # type: ignore[call-arg, misc]
|
||||
).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()
|
||||
@@ -1,101 +0,0 @@
|
||||
"""Notice kinds and the definitions that fix their severity and category."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class NoticeSeverity(str, Enum):
|
||||
error = "error"
|
||||
warning = "warning"
|
||||
info = "info"
|
||||
|
||||
|
||||
SEVERITY_ORDER: dict[NoticeSeverity, int] = {
|
||||
NoticeSeverity.error: 0,
|
||||
NoticeSeverity.warning: 1,
|
||||
NoticeSeverity.info: 2,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NoticeKind:
|
||||
"""One kind of notice. Producers pass the key; everything else comes from here.
|
||||
|
||||
Attributes:
|
||||
key: Stable machine-readable name, also the translation key suffix
|
||||
severity: error, warning, or info
|
||||
category: camera, detector, model, or system; a camera scope is a
|
||||
camera name, and the UI shows it
|
||||
link: app route or absolute URL for the row, filled in from params
|
||||
counts_repeats: whether raising an existing notice counts another occurrence
|
||||
batch_repeats: whether repeats wait in memory for the next flush
|
||||
reopen_at_count: count at which a dismissed notice shows again
|
||||
keep_latest: rows of this kind to keep; a new row drops the oldest
|
||||
reportable: whether a future analytics reporter may send this kind's counts
|
||||
"""
|
||||
|
||||
key: str
|
||||
severity: NoticeSeverity
|
||||
category: str
|
||||
link: str | None = None
|
||||
counts_repeats: bool = True
|
||||
batch_repeats: bool = False
|
||||
reopen_at_count: int | None = None
|
||||
keep_latest: int | None = None
|
||||
reportable: bool = True
|
||||
|
||||
def link_for(self, params: dict[str, Any]) -> str | None:
|
||||
"""The link with params filled in, or None if a param is missing."""
|
||||
if self.link is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self.link.format(**params)
|
||||
except (KeyError, IndexError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
_KINDS = (
|
||||
NoticeKind(
|
||||
"detector_stuck", NoticeSeverity.warning, "detector", link="/system#general"
|
||||
),
|
||||
NoticeKind("model_download_failed", NoticeSeverity.error, "model"),
|
||||
NoticeKind(
|
||||
"skipped_detections",
|
||||
NoticeSeverity.warning,
|
||||
"camera",
|
||||
link="/system#cameras",
|
||||
),
|
||||
NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"),
|
||||
# one row per user per burst; the login log lines carry the address
|
||||
NoticeKind(
|
||||
"failed_login",
|
||||
NoticeSeverity.warning,
|
||||
"system",
|
||||
link="/logs",
|
||||
batch_repeats=True,
|
||||
reopen_at_count=5,
|
||||
keep_latest=100,
|
||||
),
|
||||
# one row per release, so a dismissal lasts until the next release
|
||||
NoticeKind(
|
||||
"update_available",
|
||||
NoticeSeverity.info,
|
||||
"system",
|
||||
link="https://github.com/blakeblackshear/frigate/releases/tag/v{version}",
|
||||
counts_repeats=False,
|
||||
keep_latest=1,
|
||||
),
|
||||
)
|
||||
|
||||
NOTICE_KINDS: dict[str, NoticeKind] = {kind.key: kind for kind in _KINDS}
|
||||
|
||||
# the Health tab builds config and stream check rows in the browser, so a notice
|
||||
# row of these kinds only records a dismissal; its other fields are placeholders
|
||||
CHECK_KINDS = frozenset({"config", "stream"})
|
||||
|
||||
|
||||
def notice_id(kind: str, scope: str | None) -> str:
|
||||
"""The row id for a kind and optional scope."""
|
||||
return f"{kind}:{scope}" if scope else kind
|
||||
@@ -353,23 +353,9 @@ class ObjectDetectProcess:
|
||||
logging.info("Detection process has exited...")
|
||||
|
||||
def start_or_restart(self) -> None:
|
||||
if (self.detect_process is not None) and self.detect_process.is_alive():
|
||||
logging.info("Waiting for detection process to exit gracefully...")
|
||||
self.detect_process.join(timeout=30)
|
||||
if self.detect_process.exitcode is None:
|
||||
# detection_start is set only after detection_queue.get()
|
||||
# returns. If it was reset during the grace period, the process
|
||||
# recovered and may be waiting on the shared queue again.
|
||||
if self.detection_start.value == 0.0: # type: ignore[attr-defined]
|
||||
logging.info("Detection process recovered before restart")
|
||||
return
|
||||
|
||||
logging.info("Detection process didn't exit. Force killing...")
|
||||
self.detect_process.kill()
|
||||
self.detect_process.join()
|
||||
logging.info("Detection process has exited...")
|
||||
|
||||
self.detection_start.value = 0.0 # type: ignore[attr-defined]
|
||||
if (self.detect_process is not None) and self.detect_process.is_alive():
|
||||
self.stop()
|
||||
|
||||
# Async path for MemryX
|
||||
if self.detector_config.type == "memryx":
|
||||
|
||||
+24
-484
@@ -1,6 +1,5 @@
|
||||
"""Export recordings to storage."""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
@@ -11,7 +10,6 @@ import string
|
||||
import subprocess as sp
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -38,56 +36,13 @@ from frigate.ffmpeg_presets import (
|
||||
from frigate.models import Export, Previews, Recordings, ReviewSegment
|
||||
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
||||
from frigate.util.ownership import chown_to_runtime
|
||||
from frigate.util.recording_coverage import (
|
||||
build_spans,
|
||||
known_video_codecs,
|
||||
resolve_coverage,
|
||||
stream_media_summary,
|
||||
)
|
||||
from frigate.util.services import get_video_properties
|
||||
from frigate.util.time import is_current_hour
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS = "-an"
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
|
||||
TIMELAPSE_DATA_INPUT_ARGS = "-skip_frame nokey"
|
||||
|
||||
# nginx-vod repackages each stream into fMP4 with a timescale derived from
|
||||
# that stream's frame rate, and the concat demuxer rescales every input to
|
||||
# whatever timebase the first one happens to use. Staging each run with one
|
||||
# explicit timescale is what keeps a 5fps sub run from being replayed at the
|
||||
# main stream's rate. 90000 is the RTSP clock rate and divides evenly by
|
||||
# every common camera frame rate.
|
||||
EXPORT_TRACK_TIMESCALE = 90000
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamRun:
|
||||
"""A contiguous slice of an export served by a single stream type.
|
||||
|
||||
sample_path is one recording from the run, used to probe the stream's
|
||||
resolution when the runs have to be scaled to a common size.
|
||||
"""
|
||||
|
||||
stream_type: str
|
||||
start_time: float
|
||||
end_time: float
|
||||
sample_path: str
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return max(0.0, self.end_time - self.start_time)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ChapterWindow:
|
||||
"""A merged-timeline slice, shaped like the recording rows chapters read."""
|
||||
|
||||
start_time: float
|
||||
end_time: float
|
||||
|
||||
TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey"
|
||||
|
||||
# Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS).
|
||||
# Captures the floating-point factor so we can scale expected duration.
|
||||
@@ -251,20 +206,6 @@ class PlaybackSourceEnum(str, Enum):
|
||||
preview = "preview"
|
||||
|
||||
|
||||
class ExportStreamEnum(str, Enum):
|
||||
"""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.
|
||||
"""
|
||||
|
||||
auto = "auto"
|
||||
main = STREAM_TYPE_MAIN
|
||||
sub = STREAM_TYPE_SUB
|
||||
|
||||
|
||||
EXPORT_FILE_NAME_MAX_BYTES = 255
|
||||
|
||||
|
||||
@@ -299,7 +240,6 @@ class RecordingExporter(threading.Thread):
|
||||
ffmpeg_output_args: str | None = None,
|
||||
cpu_fallback: bool = False,
|
||||
chapters: ChaptersEnum | None = None,
|
||||
stream: ExportStreamEnum = ExportStreamEnum.auto,
|
||||
on_progress: Callable[[str, float], None] | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
@@ -316,10 +256,7 @@ class RecordingExporter(threading.Thread):
|
||||
self.ffmpeg_output_args = ffmpeg_output_args
|
||||
self.cpu_fallback = cpu_fallback
|
||||
self.chapters = chapters
|
||||
self.stream = stream
|
||||
self.on_progress = on_progress
|
||||
self.staged_runs: list[str] = []
|
||||
self._coverage: tuple[list[list[Any]], set[str], bool] | None = None
|
||||
|
||||
# ensure export thumb dir
|
||||
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
|
||||
@@ -367,337 +304,6 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
return input_duration * factor
|
||||
|
||||
@property
|
||||
def pinned_stream(self) -> str | None:
|
||||
"""The stream type this export is pinned to, or None for auto."""
|
||||
return None if self.stream == ExportStreamEnum.auto else self.stream.value
|
||||
|
||||
def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]:
|
||||
"""Resolve the export range into the spans the VOD manifest will serve.
|
||||
|
||||
Delegates to the same coverage resolution the manifest builder
|
||||
uses, so what we plan around and what nginx-vod emits agree by
|
||||
construction. Returns the spans (each [row, start, end, is_main]),
|
||||
the known video codecs, and whether audio survives the range.
|
||||
Memoized: several stages of the export ask the same question, and
|
||||
the recordings backing a finished range do not change under us.
|
||||
"""
|
||||
if self._coverage is None:
|
||||
intervals = resolve_coverage(self.camera, self.start_time, self.end_time)
|
||||
self._coverage = (
|
||||
build_spans(intervals, self.pinned_stream),
|
||||
known_video_codecs(intervals),
|
||||
self._audio_is_uniform(stream_media_summary(intervals)),
|
||||
)
|
||||
|
||||
return self._coverage
|
||||
|
||||
def _audio_is_uniform(self, summary: dict[str, dict[str, Any]]) -> bool:
|
||||
"""Whether every stream in range carries audio with the same signature.
|
||||
|
||||
Stream-copying audio across a hand-off only works when both
|
||||
streams agree, the same rule the merged manifest applies when it
|
||||
decides to serve a mixed range without audio.
|
||||
"""
|
||||
# legacy rows report None rather than False, and an unknown
|
||||
# signature is not one we can promise lines up
|
||||
if not summary or any(
|
||||
stream["has_audio"] is not True for stream in summary.values()
|
||||
):
|
||||
return False
|
||||
|
||||
return (
|
||||
len(
|
||||
{
|
||||
(stream["audio_codec"], stream["audio_rate"])
|
||||
for stream in summary.values()
|
||||
}
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
def _merged_spans(self) -> list[list[Any]]:
|
||||
return self._resolve_coverage()[0]
|
||||
|
||||
def _prepare_stream_runs(self) -> bool:
|
||||
"""Stage each stream run to a temp file when the range spans more than one.
|
||||
|
||||
Leaves ``staged_runs`` empty for a single-stream range, which is
|
||||
the overwhelmingly common case and keeps the existing
|
||||
single-playlist path byte for byte what it was. Returns False only
|
||||
when staging was needed and failed.
|
||||
"""
|
||||
if self.pinned_stream is not None:
|
||||
# a pinned range is uniform by construction, so there is no
|
||||
# hand-off to stage around
|
||||
return True
|
||||
|
||||
spans, codecs, keep_audio = self._resolve_coverage()
|
||||
runs = self._stream_runs(spans)
|
||||
|
||||
# a range one stream covers end to end has nothing to hand off,
|
||||
# so it stays on the existing path however long it is
|
||||
if len(runs) < 2:
|
||||
return True
|
||||
|
||||
runs = [piece for run in runs for piece in self._split_long_run(run)]
|
||||
return self._stage_stream_runs(runs, codecs, keep_audio)
|
||||
|
||||
def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]:
|
||||
"""Collapse the merged spans into contiguous runs of one stream type.
|
||||
|
||||
A run is the largest slice of the export that a single stream
|
||||
covers end to end, and is therefore the largest chunk we can hand
|
||||
to ffmpeg without the parameter sets changing underneath it.
|
||||
"""
|
||||
runs: list[StreamRun] = []
|
||||
|
||||
for row, span_start, span_end, is_main in spans:
|
||||
stream_type = STREAM_TYPE_MAIN if is_main else STREAM_TYPE_SUB
|
||||
|
||||
if runs and runs[-1].stream_type == stream_type:
|
||||
runs[-1].end_time = span_end
|
||||
else:
|
||||
runs.append(StreamRun(stream_type, span_start, span_end, row.path))
|
||||
|
||||
return runs
|
||||
|
||||
def _split_long_run(self, run: StreamRun) -> list[StreamRun]:
|
||||
"""Break a run into playlist-sized pieces.
|
||||
|
||||
Each run is fetched as one pinned VOD playlist, and nginx-vod caps
|
||||
how many clips a single mapping may hold. This is the same bound
|
||||
the unstaged path respects by paging its playlist lines. Splitting
|
||||
is free here: both halves are the same stream, so they share the
|
||||
parameter sets and the timebase.
|
||||
"""
|
||||
if run.duration <= MAX_PLAYLIST_SECONDS:
|
||||
return [run]
|
||||
|
||||
pieces: list[StreamRun] = []
|
||||
start = run.start_time
|
||||
|
||||
while start < run.end_time:
|
||||
end = min(start + MAX_PLAYLIST_SECONDS, run.end_time)
|
||||
pieces.append(StreamRun(run.stream_type, start, end, run.sample_path))
|
||||
start = end
|
||||
|
||||
return pieces
|
||||
|
||||
def _internal_port(self) -> int:
|
||||
"""The API port to fetch VOD playlists from."""
|
||||
internal_port = self.config.networking.listen.internal
|
||||
|
||||
# handle case where internal port is a string with ip:port
|
||||
if isinstance(internal_port, str):
|
||||
return int(internal_port.split(":")[-1])
|
||||
|
||||
return internal_port
|
||||
|
||||
def _vod_url(self, stream_type: str | None, start: float, end: float) -> str:
|
||||
"""A VOD playlist URL, pinned to one stream type when given."""
|
||||
pin = f"/{stream_type}" if stream_type else ""
|
||||
return (
|
||||
f"http://127.0.0.1:{self._internal_port()}/vod/{self.camera}{pin}"
|
||||
f"/start/{start}/end/{end}/index.m3u8"
|
||||
)
|
||||
|
||||
def _staged_run_path(self, index: int) -> str:
|
||||
return os.path.join(CACHE_DIR, f"export_stage_{self.export_id}_{index}.mp4")
|
||||
|
||||
def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None:
|
||||
"""Probe one recording from a run for its resolution.
|
||||
|
||||
Only the scaling path needs this, and one segment per stream is
|
||||
enough: a stream's resolution is fixed for as long as the camera
|
||||
keeps its configuration.
|
||||
"""
|
||||
try:
|
||||
properties = asyncio.run(
|
||||
get_video_properties(self.config.ffmpeg, run.sample_path)
|
||||
)
|
||||
except OSError:
|
||||
logger.exception("Failed to probe %s for export sizing", run.sample_path)
|
||||
return None
|
||||
|
||||
width = properties.get("width")
|
||||
height = properties.get("height")
|
||||
|
||||
if not width or not height:
|
||||
return None
|
||||
|
||||
return int(width), int(height)
|
||||
|
||||
def _staged_progress(
|
||||
self, step: str, base: float, weight: float
|
||||
) -> Callable[[float], None]:
|
||||
"""Map one run's 0-100 progress onto its slice of the whole pass."""
|
||||
|
||||
def report(percent: float) -> None:
|
||||
self._emit_progress(step, base + (percent / 100.0) * weight)
|
||||
|
||||
return report
|
||||
|
||||
def _stage_run_command(
|
||||
self,
|
||||
run: StreamRun,
|
||||
dest: str,
|
||||
target: tuple[int, int] | None,
|
||||
keep_audio: bool,
|
||||
) -> list[str]:
|
||||
"""Build the ffmpeg command that renders one run to a temp file.
|
||||
|
||||
Without a target the run is stream-copied, which is all a mixed
|
||||
*resolution* export needs. A target is only set when the runs also
|
||||
disagree on codec, where one mp4 track genuinely cannot hold both
|
||||
and every run has to be re-encoded to match.
|
||||
"""
|
||||
ffmpeg_input = (
|
||||
"-y -protocol_whitelist pipe,file,http,tcp "
|
||||
f"-i {self._vod_url(run.stream_type, run.start_time, run.end_time)}"
|
||||
)
|
||||
# audio only survives when both streams agree on it, otherwise the
|
||||
# copied track breaks at the same hand-off the video used to. These
|
||||
# are output options: "-c:a copy" ahead of -i selects a *decoder*
|
||||
# named copy, which does not exist
|
||||
audio_args = "-c:a copy" if keep_audio else "-an"
|
||||
|
||||
if target is None:
|
||||
return (
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} "
|
||||
f"{audio_args} -c:v copy "
|
||||
f"-video_track_timescale {EXPORT_TRACK_TIMESCALE} {dest}"
|
||||
).split(" ")
|
||||
|
||||
width, height = target
|
||||
# pad rather than stretch: the sub stream is often a different
|
||||
# aspect ratio than the main one, and letterboxing it is honest
|
||||
# where distorting the footage is not
|
||||
scale = (
|
||||
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,setsar=1"
|
||||
)
|
||||
# deliberately software-encoded. The vaapi and nvidia encode presets
|
||||
# set -hwaccel_output_format, which leaves decoded frames in GPU
|
||||
# memory where scale/pad cannot reach them; making this pass use the
|
||||
# GPU means per-preset hwdownload/hwupload chains (see the birdseye
|
||||
# vaapi preset). Codec-mixed ranges are rare enough to eat the CPU
|
||||
# cost rather than ship an untested filter graph
|
||||
return parse_preset_hardware_acceleration_encode(
|
||||
self.config.ffmpeg.ffmpeg_path,
|
||||
None,
|
||||
ffmpeg_input,
|
||||
f"{audio_args} -vf {scale} "
|
||||
f"-video_track_timescale {EXPORT_TRACK_TIMESCALE} {dest}",
|
||||
EncodeTypeEnum.timelapse,
|
||||
).split(" ")
|
||||
|
||||
def _stage_stream_runs(
|
||||
self,
|
||||
runs: list[StreamRun],
|
||||
codecs: set[str],
|
||||
keep_audio: bool,
|
||||
) -> bool:
|
||||
"""Render each run to its own temp file, one stream type at a time.
|
||||
|
||||
This is what makes a mixed-resolution export work. Handing the
|
||||
merged playlist straight to ffmpeg looks like it should work,
|
||||
since nginx-vod marks the stream change with a discontinuity and a
|
||||
fresh EXT-X-MAP, but ffmpeg's HLS demuxer binds the track's
|
||||
parameter sets from the *first* init segment only. Every sample
|
||||
after the hand-off is then decoded against the wrong SPS, which is
|
||||
what freezes the sub-resolution stretches of the output.
|
||||
|
||||
Demuxing each run on its own gives each one its correct parameter
|
||||
sets, and the mp4 muxer writes them in-band at the hand-off, so
|
||||
the concatenated result decodes cleanly without re-encoding.
|
||||
|
||||
Returns False when staging failed. The caller must abort rather
|
||||
than fall back to the merged playlist, which is the very thing
|
||||
that produces the broken file.
|
||||
"""
|
||||
target: tuple[int, int] | None = None
|
||||
|
||||
if len(codecs) > 1:
|
||||
# one mp4 track carries one codec, so a range that mixes them
|
||||
# has to be re-encoded to a common one. Scale up to the
|
||||
# largest stream so the main footage keeps its detail
|
||||
sizes = [
|
||||
size
|
||||
for size in (self._probe_stream_resolution(run) for run in runs)
|
||||
if size is not None
|
||||
]
|
||||
|
||||
if not sizes:
|
||||
logger.error(
|
||||
"Export %s spans video codecs %s but no run could be probed "
|
||||
"for its resolution",
|
||||
self.export_id,
|
||||
sorted(codecs),
|
||||
)
|
||||
return False
|
||||
|
||||
target = (max(s[0] for s in sizes), max(s[1] for s in sizes))
|
||||
logger.debug(
|
||||
"Export %s spans video codecs %s; re-encoding every run to %dx%d",
|
||||
self.export_id,
|
||||
sorted(codecs),
|
||||
target[0],
|
||||
target[1],
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Export %s spans %d stream runs; staging each one separately",
|
||||
self.export_id,
|
||||
len(runs),
|
||||
)
|
||||
|
||||
total_duration = sum(run.duration for run in runs) or 1.0
|
||||
step = "encoding" if target is not None else "copying"
|
||||
completed = 0.0
|
||||
|
||||
for index, run in enumerate(runs):
|
||||
dest = self._staged_run_path(index)
|
||||
weight = 100.0 * run.duration / total_duration
|
||||
base = 100.0 * completed / total_duration
|
||||
|
||||
# claim the path before ffmpeg can write to it. ffmpeg removes
|
||||
# its own output on a clean error exit, but a killed one (OOM,
|
||||
# container stop) leaves whatever it had already muxed, and a
|
||||
# path that was never recorded is a partial file nothing
|
||||
# deletes
|
||||
self.staged_runs.append(dest)
|
||||
|
||||
returncode, stderr = run_ffmpeg_with_progress(
|
||||
self._stage_run_command(run, dest, target, keep_audio),
|
||||
expected_duration_seconds=run.duration,
|
||||
on_progress=self._staged_progress(step, base, weight),
|
||||
use_low_priority=True,
|
||||
)
|
||||
|
||||
if returncode != 0:
|
||||
logger.error(
|
||||
"Failed to stage %s stream for export %s between %s and %s",
|
||||
run.stream_type,
|
||||
self.export_id,
|
||||
run.start_time,
|
||||
run.end_time,
|
||||
)
|
||||
logger.error(stderr)
|
||||
self._cleanup_staged_runs()
|
||||
return False
|
||||
|
||||
completed += run.duration
|
||||
|
||||
return True
|
||||
|
||||
def _cleanup_staged_runs(self) -> None:
|
||||
"""Remove any temp files left behind by staging."""
|
||||
for path in self.staged_runs:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
|
||||
self.staged_runs = []
|
||||
|
||||
def _get_recordings_for_range(self, stream_type: str) -> list[Any]:
|
||||
"""Fetch one stream type's recording rows overlapping the export range."""
|
||||
return list(
|
||||
@@ -731,16 +337,12 @@ class RecordingExporter(threading.Thread):
|
||||
"""
|
||||
try:
|
||||
if self.playback_source == PlaybackSourceEnum.recordings:
|
||||
# the merged timeline is what actually gets exported: main
|
||||
# where it exists, sub filling the gaps it leaves behind.
|
||||
# Summing one stream alone under-reports a mixed range and
|
||||
# pins progress at 100% for the rest of the export
|
||||
return float(
|
||||
sum(
|
||||
max(0.0, span_end - span_start)
|
||||
for _row, span_start, span_end, _is_main in self._merged_spans()
|
||||
)
|
||||
)
|
||||
# never mix streams in one estimate; use main when available
|
||||
# and fall back to sub for expired-main history
|
||||
rows = self._get_recordings_for_range(STREAM_TYPE_MAIN)
|
||||
|
||||
if not rows:
|
||||
rows = self._get_recordings_for_range(STREAM_TYPE_SUB)
|
||||
else:
|
||||
rows = (
|
||||
Previews.select(Previews.start_time, Previews.end_time)
|
||||
@@ -1121,40 +723,21 @@ class RecordingExporter(threading.Thread):
|
||||
def get_record_export_command(
|
||||
self, video_path: str, use_hwaccel: bool = True
|
||||
) -> tuple[list[str], str | list[str]]:
|
||||
if self.staged_runs:
|
||||
# each run was already rendered to a temp file with a common
|
||||
# track timescale, so the concat demuxer has nothing left to
|
||||
# reconcile and every chapter offset lines up with the merged
|
||||
# timeline the staged files reproduce
|
||||
recordings = [
|
||||
_ChapterWindow(span_start, span_end)
|
||||
for _row, span_start, span_end, _is_main in self._merged_spans()
|
||||
]
|
||||
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
|
||||
ffmpeg_input = (
|
||||
"-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin"
|
||||
)
|
||||
return self._finish_record_export_command(
|
||||
video_path, ffmpeg_input, playlist_lines, recordings, use_hwaccel
|
||||
)
|
||||
# handle case where internal port is a string with ip:port
|
||||
internal_port = self.config.networking.listen.internal
|
||||
if type(internal_port) is str:
|
||||
internal_port = int(internal_port.split(":")[-1])
|
||||
|
||||
pin = self.pinned_stream
|
||||
# never mix streams in one playlist; use main when available and
|
||||
# fall back to sub for expired-main history
|
||||
recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN)
|
||||
|
||||
if pin is not None:
|
||||
# a pinned export reads that stream and only that stream, so
|
||||
# its own rows are the ones the chapters describe
|
||||
recordings = self._get_recordings_for_range(pin)
|
||||
else:
|
||||
# never mix streams in one playlist; use main when available
|
||||
# and fall back to sub for expired-main history
|
||||
recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN)
|
||||
if not recordings:
|
||||
recordings = self._get_recordings_for_range(STREAM_TYPE_SUB)
|
||||
|
||||
if not recordings:
|
||||
recordings = self._get_recordings_for_range(STREAM_TYPE_SUB)
|
||||
|
||||
playlist_lines = []
|
||||
playlist_lines: list[str] = []
|
||||
if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS:
|
||||
playlist_url = self._vod_url(pin, self.start_time, self.end_time)
|
||||
playlist_url = f"http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
|
||||
ffmpeg_input = (
|
||||
f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_url}"
|
||||
)
|
||||
@@ -1164,26 +747,12 @@ class RecordingExporter(threading.Thread):
|
||||
page_size = 1000
|
||||
for i in range(0, len(recordings), page_size):
|
||||
chunk = recordings[i : i + page_size]
|
||||
chunk_url = self._vod_url(
|
||||
pin, float(chunk[0].start_time), float(chunk[-1].end_time)
|
||||
playlist_lines.append(
|
||||
f"file 'http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{float(chunk[0].start_time)}/end/{float(chunk[-1].end_time)}/index.m3u8'"
|
||||
)
|
||||
playlist_lines.append(f"file '{chunk_url}'")
|
||||
|
||||
ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin"
|
||||
|
||||
return self._finish_record_export_command(
|
||||
video_path, ffmpeg_input, playlist_lines, recordings, use_hwaccel
|
||||
)
|
||||
|
||||
def _finish_record_export_command(
|
||||
self,
|
||||
video_path: str,
|
||||
ffmpeg_input: str,
|
||||
playlist_lines: list[str],
|
||||
recordings: list[Any],
|
||||
use_hwaccel: bool,
|
||||
) -> tuple[list[str], str | list[str]]:
|
||||
"""Apply encoding, chapters, and metadata to a prepared input."""
|
||||
if self.ffmpeg_input_args is not None and self.ffmpeg_output_args is not None:
|
||||
hwaccel_args = (
|
||||
self.config.cameras[self.camera].record.export.hwaccel_args
|
||||
@@ -1194,7 +763,7 @@ class RecordingExporter(threading.Thread):
|
||||
parse_preset_hardware_acceleration_encode(
|
||||
self.config.ffmpeg.ffmpeg_path,
|
||||
hwaccel_args,
|
||||
f"{self.ffmpeg_input_args} {ffmpeg_input}".strip(),
|
||||
f"{self.ffmpeg_input_args} -an {ffmpeg_input}".strip(),
|
||||
f"{self.ffmpeg_output_args} -movflags +faststart".strip(),
|
||||
EncodeTypeEnum.timelapse,
|
||||
)
|
||||
@@ -1390,32 +959,8 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
Export.insert(export_values).execute()
|
||||
|
||||
try:
|
||||
self._run_export(video_path, thumb_path)
|
||||
finally:
|
||||
# staged runs hold a full copy of the export, so they must not
|
||||
# survive a failure the way the small chapter file could
|
||||
self._cleanup_staged_runs()
|
||||
|
||||
def _discard_failed_export(self, video_path: str, thumb_path: str) -> None:
|
||||
"""Drop the partial output and the row that promised it."""
|
||||
Path(video_path).unlink(missing_ok=True)
|
||||
Export.delete().where(Export.id == self.export_id).execute()
|
||||
Path(thumb_path).unlink(missing_ok=True)
|
||||
|
||||
def _run_export(self, video_path: str, thumb_path: str) -> None:
|
||||
try:
|
||||
if self.playback_source == PlaybackSourceEnum.recordings:
|
||||
if not self._prepare_stream_runs():
|
||||
# the merged playlist is what staging exists to avoid;
|
||||
# falling back to it would hand the user a file whose
|
||||
# sub-resolution stretches are frozen
|
||||
logger.error(
|
||||
"Failed to stage stream runs for export %s", self.export_id
|
||||
)
|
||||
self._discard_failed_export(video_path, thumb_path)
|
||||
return
|
||||
|
||||
ffmpeg_cmd, playlist_lines = self.get_record_export_command(video_path)
|
||||
else:
|
||||
ffmpeg_cmd, playlist_lines = self.get_preview_export_command(video_path)
|
||||
@@ -1432,10 +977,6 @@ class RecordingExporter(threading.Thread):
|
||||
)
|
||||
initial_step = "copying" if is_stream_copy else "encoding"
|
||||
|
||||
if self.staged_runs:
|
||||
# staging already reported a full pass under its own step
|
||||
initial_step = "merging"
|
||||
|
||||
returncode, stderr = self._run_ffmpeg_with_progress(
|
||||
ffmpeg_cmd, playlist_lines, step=initial_step
|
||||
)
|
||||
@@ -1452,9 +993,6 @@ class RecordingExporter(threading.Thread):
|
||||
)
|
||||
|
||||
if self.playback_source == PlaybackSourceEnum.recordings:
|
||||
# staged runs are always software-encoded, so there is no
|
||||
# hwaccel in them to fall back from; only the merge pass
|
||||
# is rebuilt here
|
||||
ffmpeg_cmd, playlist_lines = self.get_record_export_command(
|
||||
video_path, use_hwaccel=False
|
||||
)
|
||||
@@ -1474,7 +1012,9 @@ class RecordingExporter(threading.Thread):
|
||||
f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}"
|
||||
)
|
||||
logger.error(stderr)
|
||||
self._discard_failed_export(video_path, thumb_path)
|
||||
Path(video_path).unlink(missing_ok=True)
|
||||
Export.delete().where(Export.id == self.export_id).execute()
|
||||
Path(thumb_path).unlink(missing_ok=True)
|
||||
return
|
||||
else:
|
||||
chown_to_runtime(video_path)
|
||||
|
||||
+4
-134
@@ -11,61 +11,15 @@ from typing import Any
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import FREQUENCY_STATS_POINTS
|
||||
from frigate.notices import flush_notices, raise_notice, resolve_kind, resolve_notice
|
||||
from frigate.stats.hardware import HardwareStats
|
||||
from frigate.stats.prometheus import update_metrics
|
||||
from frigate.stats.util import get_latest_version, is_newer_version, stats_snapshot
|
||||
from frigate.stats.util import stats_snapshot
|
||||
from frigate.types import StatsTrackingTypes
|
||||
from frigate.version import VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
MAX_STATS_POINTS = 80
|
||||
|
||||
# how often to ask GitHub for the latest release
|
||||
VERSION_REFRESH_S = 24 * 60 * 60
|
||||
|
||||
# a camera skipping at least this percent of its frames is falling behind
|
||||
SKIPPED_DETECTIONS_PCT = 5
|
||||
|
||||
# for at least this long before it becomes a notice
|
||||
SKIPPED_DETECTIONS_HOLD_S = 60
|
||||
|
||||
# detectors warm up after a start; the status bar waits this long too
|
||||
STARTUP_GRACE_S = 120
|
||||
|
||||
|
||||
class SkippedDetectionsTracker:
|
||||
"""Finds cameras whose skipped share stays high long enough for a notice."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._since: dict[str, float] = {}
|
||||
self._raised: set[str] = set()
|
||||
|
||||
def update(self, cameras: dict[str, dict[str, Any]], now: float) -> list[str]:
|
||||
"""Return the cameras whose episode qualified on this sample."""
|
||||
qualified: list[str] = []
|
||||
|
||||
# a removed camera that comes back starts a new episode
|
||||
for camera in self._since.keys() - cameras.keys():
|
||||
self._since.pop(camera)
|
||||
self._raised.discard(camera)
|
||||
|
||||
for camera, camera_stats in cameras.items():
|
||||
if camera_stats["skipped_pct"] < SKIPPED_DETECTIONS_PCT:
|
||||
self._since.pop(camera, None)
|
||||
self._raised.discard(camera)
|
||||
continue
|
||||
|
||||
since = self._since.setdefault(camera, now)
|
||||
|
||||
if camera not in self._raised and now - since >= SKIPPED_DETECTIONS_HOLD_S:
|
||||
self._raised.add(camera)
|
||||
qualified.append(camera)
|
||||
|
||||
return qualified
|
||||
|
||||
|
||||
class StatsEmitter(threading.Thread):
|
||||
def __init__(
|
||||
@@ -78,13 +32,8 @@ class StatsEmitter(threading.Thread):
|
||||
self.config = config
|
||||
self.stats_tracking = stats_tracking
|
||||
self.stop_event = stop_event
|
||||
self.hardware_stats = HardwareStats(config)
|
||||
self.hwaccel_errors: dict[str, float] = {}
|
||||
self.stats_history: list[dict[str, Any]] = []
|
||||
self.skipped_detections = SkippedDetectionsTracker()
|
||||
|
||||
# the shm notice's params as last sent, so only a change is written
|
||||
self._shm_checked = False
|
||||
self._shm_params: dict[str, Any] | None = None
|
||||
|
||||
# create communication for stats
|
||||
self.requestor = InterProcessRequestor()
|
||||
@@ -95,7 +44,7 @@ class StatsEmitter(threading.Thread):
|
||||
return self.stats_history[-1]
|
||||
else:
|
||||
stats = stats_snapshot(
|
||||
self.config, self.stats_tracking, self.hardware_stats
|
||||
self.config, self.stats_tracking, self.hwaccel_errors
|
||||
)
|
||||
self.stats_history.append(stats)
|
||||
return stats
|
||||
@@ -175,103 +124,24 @@ class StatsEmitter(threading.Thread):
|
||||
update_metrics(stats)
|
||||
return stats
|
||||
|
||||
def _check_update_notice(self) -> None:
|
||||
"""Raise or resolve the update notice from the tracked latest version."""
|
||||
latest = self.stats_tracking["latest_frigate_version"]
|
||||
|
||||
# a failed lookup says nothing about whether an update exists
|
||||
if latest == "unknown":
|
||||
return
|
||||
|
||||
if is_newer_version(VERSION, latest):
|
||||
raise_notice("update_available", scope=latest, params={"version": latest})
|
||||
else:
|
||||
resolve_kind("update_available")
|
||||
|
||||
def _refresh_latest_version(self) -> None:
|
||||
"""Refresh the latest release on a daemon thread so the request never stalls stats."""
|
||||
|
||||
def refresh() -> None:
|
||||
latest = get_latest_version(self.config)
|
||||
|
||||
# keep the last good value; stats surface it as service.latest_version
|
||||
if latest != "unknown":
|
||||
self.stats_tracking["latest_frigate_version"] = latest
|
||||
|
||||
self._check_update_notice()
|
||||
|
||||
threading.Thread(
|
||||
target=refresh, name="frigate_version_check", daemon=True
|
||||
).start()
|
||||
|
||||
def _update_shm_notice(self, shm: dict[str, Any]) -> None:
|
||||
"""Raise the shm notice while /dev/shm is smaller than the cameras need."""
|
||||
params = (
|
||||
{"total": shm["total"], "min": shm["min_shm"]}
|
||||
if shm and shm["total"] < shm["min_shm"]
|
||||
else None
|
||||
)
|
||||
|
||||
# the first tick always sends, so a row the last run left is raised
|
||||
# again or cleared
|
||||
if self._shm_checked and params == self._shm_params:
|
||||
return
|
||||
|
||||
self._shm_checked = True
|
||||
self._shm_params = params
|
||||
|
||||
if params is None:
|
||||
resolve_notice("shm_too_low")
|
||||
else:
|
||||
raise_notice("shm_too_low", params=params)
|
||||
|
||||
def _update_notices(self, stats: dict[str, Any], now: float) -> None:
|
||||
"""Update notices based on current stats or time."""
|
||||
# skipped detections
|
||||
if stats["service"]["uptime"] >= STARTUP_GRACE_S:
|
||||
for camera in self.skipped_detections.update(stats["cameras"], now):
|
||||
raise_notice(
|
||||
"skipped_detections",
|
||||
scope=camera,
|
||||
params={"pct": stats["cameras"][camera]["skipped_pct"]},
|
||||
)
|
||||
|
||||
# shm too small for the cameras
|
||||
self._update_shm_notice(stats["service"]["storage"]["/dev/shm"])
|
||||
|
||||
# repeats of batched kinds, such as failed logins
|
||||
flush_notices()
|
||||
|
||||
# add any additional notice types here
|
||||
|
||||
def run(self) -> None:
|
||||
time.sleep(10)
|
||||
self._check_update_notice()
|
||||
last_version_check = time.time()
|
||||
for counter in itertools.cycle(
|
||||
range(int(self.config.mqtt.stats_interval / FREQUENCY_STATS_POINTS))
|
||||
):
|
||||
if self.stop_event.wait(FREQUENCY_STATS_POINTS):
|
||||
break
|
||||
|
||||
if time.time() - last_version_check >= VERSION_REFRESH_S:
|
||||
self._refresh_latest_version()
|
||||
last_version_check = time.time()
|
||||
|
||||
logger.debug("Starting stats collection")
|
||||
stats = stats_snapshot(
|
||||
self.config, self.stats_tracking, self.hardware_stats
|
||||
self.config, self.stats_tracking, self.hwaccel_errors
|
||||
)
|
||||
self.stats_history.append(stats)
|
||||
self.stats_history = self.stats_history[-MAX_STATS_POINTS:]
|
||||
self._update_notices(stats, time.time())
|
||||
|
||||
if counter == 0:
|
||||
self.requestor.send_data("stats", json.dumps(stats))
|
||||
|
||||
logger.debug("Finished stats collection")
|
||||
|
||||
# write the repeats held back since the last tick
|
||||
flush_notices()
|
||||
self.hardware_stats.stop()
|
||||
logger.info("Exiting stats emitter...")
|
||||
|
||||
@@ -1,474 +0,0 @@
|
||||
"""Config driven hardware usage collection for stats."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import Future, ThreadPoolExecutor, wait
|
||||
from dataclasses import dataclass, field
|
||||
from functools import cache, partial
|
||||
from glob import glob
|
||||
from typing import Any
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.config.classification import SemanticSearchModelEnum
|
||||
from frigate.const import FFMPEG_HWACCEL_AMF, FFMPEG_HWACCEL_VULKAN
|
||||
from frigate.detectors.hardware import DEV_ROOT, hardware_prober
|
||||
from frigate.util.services import (
|
||||
get_amd_gpu_stats,
|
||||
get_axcl_npu_stats,
|
||||
get_cpu_stats,
|
||||
get_hailo_temps,
|
||||
get_intel_gpu_stats,
|
||||
get_jetson_stats,
|
||||
get_nvidia_gpu_stats,
|
||||
get_openvino_npu_stats,
|
||||
get_rockchip_gpu_stats,
|
||||
get_rockchip_npu_stats,
|
||||
is_vaapi_amd_driver,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HARDWARE_ERROR_COOLDOWN_SECONDS = 3600
|
||||
POLL_TIMEOUT_SECONDS = 10
|
||||
|
||||
# hardware names polled here, in the order used to resolve a generic GPU demand
|
||||
GPU_HARDWARE_KEYS = {
|
||||
"onnx:nvidia": "nvidia",
|
||||
"tensorrt": "jetson",
|
||||
"onnx:amd": "amd_gpu",
|
||||
"openvino:GPU": "intel_gpu",
|
||||
"rknn": "rockchip",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class HardwarePollResult:
|
||||
"""Usage entries from one hardware poll, split by stats bucket."""
|
||||
|
||||
gpu: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
npu: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
ok: bool = True
|
||||
|
||||
|
||||
@cache
|
||||
def _is_amd_vaapi() -> bool:
|
||||
"""Cached vaapi driver check, the driver cannot change at runtime."""
|
||||
return is_vaapi_amd_driver()
|
||||
|
||||
|
||||
def _present_hardware() -> set[str]:
|
||||
return {hardware.key for hardware in hardware_prober.probe()}
|
||||
|
||||
|
||||
def _gpu_device_nodes_exist(name: str) -> bool:
|
||||
"""Whether the GPU's device nodes are visible to this container.
|
||||
|
||||
The prober reads /proc and /sys, which show the host kernel's hardware
|
||||
even when a device was not passed into the container, so a GPU is only
|
||||
usable when its device nodes exist too.
|
||||
"""
|
||||
if name == "nvidia":
|
||||
return bool(glob(f"{DEV_ROOT}/nvidia*"))
|
||||
elif name in ("amd_gpu", "intel_gpu"):
|
||||
return bool(glob(f"{DEV_ROOT}/dri/*"))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _present_gpu() -> str | None:
|
||||
"""The hardware name of the first usable GPU found on this system, if any."""
|
||||
present = _present_hardware()
|
||||
|
||||
for key, name in GPU_HARDWARE_KEYS.items():
|
||||
if key in present and _gpu_device_nodes_exist(name):
|
||||
return name
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _poll_nvidia(config: FrigateConfig) -> HardwarePollResult:
|
||||
nvidia_usage = get_nvidia_gpu_stats()
|
||||
|
||||
if not nvidia_usage:
|
||||
return HardwarePollResult(
|
||||
gpu={"nvidia-gpu": {"vendor": "nvidia", "gpu": "", "mem": ""}}, ok=False
|
||||
)
|
||||
|
||||
gpu: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for entry in nvidia_usage.values():
|
||||
gpu[entry["name"]] = {
|
||||
"vendor": "nvidia",
|
||||
"gpu": str(round(float(entry["gpu"]), 2)) + "%",
|
||||
"mem": str(round(float(entry["mem"]), 2)) + "%",
|
||||
"enc": str(round(float(entry["enc"]), 2)) + "%",
|
||||
"dec": str(round(float(entry["dec"]), 2)) + "%",
|
||||
"temp": str(entry["temp"]),
|
||||
}
|
||||
|
||||
return HardwarePollResult(gpu=gpu)
|
||||
|
||||
|
||||
def _poll_jetson(config: FrigateConfig) -> HardwarePollResult:
|
||||
jetson_usage = get_jetson_stats()
|
||||
|
||||
if not jetson_usage:
|
||||
return HardwarePollResult(
|
||||
gpu={"jetson-gpu": {"vendor": "nvidia", "gpu": "", "mem": ""}}, ok=False
|
||||
)
|
||||
|
||||
return HardwarePollResult(gpu={"jetson-gpu": {"vendor": "nvidia", **jetson_usage}})
|
||||
|
||||
|
||||
def _poll_intel_gpu(config: FrigateConfig) -> HardwarePollResult:
|
||||
intel_usage = get_intel_gpu_stats(config.telemetry.stats.intel_gpu_device)
|
||||
|
||||
if not intel_usage:
|
||||
return HardwarePollResult(
|
||||
gpu={"intel-gpu": {"vendor": "intel", "gpu": "", "mem": ""}}, ok=False
|
||||
)
|
||||
|
||||
gpu: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for entry in intel_usage.values():
|
||||
name = entry.pop("name")
|
||||
gpu[name] = entry
|
||||
|
||||
return HardwarePollResult(gpu=gpu)
|
||||
|
||||
|
||||
def _poll_amd_gpu(config: FrigateConfig) -> HardwarePollResult:
|
||||
amd_usage = get_amd_gpu_stats()
|
||||
|
||||
if not amd_usage:
|
||||
return HardwarePollResult(
|
||||
gpu={"amd-vaapi": {"vendor": "amd", "gpu": "", "mem": ""}}, ok=False
|
||||
)
|
||||
|
||||
return HardwarePollResult(gpu={"amd-vaapi": {"vendor": "amd", **amd_usage}})
|
||||
|
||||
|
||||
def _poll_rockchip(config: FrigateConfig) -> HardwarePollResult:
|
||||
result = HardwarePollResult()
|
||||
rga_usage = get_rockchip_gpu_stats()
|
||||
|
||||
if rga_usage:
|
||||
result.gpu["rockchip"] = {"vendor": "rockchip", **rga_usage}
|
||||
|
||||
npu_usage = get_rockchip_npu_stats()
|
||||
|
||||
if npu_usage:
|
||||
result.npu["rockchip"] = npu_usage
|
||||
|
||||
result.ok = bool(rga_usage or npu_usage)
|
||||
return result
|
||||
|
||||
|
||||
def _poll_rpi(config: FrigateConfig) -> HardwarePollResult:
|
||||
# RPi v4l2m2m is currently not able to get usage stats
|
||||
return HardwarePollResult(
|
||||
gpu={"rpi-v4l2m2m": {"vendor": "rpi", "gpu": "", "mem": ""}}
|
||||
)
|
||||
|
||||
|
||||
def _poll_intel_npu(config: FrigateConfig) -> HardwarePollResult:
|
||||
npu_usage = get_openvino_npu_stats()
|
||||
|
||||
if not npu_usage:
|
||||
return HardwarePollResult(ok=False)
|
||||
|
||||
return HardwarePollResult(npu={"openvino": npu_usage})
|
||||
|
||||
|
||||
def _poll_axengine(config: FrigateConfig) -> HardwarePollResult:
|
||||
npu_usage = get_axcl_npu_stats()
|
||||
|
||||
if not npu_usage:
|
||||
return HardwarePollResult(ok=False)
|
||||
|
||||
return HardwarePollResult(npu={"axengine": npu_usage})
|
||||
|
||||
|
||||
POLLERS: dict[str, Callable[[FrigateConfig], HardwarePollResult]] = {
|
||||
"nvidia": _poll_nvidia,
|
||||
"jetson": _poll_jetson,
|
||||
"intel_gpu": _poll_intel_gpu,
|
||||
"amd_gpu": _poll_amd_gpu,
|
||||
"rockchip": _poll_rockchip,
|
||||
"rpi": _poll_rpi,
|
||||
"intel_npu": _poll_intel_npu,
|
||||
"axengine": _poll_axengine,
|
||||
}
|
||||
|
||||
|
||||
def _hwaccel_hardware(args: str) -> str | None:
|
||||
"""Map an ffmpeg hwaccel arg string (preset or raw args) to a hardware name."""
|
||||
if "cuvid" in args or "nvidia" in args:
|
||||
return "nvidia"
|
||||
elif "nvmpi" in args or "jetson" in args:
|
||||
return "jetson"
|
||||
elif "qsv" in args:
|
||||
return "intel_gpu"
|
||||
elif FFMPEG_HWACCEL_AMF in args or "amf" in args:
|
||||
return "amd_gpu"
|
||||
elif "vaapi" in args:
|
||||
return "amd_gpu" if _is_amd_vaapi() else "intel_gpu"
|
||||
elif "preset-rk" in args or "rkmpp" in args:
|
||||
return "rockchip"
|
||||
elif "v4l2m2m" in args or "rpi" in args:
|
||||
return "rpi"
|
||||
elif FFMPEG_HWACCEL_VULKAN in args or "vulkan" in args:
|
||||
# vulkan is vendor neutral, attribute it to whichever GPU is present
|
||||
return _present_gpu()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class HardwareStats:
|
||||
"""Catalogs and polls all hardware relevant to the config."""
|
||||
|
||||
def __init__(self, config: FrigateConfig) -> None:
|
||||
self.config = config
|
||||
# hardware name -> bound poll function; the catalog of what to monitor
|
||||
self._monitored: dict[str, Callable[[], HardwarePollResult]] = {}
|
||||
self._errors: dict[str, float] = {}
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=8, thread_name_prefix="hw_stats"
|
||||
)
|
||||
self._config_subscriber = CameraConfigUpdateSubscriber(
|
||||
config,
|
||||
config.cameras,
|
||||
[
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
CameraConfigUpdateEnum.ffmpeg,
|
||||
CameraConfigUpdateEnum.audio_transcription,
|
||||
CameraConfigUpdateEnum.semantic_search,
|
||||
CameraConfigUpdateEnum.face_recognition,
|
||||
CameraConfigUpdateEnum.lpr,
|
||||
],
|
||||
)
|
||||
self.update_config()
|
||||
|
||||
def update_config(self) -> None:
|
||||
"""Recalculate all hardware that needs to be monitored from the config."""
|
||||
names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments()
|
||||
monitored: dict[str, Callable[[], HardwarePollResult]] = {}
|
||||
|
||||
for name in sorted(names):
|
||||
if name == "intel_gpu" and not self.config.telemetry.stats.intel_gpu_stats:
|
||||
continue
|
||||
|
||||
if name == "amd_gpu" and not self.config.telemetry.stats.amd_gpu_stats:
|
||||
continue
|
||||
|
||||
monitored[name] = partial(POLLERS[name], self.config)
|
||||
|
||||
self._monitored = monitored
|
||||
logger.debug("Monitoring hardware for stats: %s", list(monitored))
|
||||
|
||||
def update_stats(self, all_stats: dict[str, Any]) -> None:
|
||||
"""Poll the monitored hardware concurrently and fill usage stats."""
|
||||
if self._config_subscriber.check_for_updates():
|
||||
self.update_config()
|
||||
|
||||
now = time.monotonic()
|
||||
hardware_futures: dict[Future[HardwarePollResult], str] = {}
|
||||
|
||||
for name, poll in self._monitored.items():
|
||||
last_error = self._errors.get(name)
|
||||
|
||||
if last_error is not None:
|
||||
if now - last_error < HARDWARE_ERROR_COOLDOWN_SECONDS:
|
||||
continue
|
||||
|
||||
self._errors.pop(name, None)
|
||||
|
||||
hardware_futures[self._executor.submit(poll)] = name
|
||||
|
||||
cpu_future = self._executor.submit(get_cpu_stats)
|
||||
futures: list[Future[Any]] = [*hardware_futures, cpu_future]
|
||||
done, _ = wait(futures, timeout=POLL_TIMEOUT_SECONDS)
|
||||
|
||||
gpu_usages: dict[str, dict[str, Any]] = {}
|
||||
npu_usages: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for future, name in hardware_futures.items():
|
||||
if future not in done:
|
||||
logger.warning("Timed out collecting %s stats", name)
|
||||
self._errors[name] = time.monotonic()
|
||||
continue
|
||||
|
||||
try:
|
||||
result = future.result()
|
||||
except Exception:
|
||||
logger.exception("Failed to collect %s stats", name)
|
||||
self._errors[name] = time.monotonic()
|
||||
continue
|
||||
|
||||
gpu_usages.update(result.gpu)
|
||||
npu_usages.update(result.npu)
|
||||
|
||||
if not result.ok:
|
||||
self._errors[name] = time.monotonic()
|
||||
|
||||
if gpu_usages:
|
||||
all_stats["gpu_usages"] = gpu_usages
|
||||
|
||||
if npu_usages:
|
||||
all_stats["npu_usages"] = npu_usages
|
||||
|
||||
if cpu_future in done:
|
||||
try:
|
||||
cpu_stats = cpu_future.result()
|
||||
except Exception:
|
||||
logger.exception("Failed to collect cpu stats")
|
||||
else:
|
||||
if cpu_stats:
|
||||
all_stats["cpu_usages"] = cpu_stats
|
||||
|
||||
def stop(self) -> None:
|
||||
self._config_subscriber.stop()
|
||||
self._executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
def _scan_ffmpeg(self) -> set[str]:
|
||||
"""Hardware used by camera hwaccel args (presets or raw ffmpeg args)."""
|
||||
hwaccel_args: list[str] = []
|
||||
|
||||
for camera in self.config.cameras.values():
|
||||
args = camera.ffmpeg.hwaccel_args
|
||||
|
||||
if isinstance(args, list):
|
||||
args = " ".join(args)
|
||||
|
||||
if args and args not in hwaccel_args:
|
||||
hwaccel_args.append(args)
|
||||
|
||||
for stream_input in camera.ffmpeg.inputs:
|
||||
args = stream_input.hwaccel_args
|
||||
|
||||
if isinstance(args, list):
|
||||
args = " ".join(args)
|
||||
|
||||
if args and args not in hwaccel_args:
|
||||
hwaccel_args.append(args)
|
||||
|
||||
names: set[str] = set()
|
||||
|
||||
for args in hwaccel_args:
|
||||
name = _hwaccel_hardware(args)
|
||||
|
||||
if name is not None:
|
||||
names.add(name)
|
||||
|
||||
return names
|
||||
|
||||
def _scan_detectors(self) -> set[str]:
|
||||
"""Hardware used by the configured detection models."""
|
||||
names: set[str] = set()
|
||||
|
||||
for model in self.config.models:
|
||||
for spec in self.config.devices_for_model(model):
|
||||
if spec.detector == "rknn":
|
||||
names.add("rockchip")
|
||||
elif spec.detector == "axengine":
|
||||
names.add("axengine")
|
||||
elif spec.detector == "tensorrt":
|
||||
names.add("jetson")
|
||||
elif spec.detector == "openvino":
|
||||
if spec.device == "NPU":
|
||||
names.add("intel_npu")
|
||||
elif spec.device is None or spec.device.startswith("GPU"):
|
||||
names.add("intel_gpu")
|
||||
elif spec.detector == "onnx":
|
||||
gpu = _present_gpu()
|
||||
|
||||
if gpu is not None:
|
||||
names.add(gpu)
|
||||
|
||||
return names
|
||||
|
||||
def _scan_enrichments(self) -> set[str]:
|
||||
"""Hardware used by enabled enrichments, resolved to the present GPU."""
|
||||
config = self.config
|
||||
names: set[str] = set()
|
||||
gpu = _present_gpu()
|
||||
|
||||
if gpu is not None:
|
||||
semantic = config.semantic_search
|
||||
|
||||
if (
|
||||
semantic.enabled
|
||||
# GenAI providers run remotely and use no local hardware
|
||||
and (
|
||||
semantic.model is None
|
||||
or isinstance(semantic.model, SemanticSearchModelEnum)
|
||||
)
|
||||
and (
|
||||
semantic.device
|
||||
or ("GPU" if semantic.model_size == "large" else "CPU")
|
||||
)
|
||||
!= "CPU"
|
||||
):
|
||||
names.add(gpu)
|
||||
|
||||
if (
|
||||
config.face_recognition.enabled
|
||||
and (config.face_recognition.device or "GPU") != "CPU"
|
||||
):
|
||||
names.add(gpu)
|
||||
|
||||
if config.lpr.enabled and (config.lpr.device or "AUTO") != "CPU":
|
||||
names.add(gpu)
|
||||
|
||||
# audio transcription runs on CUDA only, and the device is global only
|
||||
transcription = config.audio_transcription
|
||||
transcription_enabled = transcription.enabled or any(
|
||||
camera.audio_transcription.enabled for camera in config.cameras.values()
|
||||
)
|
||||
|
||||
if (
|
||||
transcription_enabled
|
||||
and transcription.device == "GPU"
|
||||
and "onnx:nvidia" in _present_hardware()
|
||||
and _gpu_device_nodes_exist("nvidia")
|
||||
):
|
||||
names.add("nvidia")
|
||||
|
||||
return names
|
||||
|
||||
|
||||
def read_temperature(path: str) -> float | None:
|
||||
"""Read a sysfs temperature file, converting millidegrees to degrees.
|
||||
|
||||
Returns None when the file does not exist.
|
||||
"""
|
||||
if os.path.isfile(path):
|
||||
with open(path) as f:
|
||||
line = f.readline().strip()
|
||||
return int(line) / 1000
|
||||
return None
|
||||
|
||||
|
||||
def get_hardware_temperatures(detector_type: str) -> list[float | None]:
|
||||
"""Per unit temperatures for a detector type, in stable device order."""
|
||||
if detector_type == "edgetpu":
|
||||
# PCIe Corals expose a temperature through the apex driver
|
||||
base = "/sys/class/apex/"
|
||||
|
||||
if os.path.isdir(base):
|
||||
return [
|
||||
read_temperature(os.path.join(base, apex, "temp"))
|
||||
for apex in sorted(os.listdir(base))
|
||||
]
|
||||
elif detector_type == "hailo8l":
|
||||
hailo_temps = get_hailo_temps()
|
||||
return [hailo_temps[name] for name in sorted(hailo_temps.keys())]
|
||||
|
||||
return []
|
||||
+308
-147
@@ -1,12 +1,13 @@
|
||||
"""Utilities for stats."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from json import JSONDecodeError
|
||||
from multiprocessing.managers import DictProxy
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from requests.exceptions import RequestException
|
||||
@@ -15,20 +16,29 @@ from frigate.config import FrigateConfig
|
||||
from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR
|
||||
from frigate.data_processing.types import DataProcessorMetrics
|
||||
from frigate.object_detection.base import ObjectDetectProcess
|
||||
from frigate.stats.hardware import HardwareStats, get_hardware_temperatures
|
||||
from frigate.types import StatsTrackingTypes
|
||||
from frigate.util.services import (
|
||||
calculate_shm_requirements,
|
||||
get_amd_gpu_stats,
|
||||
get_axcl_npu_stats,
|
||||
get_bandwidth_stats,
|
||||
get_cpu_stats,
|
||||
get_fs_type,
|
||||
get_hailo_temps,
|
||||
get_intel_gpu_stats,
|
||||
get_jetson_stats,
|
||||
get_nvidia_gpu_stats,
|
||||
get_openvino_npu_stats,
|
||||
get_rockchip_gpu_stats,
|
||||
get_rockchip_npu_stats,
|
||||
is_vaapi_amd_driver,
|
||||
)
|
||||
from frigate.version import VERSION
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.storage import StorageMaintainer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HWACCEL_ERROR_COOLDOWN_SECONDS = 3600
|
||||
|
||||
|
||||
def get_latest_version(config: FrigateConfig) -> str:
|
||||
if not config.telemetry.version_check:
|
||||
@@ -49,49 +59,12 @@ def get_latest_version(config: FrigateConfig) -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _version_tuple(value: str) -> tuple[int, int, int] | None:
|
||||
match = re.match(r"^(\d+)\.(\d+)\.(\d+)", value or "")
|
||||
|
||||
if match is None:
|
||||
return None
|
||||
|
||||
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||||
|
||||
|
||||
# a build's git hash suffix is not a prerelease marker
|
||||
PRERELEASE_PATTERN = re.compile(r"^\d+\.\d+\.\d+-(?:beta|rc)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _is_prerelease(value: str) -> bool:
|
||||
return PRERELEASE_PATTERN.match(value or "") is not None
|
||||
|
||||
|
||||
def is_newer_version(current: str, latest: str) -> bool:
|
||||
"""Whether latest is a release newer than the running version.
|
||||
|
||||
Build suffixes after the third number are ignored, and a value that does
|
||||
not parse (disabled, unknown) is never newer. A prerelease is behind the
|
||||
final release of the same number, so 0.19.0-beta2 is behind 0.19.0.
|
||||
"""
|
||||
current_tuple = _version_tuple(current)
|
||||
latest_tuple = _version_tuple(latest)
|
||||
|
||||
if current_tuple is None or latest_tuple is None:
|
||||
return False
|
||||
|
||||
if current_tuple == latest_tuple:
|
||||
return _is_prerelease(current) and not _is_prerelease(latest)
|
||||
|
||||
return latest_tuple > current_tuple
|
||||
|
||||
|
||||
def stats_init(
|
||||
config: FrigateConfig,
|
||||
camera_metrics: DictProxy,
|
||||
embeddings_metrics: DataProcessorMetrics,
|
||||
detectors: dict[str, ObjectDetectProcess],
|
||||
processes: dict[str, int],
|
||||
storage_maintainer: "StorageMaintainer | None" = None,
|
||||
) -> StatsTrackingTypes:
|
||||
stats_tracking: StatsTrackingTypes = {
|
||||
"camera_metrics": camera_metrics,
|
||||
@@ -101,11 +74,68 @@ def stats_init(
|
||||
"latest_frigate_version": get_latest_version(config),
|
||||
"last_updated": int(time.time()),
|
||||
"processes": processes,
|
||||
"storage_maintainer": storage_maintainer,
|
||||
}
|
||||
return stats_tracking
|
||||
|
||||
|
||||
def read_temperature(path: str) -> float | None:
|
||||
if os.path.isfile(path):
|
||||
with open(path) as f:
|
||||
line = f.readline().strip()
|
||||
return int(line) / 1000
|
||||
return None
|
||||
|
||||
|
||||
def get_temperatures() -> dict[str, float]:
|
||||
temps = {}
|
||||
|
||||
# Get temperatures for all attached Corals
|
||||
base = "/sys/class/apex/"
|
||||
if os.path.isdir(base):
|
||||
for apex in os.listdir(base):
|
||||
temp = read_temperature(os.path.join(base, apex, "temp"))
|
||||
if temp is not None:
|
||||
temps[apex] = temp
|
||||
|
||||
# Get temperatures for Hailo devices
|
||||
temps.update(get_hailo_temps())
|
||||
|
||||
return temps
|
||||
|
||||
|
||||
def get_detector_temperature(
|
||||
detector_type: str,
|
||||
detector_index_by_type: dict[str, int],
|
||||
) -> float | None:
|
||||
"""Get temperature for a specific detector based on its type."""
|
||||
if detector_type == "edgetpu":
|
||||
# Get temperatures for all attached Corals
|
||||
base = "/sys/class/apex/"
|
||||
if os.path.isdir(base):
|
||||
apex_devices = sorted(os.listdir(base))
|
||||
index = detector_index_by_type.get("edgetpu", 0)
|
||||
if index < len(apex_devices):
|
||||
apex_name = apex_devices[index]
|
||||
temp = read_temperature(os.path.join(base, apex_name, "temp"))
|
||||
if temp is not None:
|
||||
return temp
|
||||
elif detector_type == "hailo8l":
|
||||
# Get temperatures for Hailo devices
|
||||
hailo_temps = get_hailo_temps()
|
||||
if hailo_temps:
|
||||
hailo_device_names = sorted(hailo_temps.keys())
|
||||
index = detector_index_by_type.get("hailo8l", 0)
|
||||
if index < len(hailo_device_names):
|
||||
device_name = hailo_device_names[index]
|
||||
return hailo_temps[device_name]
|
||||
elif detector_type == "rknn":
|
||||
# Rockchip temperatures are handled by the GPU / NPU stats
|
||||
# as there are not detector specific temperatures
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_detector_stats(
|
||||
stats_tracking: StatsTrackingTypes,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
@@ -131,112 +161,190 @@ def get_detector_stats(
|
||||
"pid": pid,
|
||||
}
|
||||
|
||||
temps = get_hardware_temperatures(detector_type)
|
||||
temp = get_detector_temperature(detector_type, {detector_type: current_index})
|
||||
|
||||
if current_index < len(temps) and temps[current_index] is not None:
|
||||
detector_stat["temperature"] = round(temps[current_index], 1)
|
||||
if temp is not None:
|
||||
detector_stat["temperature"] = round(temp, 1)
|
||||
|
||||
detector_stats[name] = detector_stat
|
||||
|
||||
return detector_stats
|
||||
|
||||
|
||||
def embeddings_stats(
|
||||
config: FrigateConfig, embeddings_metrics: DataProcessorMetrics | None
|
||||
) -> dict[str, Any]:
|
||||
"""Enrichment speed metrics plus the device each enrichment loaded on."""
|
||||
stats: dict[str, Any] = {}
|
||||
def get_processing_stats(
|
||||
config: FrigateConfig,
|
||||
stats: dict[str, str],
|
||||
hwaccel_errors: dict[str, float],
|
||||
) -> None:
|
||||
"""Get stats for cpu / gpu."""
|
||||
|
||||
if not embeddings_metrics:
|
||||
return stats
|
||||
async def run_tasks() -> None:
|
||||
stats_tasks = [
|
||||
asyncio.create_task(set_gpu_stats(config, stats, hwaccel_errors)),
|
||||
asyncio.create_task(set_cpu_stats(stats)),
|
||||
asyncio.create_task(set_npu_usages(config, stats)),
|
||||
]
|
||||
|
||||
# Add metrics based on what's enabled
|
||||
if config.semantic_search.enabled:
|
||||
stats.update(
|
||||
{
|
||||
"image_embedding_speed": round(
|
||||
embeddings_metrics.image_embeddings_speed.value * 1000, 2
|
||||
),
|
||||
"image_embedding": round(
|
||||
embeddings_metrics.image_embeddings_eps.value, 2
|
||||
),
|
||||
"text_embedding_speed": round(
|
||||
embeddings_metrics.text_embeddings_speed.value * 1000, 2
|
||||
),
|
||||
"text_embedding": round(
|
||||
embeddings_metrics.text_embeddings_eps.value, 2
|
||||
),
|
||||
}
|
||||
)
|
||||
if config.telemetry.stats.network_bandwidth:
|
||||
stats_tasks.append(asyncio.create_task(set_bandwidth_stats(config, stats)))
|
||||
|
||||
if config.face_recognition.enabled:
|
||||
stats["face_recognition_speed"] = round(
|
||||
embeddings_metrics.face_rec_speed.value * 1000, 2
|
||||
)
|
||||
stats["face_recognition"] = round(embeddings_metrics.face_rec_fps.value, 2)
|
||||
await asyncio.wait(stats_tasks)
|
||||
|
||||
if config.lpr.enabled:
|
||||
stats["plate_recognition_speed"] = round(
|
||||
embeddings_metrics.alpr_speed.value * 1000, 2
|
||||
)
|
||||
stats["plate_recognition"] = round(embeddings_metrics.alpr_pps.value, 2)
|
||||
|
||||
if embeddings_metrics.yolov9_lpr_pps.value > 0.0:
|
||||
stats["yolov9_plate_detection_speed"] = round(
|
||||
embeddings_metrics.yolov9_lpr_speed.value * 1000, 2
|
||||
)
|
||||
stats["yolov9_plate_detection"] = round(
|
||||
embeddings_metrics.yolov9_lpr_pps.value, 2
|
||||
)
|
||||
|
||||
if embeddings_metrics.review_desc_speed.value > 0.0:
|
||||
stats["review_description_speed"] = round(
|
||||
embeddings_metrics.review_desc_speed.value * 1000, 2
|
||||
)
|
||||
stats["review_description_events_per_second"] = round(
|
||||
embeddings_metrics.review_desc_dps.value, 2
|
||||
)
|
||||
|
||||
if embeddings_metrics.object_desc_speed.value > 0.0:
|
||||
stats["object_description_speed"] = round(
|
||||
embeddings_metrics.object_desc_speed.value * 1000, 2
|
||||
)
|
||||
stats["object_description_events_per_second"] = round(
|
||||
embeddings_metrics.object_desc_dps.value, 2
|
||||
)
|
||||
|
||||
for key in embeddings_metrics.classification_speeds.keys():
|
||||
stats[f"{key}_classification_speed"] = round(
|
||||
embeddings_metrics.classification_speeds[key].value * 1000, 2
|
||||
)
|
||||
stats[f"{key}_classification_events_per_second"] = round(
|
||||
embeddings_metrics.classification_cps[key].value, 2
|
||||
)
|
||||
|
||||
devices = dict(embeddings_metrics.runtime_devices)
|
||||
|
||||
if devices:
|
||||
stats["devices"] = devices
|
||||
|
||||
return stats
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(run_tasks())
|
||||
loop.close()
|
||||
|
||||
|
||||
def skipped_percent(skipped_fps: float, camera_fps: float, enabled: bool) -> float:
|
||||
"""Percent of a camera's frames dropped before detection.
|
||||
async def set_cpu_stats(all_stats: dict[str, Any]) -> None:
|
||||
"""Set cpu usage from top."""
|
||||
cpu_stats = get_cpu_stats()
|
||||
|
||||
camera_fps counts the dropped frames too. A disabled camera keeps its last
|
||||
readings, so it reports zero.
|
||||
"""
|
||||
if not enabled or camera_fps <= 0:
|
||||
return 0.0
|
||||
if cpu_stats:
|
||||
all_stats["cpu_usages"] = cpu_stats
|
||||
|
||||
return round(skipped_fps / camera_fps * 100, 1)
|
||||
|
||||
async def set_bandwidth_stats(config: FrigateConfig, all_stats: dict[str, Any]) -> None:
|
||||
"""Set bandwidth from nethogs."""
|
||||
bandwidth_stats = get_bandwidth_stats(config)
|
||||
|
||||
if bandwidth_stats:
|
||||
all_stats["bandwidth_usages"] = bandwidth_stats
|
||||
|
||||
|
||||
async def set_gpu_stats(
|
||||
config: FrigateConfig,
|
||||
all_stats: dict[str, Any],
|
||||
hwaccel_errors: dict[str, float],
|
||||
) -> None:
|
||||
"""Parse GPUs from hwaccel args and use for stats."""
|
||||
hwaccel_args = []
|
||||
|
||||
for camera in config.cameras.values():
|
||||
args = camera.ffmpeg.hwaccel_args
|
||||
|
||||
if isinstance(args, list):
|
||||
args = " ".join(args)
|
||||
|
||||
if args and args not in hwaccel_args:
|
||||
hwaccel_args.append(args)
|
||||
|
||||
for stream_input in camera.ffmpeg.inputs:
|
||||
args = stream_input.hwaccel_args
|
||||
|
||||
if isinstance(args, list):
|
||||
args = " ".join(args)
|
||||
|
||||
if args and args not in hwaccel_args:
|
||||
hwaccel_args.append(args)
|
||||
|
||||
stats: dict[str, dict] = {}
|
||||
intel_gpu_collected = False
|
||||
now = time.monotonic()
|
||||
|
||||
for args in hwaccel_args:
|
||||
last_error = hwaccel_errors.get(args)
|
||||
if last_error is not None:
|
||||
if now - last_error < HWACCEL_ERROR_COOLDOWN_SECONDS:
|
||||
continue
|
||||
hwaccel_errors.pop(args, None)
|
||||
|
||||
if "cuvid" in args or "nvidia" in args:
|
||||
# nvidia GPU
|
||||
nvidia_usage = get_nvidia_gpu_stats()
|
||||
|
||||
if nvidia_usage:
|
||||
for i in range(len(nvidia_usage)):
|
||||
stats[nvidia_usage[i]["name"]] = {
|
||||
"vendor": "nvidia",
|
||||
"gpu": str(round(float(nvidia_usage[i]["gpu"]), 2)) + "%",
|
||||
"mem": str(round(float(nvidia_usage[i]["mem"]), 2)) + "%",
|
||||
"enc": str(round(float(nvidia_usage[i]["enc"]), 2)) + "%",
|
||||
"dec": str(round(float(nvidia_usage[i]["dec"]), 2)) + "%",
|
||||
"temp": str(nvidia_usage[i]["temp"]),
|
||||
}
|
||||
|
||||
else:
|
||||
stats["nvidia-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
|
||||
hwaccel_errors[args] = time.monotonic()
|
||||
elif "nvmpi" in args or "jetson" in args:
|
||||
# nvidia Jetson
|
||||
jetson_usage = get_jetson_stats()
|
||||
|
||||
if jetson_usage:
|
||||
stats["jetson-gpu"] = {"vendor": "nvidia", **jetson_usage}
|
||||
else:
|
||||
stats["jetson-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
|
||||
hwaccel_errors[args] = time.monotonic()
|
||||
elif "qsv" in args or ("vaapi" in args and not is_vaapi_amd_driver()):
|
||||
if not config.telemetry.stats.intel_gpu_stats:
|
||||
continue
|
||||
|
||||
if not intel_gpu_collected:
|
||||
# intel GPU (QSV or VAAPI both use the same physical GPU)
|
||||
intel_gpu_collected = True
|
||||
intel_usage = get_intel_gpu_stats(
|
||||
config.telemetry.stats.intel_gpu_device
|
||||
)
|
||||
|
||||
if intel_usage:
|
||||
for entry in intel_usage.values():
|
||||
name = entry.pop("name")
|
||||
stats[name] = entry
|
||||
else:
|
||||
stats["intel-gpu"] = {"vendor": "intel", "gpu": "", "mem": ""}
|
||||
hwaccel_errors[args] = time.monotonic()
|
||||
elif "vaapi" in args:
|
||||
if not config.telemetry.stats.amd_gpu_stats:
|
||||
continue
|
||||
|
||||
# AMD VAAPI GPU
|
||||
amd_usage = get_amd_gpu_stats()
|
||||
|
||||
if amd_usage:
|
||||
stats["amd-vaapi"] = {"vendor": "amd", **amd_usage}
|
||||
else:
|
||||
stats["amd-vaapi"] = {"vendor": "amd", "gpu": "", "mem": ""}
|
||||
hwaccel_errors[args] = time.monotonic()
|
||||
elif "preset-rk" in args:
|
||||
rga_usage = get_rockchip_gpu_stats()
|
||||
|
||||
if rga_usage:
|
||||
stats["rockchip"] = {"vendor": "rockchip", **rga_usage}
|
||||
elif "v4l2m2m" in args or "rpi" in args:
|
||||
# RPi v4l2m2m is currently not able to get usage stats
|
||||
stats["rpi-v4l2m2m"] = {"vendor": "rpi", "gpu": "", "mem": ""}
|
||||
|
||||
if stats:
|
||||
all_stats["gpu_usages"] = stats
|
||||
|
||||
|
||||
async def set_npu_usages(config: FrigateConfig, all_stats: dict[str, Any]) -> None:
|
||||
stats: dict[str, dict] = {}
|
||||
|
||||
for model in config.models:
|
||||
for device in config.devices_for_model(model):
|
||||
if device.detector == "rknn":
|
||||
# Rockchip NPU usage
|
||||
rk_usage = get_rockchip_npu_stats()
|
||||
stats["rockchip"] = rk_usage
|
||||
elif device.detector == "openvino" and device.device == "NPU":
|
||||
# OpenVINO NPU usage
|
||||
ov_usage = get_openvino_npu_stats()
|
||||
stats["openvino"] = ov_usage
|
||||
elif device.detector == "axengine":
|
||||
# AXERA NPU usage
|
||||
axcl_usage = get_axcl_npu_stats()
|
||||
stats["axengine"] = axcl_usage
|
||||
|
||||
if stats:
|
||||
all_stats["npu_usages"] = stats
|
||||
|
||||
|
||||
def stats_snapshot(
|
||||
config: FrigateConfig,
|
||||
stats_tracking: StatsTrackingTypes,
|
||||
hardware_stats: HardwareStats,
|
||||
hwaccel_errors: dict[str, float],
|
||||
) -> dict[str, Any]:
|
||||
"""Get a snapshot of the current stats that are being tracked."""
|
||||
camera_metrics = stats_tracking["camera_metrics"]
|
||||
@@ -290,11 +398,6 @@ def stats_snapshot(
|
||||
"camera_fps": round(camera_stats.camera_fps.value, 2),
|
||||
"process_fps": round(camera_stats.process_fps.value, 2),
|
||||
"skipped_fps": round(camera_stats.skipped_fps.value, 2),
|
||||
"skipped_pct": skipped_percent(
|
||||
camera_stats.skipped_fps.value,
|
||||
current_fps,
|
||||
config.cameras[name].enabled,
|
||||
),
|
||||
"detection_fps": round(camera_stats.detection_fps.value, 2),
|
||||
"detection_enabled": config.cameras[name].detect.enabled,
|
||||
"pid": pid,
|
||||
@@ -311,19 +414,80 @@ def stats_snapshot(
|
||||
stats["skipped_fps"] = round(total_skipped_fps, 2)
|
||||
stats["detection_fps"] = round(total_detection_fps, 2)
|
||||
|
||||
stats["embeddings"] = embeddings_stats(
|
||||
config, stats_tracking.get("embeddings_metrics")
|
||||
)
|
||||
stats["embeddings"] = {}
|
||||
|
||||
hardware_stats.update_stats(stats)
|
||||
# Get metrics if available
|
||||
embeddings_metrics = stats_tracking.get("embeddings_metrics")
|
||||
|
||||
if config.telemetry.stats.network_bandwidth:
|
||||
bandwidth_stats = get_bandwidth_stats(config)
|
||||
if embeddings_metrics:
|
||||
# Add metrics based on what's enabled
|
||||
if config.semantic_search.enabled:
|
||||
stats["embeddings"].update(
|
||||
{
|
||||
"image_embedding_speed": round(
|
||||
embeddings_metrics.image_embeddings_speed.value * 1000, 2
|
||||
),
|
||||
"image_embedding": round(
|
||||
embeddings_metrics.image_embeddings_eps.value, 2
|
||||
),
|
||||
"text_embedding_speed": round(
|
||||
embeddings_metrics.text_embeddings_speed.value * 1000, 2
|
||||
),
|
||||
"text_embedding": round(
|
||||
embeddings_metrics.text_embeddings_eps.value, 2
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if bandwidth_stats:
|
||||
stats["bandwidth_usages"] = bandwidth_stats
|
||||
if config.face_recognition.enabled:
|
||||
stats["embeddings"]["face_recognition_speed"] = round(
|
||||
embeddings_metrics.face_rec_speed.value * 1000, 2
|
||||
)
|
||||
stats["embeddings"]["face_recognition"] = round(
|
||||
embeddings_metrics.face_rec_fps.value, 2
|
||||
)
|
||||
|
||||
storage_maintainer = stats_tracking.get("storage_maintainer")
|
||||
if config.lpr.enabled:
|
||||
stats["embeddings"]["plate_recognition_speed"] = round(
|
||||
embeddings_metrics.alpr_speed.value * 1000, 2
|
||||
)
|
||||
stats["embeddings"]["plate_recognition"] = round(
|
||||
embeddings_metrics.alpr_pps.value, 2
|
||||
)
|
||||
|
||||
if embeddings_metrics.yolov9_lpr_pps.value > 0.0:
|
||||
stats["embeddings"]["yolov9_plate_detection_speed"] = round(
|
||||
embeddings_metrics.yolov9_lpr_speed.value * 1000, 2
|
||||
)
|
||||
stats["embeddings"]["yolov9_plate_detection"] = round(
|
||||
embeddings_metrics.yolov9_lpr_pps.value, 2
|
||||
)
|
||||
|
||||
if embeddings_metrics.review_desc_speed.value > 0.0:
|
||||
stats["embeddings"]["review_description_speed"] = round(
|
||||
embeddings_metrics.review_desc_speed.value * 1000, 2
|
||||
)
|
||||
stats["embeddings"]["review_description_events_per_second"] = round(
|
||||
embeddings_metrics.review_desc_dps.value, 2
|
||||
)
|
||||
|
||||
if embeddings_metrics.object_desc_speed.value > 0.0:
|
||||
stats["embeddings"]["object_description_speed"] = round(
|
||||
embeddings_metrics.object_desc_speed.value * 1000, 2
|
||||
)
|
||||
stats["embeddings"]["object_description_events_per_second"] = round(
|
||||
embeddings_metrics.object_desc_dps.value, 2
|
||||
)
|
||||
|
||||
for key in embeddings_metrics.classification_speeds.keys():
|
||||
stats["embeddings"][f"{key}_classification_speed"] = round(
|
||||
embeddings_metrics.classification_speeds[key].value * 1000, 2
|
||||
)
|
||||
stats["embeddings"][f"{key}_classification_events_per_second"] = round(
|
||||
embeddings_metrics.classification_cps[key].value, 2
|
||||
)
|
||||
|
||||
get_processing_stats(config, stats, hwaccel_errors)
|
||||
|
||||
stats["service"] = {
|
||||
"uptime": (int(time.time()) - stats_tracking["started"]),
|
||||
@@ -331,9 +495,6 @@ def stats_snapshot(
|
||||
"latest_version": stats_tracking["latest_frigate_version"],
|
||||
"storage": {},
|
||||
"last_updated": int(time.time()),
|
||||
"retention_unmet": bool(
|
||||
storage_maintainer and storage_maintainer.retention_unmet
|
||||
),
|
||||
}
|
||||
|
||||
for path in [RECORD_DIR, CLIPS_DIR, CACHE_DIR]:
|
||||
|
||||
+17
-28
@@ -39,9 +39,6 @@ class StorageMaintainer(threading.Thread):
|
||||
self.config = config
|
||||
self.stop_event = stop_event
|
||||
self.camera_storage_stats: dict[str, dict] = {}
|
||||
|
||||
# read by stats; true while maintenance has to delete retained recordings
|
||||
self.retention_unmet = False
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config,
|
||||
self.config.cameras,
|
||||
@@ -345,11 +342,8 @@ class StorageMaintainer(threading.Thread):
|
||||
except FileNotFoundError:
|
||||
# this file was not found so we must assume no space was cleaned up
|
||||
pass
|
||||
|
||||
self.retention_unmet = True
|
||||
else:
|
||||
logger.info(f"Cleaned up {deleted_segments_size:.2f} MB of recordings")
|
||||
self.retention_unmet = False
|
||||
|
||||
logger.debug(f"Expiring {len(deleted_recordings)} recordings")
|
||||
# delete up to 100,000 at a time
|
||||
@@ -412,28 +406,23 @@ class StorageMaintainer(threading.Thread):
|
||||
|
||||
self.calculate_camera_bandwidth()
|
||||
while not self.stop_event.wait(300):
|
||||
self._maintain_once()
|
||||
updated_topics = self.config_subscriber.check_for_updates()
|
||||
|
||||
for camera in updated_topics.get(CameraConfigUpdateEnum.record.name, []):
|
||||
if camera in self.camera_storage_stats:
|
||||
self.camera_storage_stats[camera]["needs_refresh"] = True
|
||||
|
||||
if not self.camera_storage_stats or True in [
|
||||
r["needs_refresh"] for r in self.camera_storage_stats.values()
|
||||
]:
|
||||
self.calculate_camera_bandwidth()
|
||||
logger.debug(f"Default camera bandwidths: {self.camera_storage_stats}.")
|
||||
|
||||
if self.check_storage_needs_cleanup():
|
||||
logger.info(
|
||||
"Less than 1 hour of recording space left, running storage maintenance..."
|
||||
)
|
||||
self.reduce_storage_consumption()
|
||||
|
||||
self.config_subscriber.stop()
|
||||
logger.info("Exiting storage maintainer...")
|
||||
|
||||
def _maintain_once(self) -> None:
|
||||
updated_topics = self.config_subscriber.check_for_updates()
|
||||
|
||||
for camera in updated_topics.get(CameraConfigUpdateEnum.record.name, []):
|
||||
if camera in self.camera_storage_stats:
|
||||
self.camera_storage_stats[camera]["needs_refresh"] = True
|
||||
|
||||
if not self.camera_storage_stats or True in [
|
||||
r["needs_refresh"] for r in self.camera_storage_stats.values()
|
||||
]:
|
||||
self.calculate_camera_bandwidth()
|
||||
logger.debug(f"Default camera bandwidths: {self.camera_storage_stats}.")
|
||||
|
||||
if self.check_storage_needs_cleanup():
|
||||
logger.info(
|
||||
"Less than 1 hour of recording space left, running storage maintenance..."
|
||||
)
|
||||
self.reduce_storage_consumption()
|
||||
else:
|
||||
self.retention_unmet = False
|
||||
|
||||
@@ -144,9 +144,7 @@ class BaseTestHttp(unittest.TestCase):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def create_app(
|
||||
self, stats=None, event_metadata_publisher=None, notice_registry=None
|
||||
):
|
||||
def create_app(self, stats=None, event_metadata_publisher=None):
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
|
||||
app = create_fastapi_app(
|
||||
@@ -161,7 +159,6 @@ class BaseTestHttp(unittest.TestCase):
|
||||
None,
|
||||
DebugReplayManager(),
|
||||
enforce_default_admin=False,
|
||||
notice_registry=notice_registry,
|
||||
)
|
||||
|
||||
# Default test mocks for authentication
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
"""Tests for authentication endpoints."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import ANY, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.api.auth import (
|
||||
FAILED_LOGIN_BURST_GAP_S,
|
||||
MAX_NOTICE_USERNAME,
|
||||
MAX_OPEN_BURSTS,
|
||||
FailedLoginTracker,
|
||||
hash_password,
|
||||
)
|
||||
from frigate.api.auth import hash_password
|
||||
from frigate.const import JWT_SECRET_ENV_VAR
|
||||
from frigate.models import User
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
@@ -50,116 +43,3 @@ class TestHttpAuth(BaseTestHttp):
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert any("Login failed" in m and "admin" in m for m in logs.output)
|
||||
|
||||
|
||||
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
|
||||
class TestFailedLoginNotices(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([User])
|
||||
self.app = super().create_app()
|
||||
User.insert(
|
||||
username="admin",
|
||||
password_hash=hash_password("correct-horse-battery", iterations=1000),
|
||||
role="admin",
|
||||
notification_tokens=[],
|
||||
).execute()
|
||||
tracker_patch = patch("frigate.api.auth.failed_logins")
|
||||
self.failed_logins = tracker_patch.start()
|
||||
self.addCleanup(tracker_patch.stop)
|
||||
|
||||
def tearDown(self):
|
||||
User.delete().execute()
|
||||
super().tearDown()
|
||||
|
||||
def _login(self, user: str, password: str) -> int:
|
||||
with AuthTestClient(self.app) as client:
|
||||
response = client.post("/login", json={"user": user, "password": password})
|
||||
|
||||
return response.status_code
|
||||
|
||||
def test_unknown_user_is_recorded(self):
|
||||
self.assertEqual(self._login("ghost", "irrelevant"), 401)
|
||||
self.failed_logins.record.assert_called_once_with("ghost", ANY)
|
||||
|
||||
def test_bad_password_is_recorded_as_known(self):
|
||||
self.assertEqual(self._login("admin", "wrong"), 401)
|
||||
self.failed_logins.record.assert_called_once_with("admin", ANY, known=True)
|
||||
|
||||
def test_success_is_not_recorded(self):
|
||||
self.assertEqual(self._login("admin", "correct-horse-battery"), 200)
|
||||
self.failed_logins.record.assert_not_called()
|
||||
|
||||
|
||||
class TestFailedLoginTracker(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tracker = FailedLoginTracker()
|
||||
raise_patch = patch("frigate.api.auth.raise_notice")
|
||||
self.raise_notice = raise_patch.start()
|
||||
self.addCleanup(raise_patch.stop)
|
||||
|
||||
def _scopes(self) -> list[str]:
|
||||
return [call.kwargs["scope"] for call in self.raise_notice.call_args_list]
|
||||
|
||||
def _fill(self, now: float) -> None:
|
||||
for index in range(MAX_OPEN_BURSTS):
|
||||
self.tracker.record(f"ghost{index}", now)
|
||||
|
||||
def test_attempts_inside_the_gap_share_a_burst(self):
|
||||
for now in (1000.0, 1010.0, 1299.0):
|
||||
self.tracker.record("admin", now)
|
||||
|
||||
self.raise_notice.assert_called_with(
|
||||
"failed_login", scope="admin:1000", params={"user": "admin"}
|
||||
)
|
||||
self.assertEqual(self._scopes(), ["admin:1000"] * 3)
|
||||
|
||||
def test_each_user_gets_their_own_burst(self):
|
||||
self.tracker.record("admin", 1000.0)
|
||||
self.tracker.record("ghost", 1010.0)
|
||||
self.tracker.record("admin", 1020.0)
|
||||
|
||||
self.assertEqual(self._scopes(), ["admin:1000", "ghost:1010", "admin:1000"])
|
||||
|
||||
def test_a_quiet_gap_opens_a_new_burst(self):
|
||||
self.tracker.record("admin", 1000.0)
|
||||
self.tracker.record("admin", 1000.0 + FAILED_LOGIN_BURST_GAP_S)
|
||||
|
||||
self.assertEqual(self._scopes(), ["admin:1000", "admin:1300"])
|
||||
|
||||
def test_the_gap_runs_from_the_latest_attempt(self):
|
||||
for now in (1000.0, 1200.0, 1450.0):
|
||||
self.tracker.record("admin", now)
|
||||
|
||||
self.assertEqual(self._scopes(), ["admin:1000"] * 3)
|
||||
|
||||
def test_quiet_users_are_forgotten(self):
|
||||
self.tracker.record("ghost", 1000.0)
|
||||
self.tracker.record("admin", 1000.0 + FAILED_LOGIN_BURST_GAP_S)
|
||||
|
||||
self.assertEqual(list(self.tracker._bursts), ["admin"])
|
||||
|
||||
def test_a_long_username_is_cut(self):
|
||||
self.tracker.record("x" * 500, 1000.0)
|
||||
|
||||
user = self.raise_notice.call_args.kwargs["params"]["user"]
|
||||
self.assertEqual(len(user), MAX_NOTICE_USERNAME)
|
||||
|
||||
def test_unknown_users_past_the_cap_get_no_notice(self):
|
||||
self._fill(1000.0)
|
||||
self.tracker.record("one-too-many", 1001.0)
|
||||
self.tracker.record("ghost0", 1002.0)
|
||||
|
||||
self.assertEqual(self.raise_notice.call_count, MAX_OPEN_BURSTS + 1)
|
||||
self.assertEqual(self._scopes()[-1], "ghost0:1000")
|
||||
|
||||
def test_known_users_skip_the_cap(self):
|
||||
self._fill(1000.0)
|
||||
self.tracker.record("admin", 1001.0, known=True)
|
||||
|
||||
self.assertEqual(self._scopes()[-1], "admin:1001")
|
||||
|
||||
def test_quiet_bursts_free_their_slots(self):
|
||||
self._fill(1000.0)
|
||||
self.tracker.record("late", 1000.0 + FAILED_LOGIN_BURST_GAP_S)
|
||||
|
||||
self.assertEqual(self._scopes()[-1], "late:1300")
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Tests for credential scrubbing in the ffprobe API."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpFfprobe(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([])
|
||||
self.app = self.create_app()
|
||||
|
||||
def client(self) -> TestClient:
|
||||
return AuthTestClient(self.app)
|
||||
|
||||
def test_failed_probe_scrubs_credentials_from_stderr(self):
|
||||
failed = SimpleNamespace(
|
||||
returncode=1,
|
||||
stdout=b"",
|
||||
stderr=(
|
||||
b"[tcp @ 0x1] Connection to tcp://10.0.0.1:554 failed\n"
|
||||
b"rtsp://admin:hunter2@10.0.0.1:554/video: Connection refused\n"
|
||||
),
|
||||
)
|
||||
|
||||
with patch("frigate.api.camera.ffprobe_stream", return_value=failed):
|
||||
with self.client() as client:
|
||||
response = client.get("/ffprobe", params={"paths": "camera:front_door"})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
lines = response.json()[0]["stderr"]
|
||||
self.assertEqual(len(lines), 2)
|
||||
self.assertNotIn("hunter2", " ".join(lines))
|
||||
self.assertEqual(lines[1], "rtsp://*:*@10.0.0.1:554/video: Connection refused")
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Unit tests for recordings/media API endpoints."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytz
|
||||
from fastapi import Request
|
||||
@@ -1839,27 +1838,6 @@ class TestHttpMedia(BaseTestHttp):
|
||||
recording["id"] for recording in response.json()
|
||||
] == expected_ids
|
||||
|
||||
def test_recordings_default_range_follows_the_request_clock(self):
|
||||
"""Omitted bounds resolve at request time, not at module import time."""
|
||||
uptime = timedelta(hours=2)
|
||||
recorded_at = (datetime.now() + uptime).timestamp()
|
||||
|
||||
class UptimeDatetime(datetime):
|
||||
"""Stands in for a process that booted `uptime` ago."""
|
||||
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return datetime.now(tz) + uptime
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
self._insert_recording("after_boot", recorded_at - 20, recorded_at - 10)
|
||||
|
||||
with patch("frigate.api.record.datetime", UptimeDatetime):
|
||||
response = client.get("/front_door/recordings")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert [recording["id"] for recording in response.json()] == ["after_boot"]
|
||||
|
||||
def test_vod_handles_all_range_relations(self):
|
||||
"""VOD clips every interval relation with positive playback duration."""
|
||||
with AuthTestClient(self.app) as client:
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
"""Tests for the notices API."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from frigate.models import Notice, NoticeStats
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
CONFIG_CHECK = "config:detect:fps-greater-than-five:camera.garage"
|
||||
|
||||
|
||||
class TestHttpNotices(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([Notice, NoticeStats])
|
||||
self.registry = NoticeRegistry()
|
||||
self.app = self.create_app(notice_registry=self.registry)
|
||||
|
||||
def client(self) -> TestClient:
|
||||
return AuthTestClient(self.app)
|
||||
|
||||
def test_get_notices_orders_by_severity(self):
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
self.registry.raise_notice(
|
||||
"model_download_failed",
|
||||
scope="yolo/model.onnx",
|
||||
params={"file": "model.onnx", "error": "timeout"},
|
||||
)
|
||||
|
||||
with self.client() as client:
|
||||
response = client.get("/notices")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
[n["id"] for n in response.json()],
|
||||
["model_download_failed:yolo/model.onnx", "detector_stuck:ov"],
|
||||
)
|
||||
self.assertEqual(response.json()[0]["severity"], "error")
|
||||
self.assertEqual(
|
||||
[n["link"] for n in response.json()], [None, "/system#general"]
|
||||
)
|
||||
|
||||
def test_get_stats(self):
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
|
||||
with self.client() as client:
|
||||
response = client.get("/notices/stats")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()[0]["kind"], "detector_stuck")
|
||||
self.assertEqual(response.json()[0]["occurrences"], 1)
|
||||
|
||||
def test_dismiss_hides_and_history_keeps_it(self):
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
|
||||
with self.client() as client:
|
||||
response = client.post("/notices/detector_stuck:ov/dismiss")
|
||||
listed = client.get("/notices").json()
|
||||
history = client.get("/notices", params={"include_dismissed": True}).json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(listed, [])
|
||||
self.assertEqual([n["id"] for n in history], ["detector_stuck:ov"])
|
||||
self.assertIsNotNone(history[0]["dismissed_at"])
|
||||
|
||||
def test_dismiss_an_id_with_a_slash(self):
|
||||
self.registry.raise_notice(
|
||||
"model_download_failed", scope="yolo/model.onnx", params={}
|
||||
)
|
||||
|
||||
with self.client() as client:
|
||||
response = client.post(
|
||||
"/notices/model_download_failed:yolo/model.onnx/dismiss"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
|
||||
def test_dismiss_unknown_is_404(self):
|
||||
with self.client() as client:
|
||||
response = client.post("/notices/nope/dismiss")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_requires_admin(self):
|
||||
with TestClient(self.app) as client:
|
||||
response = client.get(
|
||||
"/notices", headers={"remote-user": "viewer", "remote-role": "viewer"}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_dismissed_checks_are_listed_once(self):
|
||||
with self.client() as client:
|
||||
first = client.post(f"/notices/{CONFIG_CHECK}/dismiss")
|
||||
again = client.post(f"/notices/{CONFIG_CHECK}/dismiss")
|
||||
listed = client.get("/notices/dismissed_checks").json()
|
||||
history = client.get("/notices", params={"include_dismissed": True}).json()
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(again.status_code, 200)
|
||||
self.assertEqual([check["id"] for check in listed], [CONFIG_CHECK])
|
||||
self.assertIsNotNone(listed[0]["dismissed_at"])
|
||||
# the Health tab builds the row itself, so it never comes back as a notice
|
||||
self.assertEqual(history, [])
|
||||
|
||||
def test_dismissed_checks_require_admin(self):
|
||||
with TestClient(self.app) as client:
|
||||
response = client.get(
|
||||
"/notices/dismissed_checks",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_purge_dismissed_clears_notices_and_checks(self):
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
self.registry.dismiss("detector_stuck:ov")
|
||||
self.registry.dismiss(CONFIG_CHECK)
|
||||
|
||||
with self.client() as client:
|
||||
response = client.delete("/notices/dismissed")
|
||||
history = client.get("/notices", params={"include_dismissed": True}).json()
|
||||
checks = client.get("/notices/dismissed_checks").json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(history, [])
|
||||
self.assertEqual(checks, [])
|
||||
|
||||
def test_purge_dismissed_requires_admin(self):
|
||||
with TestClient(self.app) as client:
|
||||
response = client.delete(
|
||||
"/notices/dismissed",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
@@ -497,43 +497,6 @@ class TestHttpReview(BaseTestHttp):
|
||||
)
|
||||
assert user_review.has_been_reviewed == True
|
||||
|
||||
def test_reviews_concurrent_insert_peewee_ignore(self):
|
||||
"""Validates that on_conflict_ignore() silently skips a duplicate insert
|
||||
|
||||
Two requests can both SELECT and find no existing status, then both try
|
||||
to INSERT, hitting the unique (user_id, review_segment) constraint.
|
||||
on_conflict_ignore() must silently skip the duplicate instead of raising
|
||||
an IntegrityError (which was previously caught with try/except).
|
||||
"""
|
||||
id = "123456.random"
|
||||
with AuthTestClient(self.app):
|
||||
super().insert_mock_review_segment(id)
|
||||
|
||||
# Simulate the first request having already committed its insert.
|
||||
self._insert_user_review_status(id, reviewed=True)
|
||||
|
||||
# Simulate the second concurrent request attempting the same insert.
|
||||
UserReviewStatus.insert_many(
|
||||
[
|
||||
{
|
||||
"user_id": self.user_id,
|
||||
"review_segment_id": id,
|
||||
"has_been_reviewed": True,
|
||||
}
|
||||
]
|
||||
).on_conflict_ignore().execute()
|
||||
|
||||
# Exactly one row should exist; no exception should have been raised.
|
||||
count = (
|
||||
UserReviewStatus.select()
|
||||
.where(
|
||||
(UserReviewStatus.user_id == self.user_id)
|
||||
& (UserReviewStatus.review_segment == id)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
####################################################################################################################
|
||||
################################### POST reviews/delete Endpoint ################################################
|
||||
####################################################################################################################
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user