forked from Github/frigate
Compare commits
164 Commits
v0.13.0-be
...
v0.13.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa93d4fbdd | ||
|
|
d0036b2f77 | ||
|
|
1b57f8c7e2 | ||
|
|
fa96ec64e4 | ||
|
|
e89db13282 | ||
|
|
fe6577736e | ||
|
|
64537672e6 | ||
|
|
ef36aabd30 | ||
|
|
ca84732574 | ||
|
|
0b828ef1ec | ||
|
|
3359123364 | ||
|
|
cc5357a31a | ||
|
|
f1b60f76eb | ||
|
|
f29e152619 | ||
|
|
92906a500a | ||
|
|
257bd89733 | ||
|
|
1d99bb908d | ||
|
|
591b91194a | ||
|
|
2b2c831253 | ||
|
|
08777100b5 | ||
|
|
a482160691 | ||
|
|
89dd114da1 | ||
|
|
4c05ef48a7 | ||
|
|
14c89c9b63 | ||
|
|
65e3e67a83 | ||
|
|
63233a5830 | ||
|
|
4f7b710112 | ||
|
|
ac53993f70 | ||
|
|
ef750e73a2 | ||
|
|
7270eef6bf | ||
|
|
b54aaad382 | ||
|
|
fc36be4f88 | ||
|
|
aefecad4c0 | ||
|
|
c57528cbcf | ||
|
|
090294e89b | ||
|
|
a6279a0337 | ||
|
|
0dd3dd23aa | ||
|
|
4bd29b2ee8 | ||
|
|
cc79cbcadc | ||
|
|
89366d7b12 | ||
|
|
6eff08eb2d | ||
|
|
8b6b83bd62 | ||
|
|
8b6e3a0d37 | ||
|
|
8a9b26df4e | ||
|
|
fd6a3bd5d2 | ||
|
|
8085ad4b4c | ||
|
|
af24eb7dbf | ||
|
|
d1620b4e39 | ||
|
|
ba603c1937 | ||
|
|
e89dafa82e | ||
|
|
9d717b371c | ||
|
|
3d70d29672 | ||
|
|
f1efd8dbe2 | ||
|
|
159fb51518 | ||
|
|
cd64399fe5 | ||
|
|
d72e1c38ae | ||
|
|
979c49fd35 | ||
|
|
16dc9f4bf7 | ||
|
|
52b47a3414 | ||
|
|
139664e598 | ||
|
|
441c605312 | ||
|
|
def889e3a8 | ||
|
|
613f1f6bd6 | ||
|
|
e173377859 | ||
|
|
86c59c1722 | ||
|
|
a399cb09fa | ||
|
|
5a46c36380 | ||
|
|
36c1e00a6b | ||
|
|
859ab0e7fa | ||
|
|
cf2b56613f | ||
|
|
1a9e00ee49 | ||
|
|
b9649de327 | ||
|
|
823550eed3 | ||
|
|
c141362614 | ||
|
|
e0e8a6fcc9 | ||
|
|
0b858419d1 | ||
|
|
2fb7200fb7 | ||
|
|
e9376ca285 | ||
|
|
cff4b9651f | ||
|
|
9df5927ac5 | ||
|
|
29f82add72 | ||
|
|
d102ebf855 | ||
|
|
cb3990a0ac | ||
|
|
9fc93c72a0 | ||
|
|
e13a176820 | ||
|
|
1e71e36056 | ||
|
|
18545718c1 | ||
|
|
c8b38bdd47 | ||
|
|
e80b6d9e5b | ||
|
|
ee1e1b748c | ||
|
|
0c2f3a9702 | ||
|
|
a3c0e30502 | ||
|
|
b4d5a3ef14 | ||
|
|
facd557f8c | ||
|
|
12487b3b60 | ||
|
|
8f349a6365 | ||
|
|
91f7d67c5e | ||
|
|
98200b7dda | ||
|
|
282cbf8f40 | ||
|
|
cd35481e92 | ||
|
|
126aed2798 | ||
|
|
efbc094bbc | ||
|
|
c7b2c6b95d | ||
|
|
1bdfc380c3 | ||
|
|
cac37e484d | ||
|
|
4469507e5b | ||
|
|
8626160df2 | ||
|
|
d4d2bb2521 | ||
|
|
e545dfc47b | ||
|
|
9ea10f8541 | ||
|
|
fa6c6c50d0 | ||
|
|
4c7ea01137 | ||
|
|
34b315cc8c | ||
|
|
9b687d77ce | ||
|
|
e32bd4ab15 | ||
|
|
e19c0668e7 | ||
|
|
869bb2b177 | ||
|
|
3869b274e2 | ||
|
|
2379e6fd1b | ||
|
|
dcafcc1320 | ||
|
|
d508088bd0 | ||
|
|
97e5a98b95 | ||
|
|
68ebd55425 | ||
|
|
a82334ca1c | ||
|
|
d7ddcea951 | ||
|
|
cc6e049966 | ||
|
|
dbd21eb6fa | ||
|
|
e1a6398219 | ||
|
|
7f5fba08b7 | ||
|
|
c35e8371be | ||
|
|
49e7723405 | ||
|
|
52cc707eb8 | ||
|
|
c47b02d2fe | ||
|
|
3a460133d4 | ||
|
|
67a5a7d21a | ||
|
|
5d2b87e077 | ||
|
|
8298806028 | ||
|
|
8cc7acd591 | ||
|
|
8bde914939 | ||
|
|
fe9fcf3eaa | ||
|
|
20c2ab39bc | ||
|
|
08ef69bac4 | ||
|
|
79fabbb6b0 | ||
|
|
8941aa5311 | ||
|
|
14d2b79c72 | ||
|
|
9a4f970337 | ||
|
|
22b9507797 | ||
|
|
37379e6fba | ||
|
|
232588636f | ||
|
|
e77fedc445 | ||
|
|
ead03c381b | ||
|
|
0048cd5edc | ||
|
|
56dfcd7a32 | ||
|
|
9f3ac19e05 | ||
|
|
50f13b7196 | ||
|
|
50b17031c4 | ||
|
|
d11c1a2066 | ||
|
|
27144eb0b9 | ||
|
|
64705c065f | ||
|
|
08eefd8385 | ||
|
|
705ee54315 | ||
|
|
e26bb94007 | ||
|
|
1aba8c1ef5 | ||
|
|
f92237c9c1 |
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -18,6 +18,12 @@ updates:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
target-branch: dev
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/docker/tensorrt"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
target-branch: dev
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/web"
|
||||
schedule:
|
||||
|
||||
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@@ -79,6 +79,15 @@ jobs:
|
||||
rpi.tags=${{ steps.setup.outputs.image-name }}-rpi
|
||||
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64
|
||||
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64,mode=max
|
||||
- name: Build and push RockChip build
|
||||
uses: docker/bake-action@v3
|
||||
with:
|
||||
push: true
|
||||
targets: rk
|
||||
files: docker/rockchip/rk.hcl
|
||||
set: |
|
||||
rk.tags=${{ steps.setup.outputs.image-name }}-rk
|
||||
*.cache-from=type=gha
|
||||
jetson_jp4_build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Jetson Jetpack 4
|
||||
@@ -141,7 +150,7 @@ jobs:
|
||||
- arm64_build
|
||||
steps:
|
||||
- id: lowercaseRepo
|
||||
uses: ASzc/change-string-case-action@v5
|
||||
uses: ASzc/change-string-case-action@v6
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
|
||||
2
.github/workflows/pull_request.yml
vendored
2
.github/workflows/pull_request.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.7.0
|
||||
uses: actions/setup-python@v4.7.1
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Install requirements
|
||||
|
||||
37
.github/workflows/release.yml
vendored
Normal file
37
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: On release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: lowercaseRepo
|
||||
uses: ASzc/change-string-case-action@v6
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Create tag variables
|
||||
run: |
|
||||
BRANCH=$([[ "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "master" || echo "dev")
|
||||
echo "BASE=ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}" >> $GITHUB_ENV
|
||||
echo "BUILD_TAG=${BRANCH}-${GITHUB_SHA::7}" >> $GITHUB_ENV
|
||||
echo "CLEAN_VERSION=$(echo ${GITHUB_REF##*/} | tr '[:upper:]' '[:lower:]' | sed 's/^[v]//')" >> $GITHUB_ENV
|
||||
- name: Tag and push the main image
|
||||
run: |
|
||||
VERSION_TAG=${BASE}:${CLEAN_VERSION}
|
||||
PULL_TAG=${BASE}:${BUILD_TAG}
|
||||
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${VERSION_TAG}
|
||||
for variant in standard-amd64 tensorrt tensorrt-jp4 tensorrt-jp5 rk; do
|
||||
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${VERSION_TAG}-${variant}
|
||||
done
|
||||
@@ -2,3 +2,5 @@
|
||||
/docker/tensorrt/ @madsciencetist @NateMeyer
|
||||
/docker/tensorrt/*arm64* @madsciencetist
|
||||
/docker/tensorrt/*jetson* @madsciencetist
|
||||
|
||||
/docker/rockchip/ @MarcA711
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
# syntax=docker/dockerfile:1.6
|
||||
|
||||
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
@@ -33,7 +33,7 @@ RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/cache/apt \
|
||||
FROM scratch AS go2rtc
|
||||
ARG TARGETARCH
|
||||
WORKDIR /rootfs/usr/local/go2rtc/bin
|
||||
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.7.1/go2rtc_linux_${TARGETARCH}" go2rtc
|
||||
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.8.2/go2rtc_linux_${TARGETARCH}" go2rtc
|
||||
|
||||
|
||||
####
|
||||
@@ -121,13 +121,15 @@ RUN apt-get -qq update \
|
||||
apt-transport-https \
|
||||
gnupg \
|
||||
wget \
|
||||
&& apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 648ACFD622F3D138 \
|
||||
&& echo "deb http://deb.debian.org/debian bullseye main contrib non-free" | tee /etc/apt/sources.list.d/raspi.list \
|
||||
# the key fingerprint can be obtained from https://ftp-master.debian.org/keys.html
|
||||
&& wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xA4285295FC7B1A81600062A9605C66F00D6C9793" | \
|
||||
gpg --dearmor > /usr/share/keyrings/debian-archive-bullseye-stable.gpg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/debian-archive-bullseye-stable.gpg] http://deb.debian.org/debian bullseye main contrib non-free" | \
|
||||
tee /etc/apt/sources.list.d/debian-bullseye-nonfree.list \
|
||||
&& apt-get -qq update \
|
||||
&& apt-get -qq install -y \
|
||||
python3.9 \
|
||||
python3.9-dev \
|
||||
wget \
|
||||
# opencv dependencies
|
||||
build-essential cmake git pkg-config libgtk-3-dev \
|
||||
libavcodec-dev libavformat-dev libswscale-dev libv4l-dev \
|
||||
@@ -199,6 +201,9 @@ ENV S6_LOGGING_SCRIPT="T 1 n0 s10000000 T"
|
||||
ENTRYPOINT ["/init"]
|
||||
CMD []
|
||||
|
||||
HEALTHCHECK --start-period=120s --start-interval=5s --interval=15s --timeout=5s --retries=3 \
|
||||
CMD curl --fail --silent --show-error http://127.0.0.1:5000/api/version || exit 1
|
||||
|
||||
# Frigate deps with Node.js and NPM for devcontainer
|
||||
FROM deps AS devcontainer
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
NGINX_VERSION="1.25.2"
|
||||
NGINX_VERSION="1.25.3"
|
||||
VOD_MODULE_VERSION="1.31"
|
||||
SECURE_TOKEN_MODULE_VERSION="1.5"
|
||||
RTMP_MODULE_VERSION="1.2.2"
|
||||
|
||||
@@ -55,17 +55,16 @@ fi
|
||||
|
||||
# arch specific packages
|
||||
if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||
# Use debian testing repo only for hwaccel packages
|
||||
echo 'deb http://deb.debian.org/debian testing main non-free' >/etc/apt/sources.list.d/debian-testing.list
|
||||
# use debian bookworm for hwaccel packages
|
||||
echo 'deb https://deb.debian.org/debian bookworm main contrib non-free' >/etc/apt/sources.list.d/debian-bookworm.list
|
||||
apt-get -qq update
|
||||
# intel-opencl-icd specifically for GPU support in OpenVino
|
||||
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
||||
intel-opencl-icd \
|
||||
mesa-va-drivers libva-drm2 intel-media-va-driver-non-free i965-va-driver libmfx1 radeontop intel-gpu-tools
|
||||
mesa-va-drivers radeontop libva-drm2 intel-media-va-driver-non-free i965-va-driver libmfx1 intel-gpu-tools
|
||||
# something about this dependency requires it to be installed in a separate call rather than in the line above
|
||||
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
||||
i965-va-driver-shaders
|
||||
rm -f /etc/apt/sources.list.d/debian-testing.list
|
||||
rm -f /etc/apt/sources.list.d/debian-bookworm.list
|
||||
fi
|
||||
|
||||
if [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
black == 23.3.*
|
||||
black == 23.10.*
|
||||
isort
|
||||
ruff
|
||||
|
||||
@@ -2,20 +2,20 @@ click == 8.1.*
|
||||
Flask == 2.3.*
|
||||
imutils == 0.5.*
|
||||
matplotlib == 3.7.*
|
||||
mypy == 1.4.1
|
||||
mypy == 1.6.1
|
||||
numpy == 1.23.*
|
||||
onvif_zeep == 0.2.12
|
||||
opencv-python-headless == 4.7.0.*
|
||||
paho-mqtt == 1.6.*
|
||||
peewee == 3.16.*
|
||||
peewee == 3.17.*
|
||||
peewee_migrate == 1.12.*
|
||||
psutil == 5.9.*
|
||||
pydantic == 1.10.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
PyYAML == 6.0.*
|
||||
pytz == 2023.3
|
||||
ruamel.yaml == 0.17.*
|
||||
tzlocal == 5.0.*
|
||||
pytz == 2023.3.post1
|
||||
ruamel.yaml == 0.18.*
|
||||
tzlocal == 5.2
|
||||
types-PyYAML == 6.0.*
|
||||
requests == 2.31.*
|
||||
types-requests == 2.31.*
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -16,6 +17,14 @@ sys.path.remove("/opt/frigate")
|
||||
|
||||
|
||||
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
|
||||
# read docker secret files as env vars too
|
||||
if os.path.isdir("/run/secrets"):
|
||||
for secret_file in os.listdir("/run/secrets"):
|
||||
if secret_file.startswith("FRIGATE_"):
|
||||
FRIGATE_ENV_VARS[secret_file] = Path(
|
||||
os.path.join("/run/secrets", secret_file)
|
||||
).read_text()
|
||||
|
||||
config_file = os.environ.get("CONFIG_FILE", "/config/config.yml")
|
||||
|
||||
# Check if we can use .yaml instead of .yml
|
||||
@@ -49,7 +58,15 @@ if go2rtc_config.get("log") is None:
|
||||
elif go2rtc_config["log"].get("format") is None:
|
||||
go2rtc_config["log"]["format"] = "text"
|
||||
|
||||
if not go2rtc_config.get("webrtc", {}).get("candidates", []):
|
||||
# ensure there is a default webrtc config
|
||||
if not go2rtc_config.get("webrtc"):
|
||||
go2rtc_config["webrtc"] = {}
|
||||
|
||||
# go2rtc should listen on 8555 tcp & udp by default
|
||||
if not go2rtc_config["webrtc"].get("listen"):
|
||||
go2rtc_config["webrtc"]["listen"] = ":8555"
|
||||
|
||||
if not go2rtc_config["webrtc"].get("candidates", []):
|
||||
default_candidates = []
|
||||
# use internal candidate if it was discovered when running through the add-on
|
||||
internal_candidate = os.environ.get(
|
||||
|
||||
@@ -32,6 +32,13 @@ http {
|
||||
gzip_proxied no-cache no-store private expired auth;
|
||||
gzip_vary on;
|
||||
|
||||
proxy_cache_path /dev/shm/nginx_cache levels=1:2 keys_zone=api_cache:10m max_size=10m inactive=1m use_temp_path=off;
|
||||
|
||||
map $sent_http_content_type $should_not_cache {
|
||||
'application/json' 0;
|
||||
default 1;
|
||||
}
|
||||
|
||||
upstream frigate_api {
|
||||
server 127.0.0.1:5001;
|
||||
keepalive 1024;
|
||||
@@ -93,10 +100,6 @@ http {
|
||||
secure_token $args;
|
||||
secure_token_types application/vnd.apple.mpegurl;
|
||||
|
||||
add_header Access-Control-Allow-Headers '*';
|
||||
add_header Access-Control-Expose-Headers 'Server,range,Content-Length,Content-Range';
|
||||
add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS';
|
||||
add_header Access-Control-Allow-Origin '*';
|
||||
add_header Cache-Control "no-store";
|
||||
expires off;
|
||||
}
|
||||
@@ -104,16 +107,6 @@ http {
|
||||
location /stream/ {
|
||||
add_header Cache-Control "no-store";
|
||||
expires off;
|
||||
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length';
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' "$http_origin";
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
types {
|
||||
application/dash+xml mpd;
|
||||
@@ -126,16 +119,6 @@ http {
|
||||
}
|
||||
|
||||
location /clips/ {
|
||||
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length';
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' "$http_origin";
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
types {
|
||||
video/mp4 mp4;
|
||||
@@ -152,17 +135,6 @@ http {
|
||||
}
|
||||
|
||||
location /recordings/ {
|
||||
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length';
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' "$http_origin";
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
types {
|
||||
video/mp4 mp4;
|
||||
}
|
||||
@@ -173,17 +145,6 @@ http {
|
||||
}
|
||||
|
||||
location /exports/ {
|
||||
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length';
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' "$http_origin";
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
types {
|
||||
video/mp4 mp4;
|
||||
}
|
||||
@@ -195,68 +156,69 @@ http {
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://mqtt_ws/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /live/jsmpeg/ {
|
||||
proxy_pass http://jsmpeg/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /live/mse/ {
|
||||
proxy_pass http://go2rtc/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /live/webrtc/ {
|
||||
proxy_pass http://go2rtc/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location ~* /api/go2rtc([/]?.*)$ {
|
||||
proxy_pass http://go2rtc;
|
||||
rewrite ^/api/go2rtc(.*)$ /api$1 break;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location ~* /api/.*\.(jpg|jpeg|png)$ {
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
|
||||
rewrite ^/api/(.*)$ $1 break;
|
||||
proxy_pass http://frigate_api;
|
||||
proxy_pass_request_headers on;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
add_header Cache-Control "no-store";
|
||||
expires off;
|
||||
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
proxy_pass http://frigate_api/;
|
||||
proxy_pass_request_headers on;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
include proxy.conf;
|
||||
|
||||
proxy_cache api_cache;
|
||||
proxy_cache_lock on;
|
||||
proxy_cache_use_stale updating;
|
||||
proxy_cache_valid 200 5s;
|
||||
proxy_cache_bypass $http_x_cache_bypass;
|
||||
proxy_no_cache $should_not_cache;
|
||||
add_header X-Cache-Status $upstream_cache_status;
|
||||
|
||||
location /api/vod/ {
|
||||
proxy_pass http://frigate_api/vod/;
|
||||
include proxy.conf;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
location /api/stats {
|
||||
access_log off;
|
||||
rewrite ^/api/(.*)$ $1 break;
|
||||
proxy_pass http://frigate_api;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /api/version {
|
||||
access_log off;
|
||||
rewrite ^/api/(.*)$ $1 break;
|
||||
proxy_pass http://frigate_api;
|
||||
include proxy.conf;
|
||||
}
|
||||
}
|
||||
|
||||
location / {
|
||||
@@ -299,4 +261,4 @@ rtmp {
|
||||
meta copy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
docker/main/rootfs/usr/local/nginx/conf/proxy.conf
Normal file
4
docker/main/rootfs/usr/local/nginx/conf/proxy.conf
Normal file
@@ -0,0 +1,4 @@
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
24
docker/rockchip/Dockerfile
Normal file
24
docker/rockchip/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
|
||||
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
FROM wheels as rk-wheels
|
||||
COPY docker/main/requirements-wheels.txt /requirements-wheels.txt
|
||||
COPY docker/rockchip/requirements-wheels-rk.txt /requirements-wheels-rk.txt
|
||||
RUN sed -i "/https/d" /requirements-wheels.txt
|
||||
RUN pip3 wheel --wheel-dir=/rk-wheels -c /requirements-wheels.txt -r /requirements-wheels-rk.txt
|
||||
|
||||
FROM wget as rk-libs
|
||||
RUN wget -qO librknnrt.so https://github.com/MarcA711/rknpu2/raw/master/runtime/RK3588/Linux/librknn_api/aarch64/librknnrt.so
|
||||
|
||||
FROM deps AS rk-deps
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN --mount=type=bind,from=rk-wheels,source=/rk-wheels,target=/deps/rk-wheels \
|
||||
pip3 install -U /deps/rk-wheels/*.whl
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
COPY --from=rk-libs /rootfs/librknnrt.so /usr/lib/
|
||||
COPY docker/rockchip/yolov8n-320x320.rknn /models/
|
||||
2
docker/rockchip/requirements-wheels-rk.txt
Normal file
2
docker/rockchip/requirements-wheels-rk.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
hide-warnings == 0.17
|
||||
rknn-toolkit-lite2 @ https://github.com/MarcA711/rknn-toolkit2/raw/master/rknn_toolkit_lite2/packages/rknn_toolkit_lite2-1.5.2-cp39-cp39-linux_aarch64.whl
|
||||
34
docker/rockchip/rk.hcl
Normal file
34
docker/rockchip/rk.hcl
Normal file
@@ -0,0 +1,34 @@
|
||||
target wget {
|
||||
dockerfile = "docker/main/Dockerfile"
|
||||
platforms = ["linux/arm64"]
|
||||
target = "wget"
|
||||
}
|
||||
|
||||
target wheels {
|
||||
dockerfile = "docker/main/Dockerfile"
|
||||
platforms = ["linux/arm64"]
|
||||
target = "wheels"
|
||||
}
|
||||
|
||||
target deps {
|
||||
dockerfile = "docker/main/Dockerfile"
|
||||
platforms = ["linux/arm64"]
|
||||
target = "deps"
|
||||
}
|
||||
|
||||
target rootfs {
|
||||
dockerfile = "docker/main/Dockerfile"
|
||||
platforms = ["linux/arm64"]
|
||||
target = "rootfs"
|
||||
}
|
||||
|
||||
target rk {
|
||||
dockerfile = "docker/rockchip/Dockerfile"
|
||||
contexts = {
|
||||
wget = "target:wget",
|
||||
wheels = "target:wheels",
|
||||
deps = "target:deps",
|
||||
rootfs = "target:rootfs"
|
||||
}
|
||||
platforms = ["linux/arm64"]
|
||||
}
|
||||
10
docker/rockchip/rk.mk
Normal file
10
docker/rockchip/rk.mk
Normal file
@@ -0,0 +1,10 @@
|
||||
BOARDS += rk
|
||||
|
||||
local-rk: version
|
||||
docker buildx bake --load --file=docker/rockchip/rk.hcl --set rk.tags=frigate:latest-rk rk
|
||||
|
||||
build-rk: version
|
||||
docker buildx bake --file=docker/rockchip/rk.hcl --set rk.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-rk rk
|
||||
|
||||
push-rk: build-rk
|
||||
docker buildx bake --push --file=docker/rockchip/rk.hcl --set rk.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-rk rk
|
||||
BIN
docker/rockchip/yolov8n-320x320.rknn
Normal file
BIN
docker/rockchip/yolov8n-320x320.rknn
Normal file
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
# syntax=docker/dockerfile:1.6
|
||||
|
||||
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
@@ -24,3 +24,6 @@ COPY --from=trt-deps /usr/local/lib/libyolo_layer.so /usr/local/lib/libyolo_laye
|
||||
COPY --from=trt-deps /usr/local/src/tensorrt_demos /usr/local/src/tensorrt_demos
|
||||
COPY docker/tensorrt/detector/rootfs/ /
|
||||
ENV YOLO_MODELS="yolov7-320"
|
||||
|
||||
HEALTHCHECK --start-period=600s --start-interval=5s --interval=15s --timeout=5s --retries=3 \
|
||||
CMD curl --fail --silent --show-error http://127.0.0.1:5000/api/version || exit 1
|
||||
|
||||
@@ -120,7 +120,7 @@ NOTE: The folder that is mapped from the host needs to be the folder that contai
|
||||
|
||||
## Custom go2rtc version
|
||||
|
||||
Frigate currently includes go2rtc v1.7.1, there may be certain cases where you want to run a different version of go2rtc.
|
||||
Frigate currently includes go2rtc v1.8.2, there may be certain cases where you want to run a different version of go2rtc.
|
||||
|
||||
To do this:
|
||||
|
||||
@@ -128,3 +128,34 @@ To do this:
|
||||
2. Rename the build to `go2rtc`.
|
||||
3. Give `go2rtc` execute permission.
|
||||
4. Restart Frigate and the custom version will be used, you can verify by checking go2rtc logs.
|
||||
|
||||
## Validating your config.yaml file updates
|
||||
|
||||
When frigate starts up, it checks whether your config file is valid, and if it is not, the process exits. To minimize interruptions when updating your config, you have three options -- you can edit the config via the WebUI which has built in validation, use the config API, or you can validate on the command line using the frigate docker container.
|
||||
|
||||
### Via API
|
||||
|
||||
Frigate can accept a new configuration file as JSON at the `/config/save` endpoint. When updating the config this way, Frigate will validate the config before saving it, and return a `400` if the config is not valid.
|
||||
|
||||
```bash
|
||||
curl -X POST http://frigate_host:5000/config/save -d @config.json
|
||||
```
|
||||
|
||||
if you'd like you can use your yaml config directly by using [`yq`](https://github.com/mikefarah/yq) to convert it to json:
|
||||
|
||||
```bash
|
||||
yq r -j config.yml | curl -X POST http://frigate_host:5000/config/save -d @-
|
||||
```
|
||||
|
||||
### Via Command Line
|
||||
|
||||
You can also validate your config at the command line by using the docker container itself. In CI/CD, you leverage the return code to determine if your config is valid, Frigate will return `1` if the config is invalid, or `0` if it's valid.
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-v $(pwd)/config.yml:/config/config.yml \
|
||||
--entrypoint python3 \
|
||||
ghcr.io/blakeblackshear/frigate:stable \
|
||||
-u -m frigate \
|
||||
--validate_config
|
||||
```
|
||||
|
||||
@@ -50,7 +50,7 @@ cameras:
|
||||
|
||||
### Configuring Minimum Volume
|
||||
|
||||
The audio detector uses volume levels in the same way that motion in a camera feed is used for object detection. This means that frigate will not run audio detection unless the audio volume is above the configured level in order to reduce resource usage. Audio levels can vary widelely between camera models so it is important to run tests to see what volume levels are. MQTT explorer can be used on the audio topic to see what volume level is being detected.
|
||||
The audio detector uses volume levels in the same way that motion in a camera feed is used for object detection. This means that frigate will not run audio detection unless the audio volume is above the configured level in order to reduce resource usage. Audio levels can vary widely between camera models so it is important to run tests to see what volume levels are. MQTT explorer can be used on the audio topic to see what volume level is being detected.
|
||||
|
||||
:::tip
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ title: Camera Autotracking
|
||||
|
||||
An ONVIF-capable, PTZ (pan-tilt-zoom) camera that supports relative movement within the field of view (FOV) can be configured to automatically track moving objects and keep them in the center of the frame.
|
||||
|
||||

|
||||
|
||||
## Autotracking behavior
|
||||
|
||||
Once Frigate determines that an object is not a false positive and has entered one of the required zones, the autotracker will move the PTZ camera to keep the object centered in the frame until the object either moves out of the frame, the PTZ is not capable of any more movement, or Frigate loses track of it.
|
||||
@@ -21,13 +23,15 @@ Many cheaper or older PTZs may not support this standard. Frigate will report an
|
||||
|
||||
Alternatively, you can download and run [this simple Python script](https://gist.github.com/hawkeye217/152a1d4ba80760dac95d46e143d37112), replacing the details on line 4 with your camera's IP address, ONVIF port, username, and password to check your camera.
|
||||
|
||||
A growing list of cameras and brands that have been reported by users to work with Frigate's autotracking can be found [here](cameras.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
First, set up a PTZ preset in your camera's firmware and give it a name. If you're unsure how to do this, consult the documentation for your camera manufacturer's firmware. Some tutorials for common brands: [Amcrest](https://www.youtube.com/watch?v=lJlE9-krmrM), [Reolink](https://www.youtube.com/watch?v=VAnxHUY5i5w), [Dahua](https://www.youtube.com/watch?v=7sNbc5U-k54).
|
||||
|
||||
Edit your Frigate configuration file and enter the ONVIF parameters for your camera. Specify the object types to track, a required zone the object must enter to begin autotracking, and the camera preset name you configured in your camera's firmware to return to when tracking has ended. Optionally, specify a delay in seconds before Frigate returns the camera to the preset.
|
||||
|
||||
An [ONVIF connection](cameras.md) is required for autotracking to function.
|
||||
An [ONVIF connection](cameras.md) is required for autotracking to function. Also, a [motion mask](masks.md) over your camera's timestamp and any overlay text is recommended to ensure they are completely excluded from scene change calculations when the camera is moving.
|
||||
|
||||
Note that `autotracking` is disabled by default but can be enabled in the configuration or by MQTT.
|
||||
|
||||
@@ -50,6 +54,23 @@ cameras:
|
||||
autotracking:
|
||||
# Optional: enable/disable object autotracking. (default: shown below)
|
||||
enabled: False
|
||||
# Optional: calibrate the camera on startup (default: shown below)
|
||||
# A calibration will move the PTZ in increments and measure the time it takes to move.
|
||||
# The results are used to help estimate the position of tracked objects after a camera move.
|
||||
# Frigate will update your config file automatically after a calibration with
|
||||
# a "movement_weights" entry for the camera. You should then set calibrate_on_startup to False.
|
||||
calibrate_on_startup: False
|
||||
# Optional: the mode to use for zooming in/out on objects during autotracking. (default: shown below)
|
||||
# Available options are: disabled, absolute, and relative
|
||||
# disabled - don't zoom in/out on autotracked objects, use pan/tilt only
|
||||
# absolute - use absolute zooming (supported by most PTZ capable cameras)
|
||||
# relative - use relative zooming (not supported on all PTZs, but makes concurrent pan/tilt/zoom movements)
|
||||
zooming: disabled
|
||||
# Optional: A value to change the behavior of zooming on autotracked objects. (default: shown below)
|
||||
# A lower value will keep more of the scene in view around a tracked object.
|
||||
# A higher value will zoom in more on a tracked object, but Frigate may lose tracking more quickly.
|
||||
# The value should be between 0.1 and 0.75
|
||||
zoom_factor: 0.3
|
||||
# Optional: list of objects to track from labelmap.txt (default: shown below)
|
||||
track:
|
||||
- person
|
||||
@@ -60,18 +81,86 @@ cameras:
|
||||
return_preset: home
|
||||
# Optional: Seconds to delay before returning to preset. (default: shown below)
|
||||
timeout: 10
|
||||
# Optional: Values generated automatically by a camera calibration. Do not modify these manually. (default: shown below)
|
||||
movement_weights: []
|
||||
```
|
||||
|
||||
## Calibration
|
||||
|
||||
PTZ motors operate at different speeds. Performing a calibration will direct Frigate to measure this speed over a variety of movements and use those measurements to better predict the amount of movement necessary to keep autotracked objects in the center of the frame.
|
||||
|
||||
Calibration is optional, but will greatly assist Frigate in autotracking objects that move across the camera's field of view more quickly.
|
||||
|
||||
To begin calibration, set the `calibrate_on_startup` for your camera to `True` and restart Frigate. Frigate will then make a series of small and large movements with your camera. Don't move the PTZ manually while calibration is in progress. Once complete, camera motion will stop and your config file will be automatically updated with a `movement_weights` parameter to be used in movement calculations. You should not modify this parameter manually.
|
||||
|
||||
After calibration has ended, your PTZ will be moved to the preset specified by `return_preset`.
|
||||
|
||||
:::note
|
||||
|
||||
Frigate's web UI and all other cameras will be unresponsive while calibration is in progress. This is expected and normal to avoid excessive network traffic or CPU usage during calibration. Calibration for most PTZs will take about two minutes. The Frigate log will show calibration progress and any errors.
|
||||
|
||||
:::
|
||||
|
||||
At this point, Frigate will be running and will continue to refine and update the `movement_weights` parameter in your config automatically as the PTZ moves during autotracking and more measurements are obtained.
|
||||
|
||||
Before restarting Frigate, you should set `calibrate_on_startup` in your config file to `False`, otherwise your refined `movement_weights` will be overwritten and calibration will occur when starting again.
|
||||
|
||||
You can recalibrate at any time by removing the `movement_weights` parameter, setting `calibrate_on_startup` to `True`, and then restarting Frigate. You may need to recalibrate or remove `movement_weights` from your config altogether if autotracking is erratic. If you change your `return_preset` in any way or if you change your camera's detect `fps` value, a recalibration is also recommended.
|
||||
|
||||
If you initially calibrate with zooming disabled and then enable zooming at a later point, you should also recalibrate.
|
||||
|
||||
## Best practices and considerations
|
||||
|
||||
Every PTZ camera is different, so autotracking may not perform ideally in every situation. This experimental feature was initially developed using an EmpireTech/Dahua SD1A404XB-GNR.
|
||||
|
||||
The object tracker in Frigate estimates the motion of the PTZ so that tracked objects are preserved when the camera moves. In most cases (especially for faster moving objects), the default 5 fps is insufficient for the motion estimator to perform accurately. 10 fps is the current recommendation. Higher frame rates will likely not be more performant and will only slow down Frigate and the motion estimator. Adjust your camera to output at least 10 frames per second and change the `fps` parameter in the [detect configuration](index.md) of your configuration file.
|
||||
The object tracker in Frigate estimates the motion of the PTZ so that tracked objects are preserved when the camera moves. In most cases 5 fps is sufficient, but if you plan to track faster moving objects, you may want to increase this slightly. Higher frame rates (> 10fps) will only slow down Frigate and the motion estimator and may lead to dropped frames, especially if you are using experimental zooming.
|
||||
|
||||
A fast [detector](object_detectors.md) is recommended. CPU detectors will not perform well or won't work at all. If Frigate already has trouble keeping track of your object, the autotracker will struggle as well.
|
||||
A fast [detector](object_detectors.md) is recommended. CPU detectors will not perform well or won't work at all. You can watch Frigate's debug viewer for your camera to see a thicker colored box around the object currently being autotracked.
|
||||
|
||||
The autotracker will add PTZ motion requests to a queue while the motor is moving. Once the motor stops, the events in the queue will be executed together as one large move (rather than incremental moves). If your PTZ's motor is slow, you may not be able to reliably autotrack fast moving objects.
|
||||

|
||||
|
||||
A full-frame zone in `required_zones` is not recommended, especially if you've calibrated your camera and there are `movement_weights` defined in the configuration file. Frigate will continue to autotrack an object that has entered one of the `required_zones`, even if it moves outside of that zone.
|
||||
|
||||
Some users have found it helpful to adjust the zone `inertia` value. See the [configuration reference](index.md).
|
||||
|
||||
## Zooming
|
||||
|
||||
Zooming is a very experimental feature and may use significantly more CPU when tracking objects than panning/tilting only.
|
||||
|
||||
Absolute zooming makes zoom movements separate from pan/tilt movements. Most PTZ cameras will support absolute zooming. Absolute zooming was developed to be very conservative to work best with a variety of cameras and scenes. Absolute zooming usually will not occur until an object has stopped moving or is moving very slowly.
|
||||
|
||||
Relative zooming attempts to make a zoom movement concurrently with any pan/tilt movements. It was tested to work with some Dahua and Amcrest PTZs. But the ONVIF specification indicates that there no assumption about how the generic zoom range is mapped to magnification, field of view or other physical zoom dimension when using relative zooming. So if relative zooming behavior is erratic or just doesn't work, try absolute zooming.
|
||||
|
||||
You can optionally adjust the `zoom_factor` for your camera in your configuration file. Lower values will leave more space from the scene around the tracked object while higher values will cause your camera to zoom in more on the object. However, keep in mind that Frigate needs a fair amount of pixels and scene details outside of the bounding box of the tracked object to estimate the motion of your camera. If the object is taking up too much of the frame, Frigate will not be able to track the motion of the camera and your object will be lost.
|
||||
|
||||
The range of this option is from 0.1 to 0.75. The default value of 0.3 is conservative and should be sufficient for most users. Because every PTZ and scene is different, you should experiment to determine what works best for you.
|
||||
|
||||
## Usage applications
|
||||
|
||||
In security and surveillance, it's common to use "spotter" cameras in combination with your PTZ. When your fixed spotter camera detects an object, you could use an automation platform like Home Assistant to move the PTZ to a specific preset so that Frigate can begin automatically tracking the object. For example: a residence may have fixed cameras on the east and west side of the property, capturing views up and down a street. When the spotter camera on the west side detects a person, a Home Assistant automation could move the PTZ to a camera preset aimed toward the west. When the object enters the specified zone, Frigate's autotracker could then continue to track the person as it moves out of view of any of the fixed cameras.
|
||||
|
||||
## Troubleshooting and FAQ
|
||||
|
||||
### The autotracker loses track of my object. Why?
|
||||
|
||||
There are many reasons this could be the case. If you are using experimental zooming, your `zoom_factor` value might be too high, the object might be traveling too quickly, the scene might be too dark, there are not enough details in the scene (for example, a PTZ looking down on a driveway or other monotone background without a sufficient number of hard edges or corners), or the scene is otherwise less than optimal for Frigate to maintain tracking.
|
||||
|
||||
Your camera's shutter speed may also be set too low so that blurring occurs with motion. Check your camera's firmware to see if you can increase the shutter speed.
|
||||
|
||||
Watching Frigate's debug view can help to determine a possible cause. The autotracked object will have a thicker colored box around it.
|
||||
|
||||
### I'm seeing an error in the logs that my camera "is still in ONVIF 'MOVING' status." What does this mean?
|
||||
|
||||
There are two possible known reasons for this (and perhaps others yet unknown): a slow PTZ motor or buggy camera firmware. Frigate uses an ONVIF parameter provided by the camera, `MoveStatus`, to determine when the PTZ's motor is moving or idle. According to some users, Hikvision PTZs (even with the latest firmware), are not updating this value after PTZ movement. Unfortunately there is no workaround to this bug in Hikvision firmware, so autotracking will not function correctly and should be disabled in your config. This may also be the case with other non-Hikvision cameras utilizing Hikvision firmware.
|
||||
|
||||
### I tried calibrating my camera, but the logs show that it is stuck at 0% and Frigate is not starting up.
|
||||
|
||||
This is often caused by the same reason as above - the `MoveStatus` ONVIF parameter is not changing due to a bug in your camera's firmware. Also, see the note above: Frigate's web UI and all other cameras will be unresponsive while calibration is in progress. This is expected and normal. But if you don't see log entries every few seconds for calibration progress, your camera is not compatible with autotracking.
|
||||
|
||||
### I'm seeing this error in the logs: "Autotracker: motion estimator couldn't get transformations". What does this mean?
|
||||
|
||||
To maintain object tracking during PTZ moves, Frigate tracks the motion of your camera based on the details of the frame. If you are seeing this message, it could mean that your `zoom_factor` may be set too high, the scene around your detected object does not have enough details (like hard edges or color variatons), or your camera's shutter speed is too slow and motion blur is occurring. Try reducing `zoom_factor`, finding a way to alter the scene around your object, or changing your camera's shutter speed.
|
||||
|
||||
### Calibration seems to have completed, but the camera is not actually moving to track my object. Why?
|
||||
|
||||
Some cameras have firmware that reports that FOV RelativeMove, the ONVIF command that Frigate uses for autotracking, is supported. However, if the camera does not pan or tilt when an object comes into the required zone, your camera's firmware does not actually support FOV RelativeMove. One such camera is the Uniview IPC672LR-AX4DUPK. It actually moves its zoom motor instead of panning and tilting and does not follow the ONVIF standard whatsoever.
|
||||
|
||||
@@ -140,7 +140,7 @@ go2rtc:
|
||||
- rtspx://192.168.1.1:7441/abcdefghijk
|
||||
```
|
||||
|
||||
[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#source-rtsp)
|
||||
[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.8.2#source-rtsp)
|
||||
|
||||
In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record and rtmp if used directly with unifi protect.
|
||||
|
||||
@@ -150,3 +150,7 @@ ffmpeg:
|
||||
record: preset-record-ubiquiti
|
||||
rtmp: preset-rtmp-ubiquiti # recommend using go2rtc instead
|
||||
```
|
||||
|
||||
### TP-Link VIGI Cameras
|
||||
|
||||
TP-Link VIGI cameras need some adjustments to the main stream settings on the camera itself to avoid issues. The stream needs to be configured as `H264` with `Smart Coding` set to `off`. Without these settings you may have problems when trying to watch recorded events. For example Firefox will stop playback after a few seconds and show the following error message: `The media playback was aborted due to a corruption problem or because the media used features your browser did not support.`.
|
||||
|
||||
@@ -11,11 +11,12 @@ A camera is enabled by default but can be temporarily disabled by using `enabled
|
||||
|
||||
Each role can only be assigned to one input per camera. The options for roles are as follows:
|
||||
|
||||
| Role | Description |
|
||||
| ---------- | ---------------------------------------------------------------------------------------- |
|
||||
| `detect` | Main feed for object detection |
|
||||
| `record` | Saves segments of the video feed based on configuration settings. [docs](record.md) |
|
||||
| `rtmp` | Deprecated: Broadcast as an RTMP feed for other services to consume. [docs](restream.md) |
|
||||
| Role | Description |
|
||||
| -------- | ---------------------------------------------------------------------------------------- |
|
||||
| `detect` | Main feed for object detection. [docs](object_detectors.md) |
|
||||
| `record` | Saves segments of the video feed based on configuration settings. [docs](record.md) |
|
||||
| `audio` | Feed for audio based detection. [docs](audio_detectors.md) |
|
||||
| `rtmp` | Deprecated: Broadcast as an RTMP feed for other services to consume. [docs](restream.md) |
|
||||
|
||||
```yaml
|
||||
mqtt:
|
||||
@@ -51,13 +52,18 @@ For camera model specific settings check the [camera specific](camera_specific.m
|
||||
|
||||
## Setting up camera PTZ controls
|
||||
|
||||
Add onvif config to camera
|
||||
:::caution
|
||||
|
||||
Not every PTZ supports ONVIF, which is the standard protocol Frigate uses to communicate with your camera. Check the [official list of ONVIF conformant products](https://www.onvif.org/conformant-products/), your camera documentation, or camera manufacturer's website to ensure your PTZ supports ONVIF. Also, ensure your camera is running the latest firmware.
|
||||
|
||||
:::
|
||||
|
||||
Add the onvif section to your camera in your configuration file:
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
back:
|
||||
ffmpeg:
|
||||
...
|
||||
ffmpeg: ...
|
||||
onvif:
|
||||
host: 10.0.10.10
|
||||
port: 8000
|
||||
@@ -65,6 +71,27 @@ cameras:
|
||||
password: password
|
||||
```
|
||||
|
||||
then PTZ controls will be available in the cameras WebUI.
|
||||
If the ONVIF connection is successful, PTZ controls will be available in the camera's WebUI.
|
||||
|
||||
An ONVIF-capable camera that supports relative movement within the field of view (FOV) can also be configured to automatically track moving objects and keep them in the center of the frame. For autotracking setup, see the [autotracking](autotracking.md) docs.
|
||||
|
||||
## ONVIF PTZ camera recommendations
|
||||
|
||||
This list of working and non-working PTZ cameras is based on user feedback.
|
||||
|
||||
| Brand or specific camera | PTZ Controls | Autotracking | Notes |
|
||||
| ------------------------ | :----------: | :----------: | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Amcrest | ✅ | ✅ | ⛔️ Generally, Amcrest should work, but some older models (like the common IP2M-841) don't support autotracking |
|
||||
| Amcrest ASH21 | ❌ | ❌ | No ONVIF support |
|
||||
| Ctronics PTZ | ✅ | ❌ | |
|
||||
| Dahua | ✅ | ✅ | |
|
||||
| Foscam R5 | ✅ | ❌ | |
|
||||
| Hikvision | ✅ | ❌ | Incomplete ONVIF support (MoveStatus won't update even on latest firmware) - reported with HWP-N4215IH-DE and DS-2DE3304W-DE, but likely others |
|
||||
| Reolink 511WA | ✅ | ❌ | Zoom only |
|
||||
| Reolink E1 Pro | ✅ | ❌ | |
|
||||
| Reolink E1 Zoom | ✅ | ❌ | |
|
||||
| Sunba 405-D20X | ✅ | ❌ | |
|
||||
| Tapo C200 | ✅ | ❌ | Incomplete ONVIF support |
|
||||
| Tapo C210 | ❌ | ❌ | Incomplete ONVIF support |
|
||||
| Uniview IPC672LR-AX4DUPK | ✅ | ❌ | Firmware says FOV relative movement is supported, but camera doesn't actually move when sending ONVIF commands |
|
||||
| Vikylin PTZ-2804X-I2 | ❌ | ❌ | Incomplete ONVIF support |
|
||||
|
||||
@@ -13,7 +13,6 @@ See [the hwaccel docs](/configuration/hardware_acceleration.md) for more info on
|
||||
|
||||
| Preset | Usage | Other Notes |
|
||||
| --------------------- | ------------------------------ | ----------------------------------------------------- |
|
||||
| preset-rpi-32-h264 | 32 bit Rpi with h264 stream | |
|
||||
| preset-rpi-64-h264 | 64 bit Rpi with h264 stream | |
|
||||
| preset-vaapi | Intel & AMD VAAPI | Check hwaccel docs to ensure correct driver is chosen |
|
||||
| preset-intel-qsv-h264 | Intel QSV with h264 stream | If issues occur recommend using vaapi preset instead |
|
||||
|
||||
@@ -64,11 +64,10 @@ ffmpeg:
|
||||
|
||||
### Configuring Intel GPU Stats in Docker
|
||||
|
||||
Additional configuration is needed for the Docker container to be able to access the `intel_gpu_top` command for GPU stats. Three possible changes can be made:
|
||||
Additional configuration is needed for the Docker container to be able to access the `intel_gpu_top` command for GPU stats. There are two options:
|
||||
|
||||
1. Run the container as privileged.
|
||||
2. Adding the `CAP_PERFMON` capability.
|
||||
3. Setting the `perf_event_paranoid` low enough to allow access to the performance event system.
|
||||
2. Add the `CAP_PERFMON` capability (note: you might need to set the `perf_event_paranoid` low enough to allow access to the performance event system.)
|
||||
|
||||
#### Run as privileged
|
||||
|
||||
@@ -125,7 +124,7 @@ _Note: This setting must be changed for the entire system._
|
||||
|
||||
For more information on the various values across different distributions, see https://askubuntu.com/questions/1400874/what-does-perf-paranoia-level-four-do.
|
||||
|
||||
Depending on your OS and kernel configuration, you may need to change the `/proc/sys/kernel/perf_event_paranoid` kernel tunable. You can test the change by running `sudo sh -c 'echo 2 >/proc/sys/kernel/perf_event_paranoid'` which will persist until a reboot. Make it permanent by running `sudo sh -c 'echo kernel.perf_event_paranoid=1 >> /etc/sysctl.d/local.conf'`
|
||||
Depending on your OS and kernel configuration, you may need to change the `/proc/sys/kernel/perf_event_paranoid` kernel tunable. You can test the change by running `sudo sh -c 'echo 2 >/proc/sys/kernel/perf_event_paranoid'` which will persist until a reboot. Make it permanent by running `sudo sh -c 'echo kernel.perf_event_paranoid=2 >> /etc/sysctl.d/local.conf'`
|
||||
|
||||
## AMD/ATI GPUs (Radeon HD 2000 and newer GPUs) via libva-mesa-driver
|
||||
|
||||
|
||||
@@ -75,11 +75,11 @@ mqtt:
|
||||
# NOTE: must be unique if you are running multiple instances
|
||||
client_id: frigate
|
||||
# Optional: user
|
||||
# NOTE: MQTT user can be specified with an environment variables that must begin with 'FRIGATE_'.
|
||||
# NOTE: MQTT user can be specified with an environment variables or docker secrets that must begin with 'FRIGATE_'.
|
||||
# e.g. user: '{FRIGATE_MQTT_USER}'
|
||||
user: mqtt_user
|
||||
# Optional: password
|
||||
# NOTE: MQTT password can be specified with an environment variables that must begin with 'FRIGATE_'.
|
||||
# NOTE: MQTT password can be specified with an environment variables or docker secrets that must begin with 'FRIGATE_'.
|
||||
# e.g. password: '{FRIGATE_MQTT_PASSWORD}'
|
||||
password: password
|
||||
# Optional: tls_ca_certs for enabling TLS using self-signed certs (default: None)
|
||||
@@ -222,15 +222,17 @@ ffmpeg:
|
||||
# Optional: Detect configuration
|
||||
# NOTE: Can be overridden at the camera level
|
||||
detect:
|
||||
# Optional: width of the frame for the input with the detect role (default: shown below)
|
||||
# Optional: width of the frame for the input with the detect role (default: use native stream resolution)
|
||||
width: 1280
|
||||
# Optional: height of the frame for the input with the detect role (default: shown below)
|
||||
# Optional: height of the frame for the input with the detect role (default: use native stream resolution)
|
||||
height: 720
|
||||
# Optional: desired fps for your camera for the input with the detect role (default: shown below)
|
||||
# NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera.
|
||||
fps: 5
|
||||
# Optional: enables detection for the camera (default: True)
|
||||
enabled: True
|
||||
# Optional: Number of consecutive detection hits required for an object to be initialized in the tracker. (default: 1/2 the frame rate)
|
||||
min_initialized: 2
|
||||
# Optional: Number of frames without a detection before Frigate considers an object to be gone. (default: 5x the frame rate)
|
||||
max_disappeared: 25
|
||||
# Optional: Configuration for stationary object tracking
|
||||
@@ -324,7 +326,7 @@ motion:
|
||||
# Low values will cause things like moving shadows to be detected as motion for longer.
|
||||
# https://www.geeksforgeeks.org/background-subtraction-in-an-image-using-concept-of-running-average/
|
||||
frame_alpha: 0.01
|
||||
# Optional: Height of the resized motion frame (default: 50)
|
||||
# Optional: Height of the resized motion frame (default: 100)
|
||||
# Higher values will result in more granular motion detection at the expense of higher CPU usage.
|
||||
# Lower values result in less CPU, but small changes may not register as motion.
|
||||
frame_height: 100
|
||||
@@ -436,7 +438,7 @@ rtmp:
|
||||
enabled: False
|
||||
|
||||
# Optional: Restream configuration
|
||||
# Uses https://github.com/AlexxIT/go2rtc (v1.7.1)
|
||||
# Uses https://github.com/AlexxIT/go2rtc (v1.8.2)
|
||||
go2rtc:
|
||||
|
||||
# Optional: jsmpeg stream configuration for WebUI
|
||||
@@ -489,7 +491,7 @@ cameras:
|
||||
# Required: A list of input streams for the camera. See documentation for more information.
|
||||
inputs:
|
||||
# Required: the path to the stream
|
||||
# NOTE: path may include environment variables, which must begin with 'FRIGATE_' and be referenced in {}
|
||||
# NOTE: path may include environment variables or docker secrets, which must begin with 'FRIGATE_' and be referenced in {}
|
||||
- path: rtsp://viewer:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
|
||||
# Required: list of roles for this stream. valid values are: audio,detect,record,rtmp
|
||||
# NOTICE: In addition to assigning the audio, record, and rtmp roles,
|
||||
@@ -518,6 +520,9 @@ cameras:
|
||||
# to be replaced by a newer image. (default: shown below)
|
||||
best_image_timeout: 60
|
||||
|
||||
# Optional: URL to visit the camera web UI directly from the system page. Might not be available on every camera.
|
||||
webui_url: ""
|
||||
|
||||
# Optional: zones for this camera
|
||||
zones:
|
||||
# Required: name of the zone
|
||||
@@ -527,7 +532,7 @@ cameras:
|
||||
# Required: List of x,y coordinates to define the polygon of the zone.
|
||||
# NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box.
|
||||
coordinates: 545,1077,747,939,788,805
|
||||
# Optional: Number of consecutive frames required for object to be considered present in the zone. Allowed values are 1-10 (default: shown below)
|
||||
# Optional: Number of consecutive frames required for object to be considered present in the zone (default: shown below).
|
||||
inertia: 3
|
||||
# Optional: List of objects that can trigger this zone (default: all tracked objects)
|
||||
objects:
|
||||
@@ -584,6 +589,23 @@ cameras:
|
||||
autotracking:
|
||||
# Optional: enable/disable object autotracking. (default: shown below)
|
||||
enabled: False
|
||||
# Optional: calibrate the camera on startup (default: shown below)
|
||||
# A calibration will move the PTZ in increments and measure the time it takes to move.
|
||||
# The results are used to help estimate the position of tracked objects after a camera move.
|
||||
# Frigate will update your config file automatically after a calibration with
|
||||
# a "movement_weights" entry for the camera. You should then set calibrate_on_startup to False.
|
||||
calibrate_on_startup: False
|
||||
# Optional: the mode to use for zooming in/out on objects during autotracking. (default: shown below)
|
||||
# Available options are: disabled, absolute, and relative
|
||||
# disabled - don't zoom in/out on autotracked objects, use pan/tilt only
|
||||
# absolute - use absolute zooming (supported by most PTZ capable cameras)
|
||||
# relative - use relative zooming (not supported on all PTZs, but makes concurrent pan/tilt/zoom movements)
|
||||
zooming: disabled
|
||||
# Optional: A value to change the behavior of zooming on autotracked objects. (default: shown below)
|
||||
# A lower value will keep more of the scene in view around a tracked object.
|
||||
# A higher value will zoom in more on a tracked object, but Frigate may lose tracking more quickly.
|
||||
# The value should be between 0.1 and 0.75
|
||||
zoom_factor: 0.3
|
||||
# Optional: list of objects to track from labelmap.txt (default: shown below)
|
||||
track:
|
||||
- person
|
||||
@@ -591,9 +613,11 @@ cameras:
|
||||
required_zones:
|
||||
- zone_name
|
||||
# Required: Name of ONVIF preset in camera's firmware to return to when tracking is over. (default: shown below)
|
||||
return_preset: preset_name
|
||||
return_preset: home
|
||||
# Optional: Seconds to delay before returning to preset. (default: shown below)
|
||||
timeout: 10
|
||||
# Optional: Values generated automatically by a camera calibration. Do not modify these manually. (default: shown below)
|
||||
movement_weights: []
|
||||
|
||||
# Optional: Configuration for how to sort the cameras in the Birdseye view.
|
||||
birdseye:
|
||||
|
||||
@@ -9,11 +9,11 @@ Frigate has different live view options, some of which require the bundled `go2r
|
||||
|
||||
Live view options can be selected while viewing the live stream. The options are:
|
||||
|
||||
| Source | Latency | Frame Rate | Resolution | Audio | Requires go2rtc | Other Limitations |
|
||||
| ------ | ------- | ------------------------------------- | -------------- | ---------------------------- | --------------- | -------------------------------------------- |
|
||||
| jsmpeg | low | same as `detect -> fps`, capped at 10 | same as detect | no | no | none |
|
||||
| mse | low | native | native | yes (depends on audio codec) | yes | not supported on iOS, Firefox is h.264 only |
|
||||
| webrtc | lowest | native | native | yes (depends on audio codec) | yes | requires extra config, doesn't support h.265 |
|
||||
| Source | Latency | Frame Rate | Resolution | Audio | Requires go2rtc | Other Limitations |
|
||||
| ------ | ------- | ------------------------------------- | -------------- | ---------------------------- | --------------- | ------------------------------------------------ |
|
||||
| jsmpeg | low | same as `detect -> fps`, capped at 10 | same as detect | no | no | none |
|
||||
| mse | low | native | native | yes (depends on audio codec) | yes | iPhone requires iOS 17.1+, Firefox is h.264 only |
|
||||
| webrtc | lowest | native | native | yes (depends on audio codec) | yes | requires extra config, doesn't support h.265 |
|
||||
|
||||
### Audio Support
|
||||
|
||||
@@ -37,12 +37,12 @@ There may be some cameras that you would prefer to use the sub stream for live v
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
rtsp_cam:
|
||||
test_cam:
|
||||
- rtsp://192.168.1.5:554/live0 # <- stream which supports video & aac audio.
|
||||
- "ffmpeg:rtsp_cam#audio=opus" # <- copy of the stream which transcodes audio to opus
|
||||
rtsp_cam_sub:
|
||||
- "ffmpeg:test_cam#audio=opus" # <- copy of the stream which transcodes audio to opus for webrtc
|
||||
test_cam_sub:
|
||||
- rtsp://192.168.1.5:554/substream # <- stream which supports video & aac audio.
|
||||
- "ffmpeg:rtsp_cam_sub#audio=opus" # <- copy of the stream which transcodes audio to opus
|
||||
- "ffmpeg:test_cam_sub#audio=opus" # <- copy of the stream which transcodes audio to opus for webrtc
|
||||
|
||||
cameras:
|
||||
test_cam:
|
||||
@@ -59,7 +59,7 @@ cameras:
|
||||
roles:
|
||||
- detect
|
||||
live:
|
||||
stream_name: rtsp_cam_sub
|
||||
stream_name: test_cam_sub
|
||||
```
|
||||
|
||||
### WebRTC extra configuration:
|
||||
@@ -104,6 +104,7 @@ If you are having difficulties getting WebRTC to work and you are running Frigat
|
||||
If not running in host mode, port 8555 will need to be mapped for the container:
|
||||
|
||||
docker-compose.yml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
@@ -115,4 +116,4 @@ services:
|
||||
|
||||
:::
|
||||
|
||||
See [go2rtc WebRTC docs](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#module-webrtc) for more information about this.
|
||||
See [go2rtc WebRTC docs](https://github.com/AlexxIT/go2rtc/tree/v1.8.2#module-webrtc) for more information about this.
|
||||
|
||||
103
docs/docs/configuration/motion_detection.md
Normal file
103
docs/docs/configuration/motion_detection.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
id: motion_detection
|
||||
title: Motion Detection
|
||||
---
|
||||
|
||||
# Tuning Motion Detection
|
||||
|
||||
Frigate uses motion detection as a first line check to see if there is anything happening in the frame worth checking with object detection.
|
||||
|
||||
Once motion is detected, it tries to group up nearby areas of motion together in hopes of identifying a rectangle in the image that will capture the area worth inspecting. These are the red "motion boxes" you see in the debug viewer.
|
||||
|
||||
## The Goal
|
||||
|
||||
The default motion settings should work well for the majority of cameras, however there are cases where tuning motion detection can lead to better and more optimal results. Each camera has its own environment with different variables that affect motion, this means that the same motion settings will not fit all of your cameras.
|
||||
|
||||
Before tuning motion it is important to understand the goal. In an optimal configuration, motion from people and cars would be detected, but not grass moving, lighting changes, timestamps, etc. If your motion detection is too sensitive, you will experience higher CPU loads and greater false positives from the increased rate of object detection. If it is not sensitive enough, you will miss events.
|
||||
|
||||
## Create Motion Masks
|
||||
|
||||
First, mask areas with regular motion not caused by the objects you want to detect. The best way to find candidates for motion masks is by watching the debug stream with motion boxes enabled. Good use cases for motion masks are timestamps or tree limbs and large bushes that regularly move due to wind. When possible, avoid creating motion masks that would block motion detection for objects you want to track **even if they are in locations where you don't want events**. Motion masks should not be used to avoid detecting objects in specific areas. More details can be found [in the masks docs.](/configuration/masks.md).
|
||||
|
||||
## Prepare For Testing
|
||||
|
||||
The easiest way to tune motion detection is to do it live, have one window / screen open with the frigate debug view and motion boxes enabled with another window / screen open allowing for configuring the motion settings. It is recommended to use Home Assistant or MQTT as they offer live configuration of some motion settings meaning that Frigate does not need to be restarted when values are changed.
|
||||
|
||||
In Home Assistant the `Improve Contrast`, `Contour Area`, and `Threshold` configuration entities are disabled by default but can easily be enabled and used to tune live, otherwise MQTT can be used.
|
||||
|
||||
## Tuning Motion Detection During The Day
|
||||
|
||||
Now that things are set up, find a time to tune that represents normal circumstances. For example, if you tune your motion on a day that is sunny and windy you may find later that the motion settings are not sensitive enough on a cloudy and still day.
|
||||
|
||||
:::note
|
||||
|
||||
Remember that motion detection is just used to determine when object detection should be used. You should aim to have motion detection sensitive enough that you won't miss events from objects you want to detect with object detection. The goal is to prevent object detection from running constantly for every small pixel change in the image. Windy days are still going to result in lots of motion being detected.
|
||||
|
||||
:::
|
||||
|
||||
### Threshold
|
||||
|
||||
The threshold value dictates how much of a change in a pixels luminance is required to be considered motion.
|
||||
|
||||
```yaml
|
||||
# default threshold value
|
||||
motion:
|
||||
# Optional: The threshold passed to cv2.threshold to determine if a pixel is different enough to be counted as motion. (default: shown below)
|
||||
# Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive.
|
||||
# The value should be between 1 and 255.
|
||||
threshold: 30
|
||||
```
|
||||
|
||||
Lower values mean motion detection is more sensitive to changes in color, making it more likely for example to detect motion when a brown dogs blends in with a brown fence or a person wearing a red shirt blends in with a red car. If the threshold is too low however, it may detect things like grass blowing in the wind, shadows, etc. to be detected as motion.
|
||||
|
||||
Watching the motion boxes in the debug view, increase the threshold until you only see motion that is visible to the eye. Once this is done, it is important to test and ensure that desired motion is still detected.
|
||||
|
||||
### Contour Area
|
||||
|
||||
```yaml
|
||||
# default contour_area value
|
||||
motion:
|
||||
# Optional: Minimum size in pixels in the resized motion image that counts as motion (default: shown below)
|
||||
# Increasing this value will prevent smaller areas of motion from being detected. Decreasing will
|
||||
# make motion detection more sensitive to smaller moving objects.
|
||||
# As a rule of thumb:
|
||||
# - 10 - high sensitivity
|
||||
# - 30 - medium sensitivity
|
||||
# - 50 - low sensitivity
|
||||
contour_area: 10
|
||||
```
|
||||
|
||||
Once the threshold calculation is run, the pixels that have changed are grouped together. The contour area value is used to decide which groups of changed pixels qualify as motion. Smaller values are more sensitive meaning people that are far away, small animals, etc. are more likely to be detected as motion, but it also means that small changes in shadows, leaves, etc. are detected as motion. Higher values are less sensitive meaning these things won't be detected as motion but with the risk that desired motion won't be detected until closer to the camera.
|
||||
|
||||
Watching the motion boxes in the debug view, adjust the contour area until there are no motion boxes smaller than the smallest you'd expect frigate to detect something moving.
|
||||
|
||||
### Improve Contrast
|
||||
|
||||
At this point if motion is working as desired there is no reason to continue with tuning for the day. If you were unable to find a balance between desired and undesired motion being detected, you can try disabling improve contrast and going back to the threshold and contour area steps.
|
||||
|
||||
## Tuning Motion Detection During The Night
|
||||
|
||||
Once daytime motion detection is tuned, there is a chance that the settings will work well for motion detection during the night as well. If this is the case then the preferred settings can be written to the config file and left alone.
|
||||
|
||||
However, if the preferred day settings do not work well at night it is recommended to use HomeAssistant or some other solution to automate changing the settings. That way completely separate sets of motion settings can be used for optimal day and night motion detection.
|
||||
|
||||
## Tuning For Large Changes In Motion
|
||||
|
||||
```yaml
|
||||
# default lightning_threshold:
|
||||
motion:
|
||||
# Optional: The percentage of the image used to detect lightning or other substantial changes where motion detection
|
||||
# needs to recalibrate. (default: shown below)
|
||||
# Increasing this value will make motion detection more likely to consider lightning or ir mode changes as valid motion.
|
||||
# Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching
|
||||
# a doorbell camera.
|
||||
lightning_threshold: 0.8
|
||||
```
|
||||
|
||||
:::tip
|
||||
|
||||
Some cameras like doorbell cameras may have missed detections when someone walks directly in front of the camera and the lightning_threshold causes motion detection to be re-calibrated. In this case, it may be desirable to increase the `lightning_threshold` to ensure these events are not missed.
|
||||
|
||||
:::
|
||||
|
||||
Large changes in motion like PTZ moves and camera switches between Color and IR mode should result in no motion detection. This is done via the `lightning_threshold` configuration. It is defined as the percentage of the image used to detect lightning or other substantial changes where motion detection needs to recalibrate. Increasing this value will make motion detection more likely to consider lightning or IR mode changes as valid motion. Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching a doorbell camera.
|
||||
@@ -5,7 +5,7 @@ title: Object Detectors
|
||||
|
||||
# Officially Supported Detectors
|
||||
|
||||
Frigate provides the following builtin detector types: `cpu`, `edgetpu`, `openvino`, and `tensorrt`. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras.
|
||||
Frigate provides the following builtin detector types: `cpu`, `edgetpu`, `openvino`, `tensorrt`, and `rknn`. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras.
|
||||
|
||||
## CPU Detector (not recommended)
|
||||
|
||||
@@ -291,3 +291,38 @@ To verify that the integration is working correctly, start Frigate and observe t
|
||||
|
||||
|
||||
# Community Supported Detectors
|
||||
|
||||
## Rockchip RKNN-Toolkit-Lite2
|
||||
|
||||
This detector is only available if one of the following Rockchip SoCs is used:
|
||||
- RK3566/RK3568
|
||||
- RK3588/RK3588S
|
||||
- RV1103/RV1106
|
||||
- RK3562
|
||||
|
||||
These SoCs come with a NPU that will highly speed up detection.
|
||||
|
||||
### Setup
|
||||
|
||||
RKNN support is provided using the `-rk` suffix for the docker image. Moreover, privileged mode must be enabled by adding the `--privileged` flag to your docker run command or `privileged: true` to your `docker-compose.yml` file.
|
||||
|
||||
### Configuration
|
||||
|
||||
This `config.yml` shows all relevant options to configure the detector and explains them. All values shown are the default values (except for one). Lines that are required at least to use the detector are labeled as required, all other lines are optional.
|
||||
```yaml
|
||||
detectors: # required
|
||||
rknn: # required
|
||||
type: rknn # required
|
||||
|
||||
model: # required
|
||||
# path to .rknn model file
|
||||
path: /models/yolov8n-320x320.rknn
|
||||
# width and height of detection frames
|
||||
width: 320
|
||||
height: 320
|
||||
# pixel format of detection frame
|
||||
# default value is rgb but yolov models usually use bgr format
|
||||
input_pixel_format: bgr # required
|
||||
# shape of detection frame
|
||||
input_tensor: nhwc
|
||||
```
|
||||
@@ -7,7 +7,7 @@ title: Restream
|
||||
|
||||
Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://<frigate_host>:8554/<camera_name>`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate.
|
||||
|
||||
Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.7.1) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#configuration) for more advanced configurations and features.
|
||||
Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.8.2) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.8.2#configuration) for more advanced configurations and features.
|
||||
|
||||
:::note
|
||||
|
||||
@@ -18,6 +18,7 @@ You can access the go2rtc webUI at `http://frigate_ip:5000/live/webrtc` which ca
|
||||
### Birdseye Restream
|
||||
|
||||
Birdseye RTSP restream can be accessed at `rtsp://<frigate_host>:8554/birdseye`. Enabling the birdseye restream will cause birdseye to run 24/7 which may increase CPU usage somewhat.
|
||||
|
||||
```yaml
|
||||
birdseye:
|
||||
restream: true
|
||||
@@ -32,8 +33,7 @@ go2rtc:
|
||||
rtsp:
|
||||
username: "admin"
|
||||
password: "pass"
|
||||
streams:
|
||||
...
|
||||
streams: ...
|
||||
```
|
||||
|
||||
**NOTE:** This does not apply to localhost requests, there is no need to provide credentials when using the restream as a source for frigate cameras.
|
||||
@@ -138,7 +138,7 @@ cameras:
|
||||
|
||||
## Advanced Restream Configurations
|
||||
|
||||
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
|
||||
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.8.2#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
|
||||
|
||||
NOTE: The output will need to be passed with two curly braces `{{output}}`
|
||||
|
||||
|
||||
@@ -56,3 +56,27 @@ camera:
|
||||
```
|
||||
|
||||
Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. You will get events for person objects that enter anywhere in the yard, and events for cars only if they enter the street.
|
||||
|
||||
### Zone Inertia
|
||||
|
||||
Sometimes an objects bounding box may be slightly incorrect and the bottom center of the bounding box is inside the zone while the object is not actually in the zone. Zone inertia helps guard against this by requiring an object's bounding box to be within the zone for multiple consecutive frames. This value can be configured:
|
||||
|
||||
```yaml
|
||||
camera:
|
||||
zones:
|
||||
front_yard:
|
||||
inertia: 3
|
||||
objects:
|
||||
- person
|
||||
```
|
||||
|
||||
There may also be cases where you expect an object to quickly enter and exit a zone, like when a car is pulling into the driveway, and you may want to have the object be considered present in the zone immediately:
|
||||
|
||||
```yaml
|
||||
camera:
|
||||
zones:
|
||||
driveway_entrance:
|
||||
inertia: 1
|
||||
objects:
|
||||
- car
|
||||
```
|
||||
|
||||
@@ -155,10 +155,6 @@ cd web && npm install
|
||||
cd web && npm run dev
|
||||
```
|
||||
|
||||
#### 3a. Run the development server against a non-local instance
|
||||
|
||||
To run the development server against a non-local instance, you will need to modify the API_HOST default return in `web/src/env.js`.
|
||||
|
||||
#### 4. Making changes
|
||||
|
||||
The Web UI is built using [Vite](https://vitejs.dev/), [Preact](https://preactjs.com), and [Tailwind CSS](https://tailwindcss.com).
|
||||
|
||||
@@ -95,6 +95,16 @@ Frigate supports all Jetson boards, from the inexpensive Jetson Nano to the powe
|
||||
|
||||
Inference speed will vary depending on the YOLO model, jetson platform and jetson nvpmodel (GPU/DLA/EMC clock speed). It is typically 20-40 ms for most models. The DLA is more efficient than the GPU, but not faster, so using the DLA will reduce power consumption but will slightly increase inference time.
|
||||
|
||||
#### Rockchip SoC
|
||||
|
||||
Frigate supports SBCs with the following Rockchip SoCs:
|
||||
- RK3566/RK3568
|
||||
- RK3588/RK3588S
|
||||
- RV1103/RV1106
|
||||
- RK3562
|
||||
|
||||
Using the yolov8n model and an Orange Pi 5 Plus with RK3588 SoC inference speeds vary between 25-40 ms.
|
||||
|
||||
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
|
||||
|
||||
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.
|
||||
|
||||
@@ -95,6 +95,7 @@ The following community supported builds are available:
|
||||
|
||||
`ghcr.io/blakeblackshear/frigate:stable-tensorrt-jp5` - Frigate build optimized for nvidia Jetson devices running Jetpack 5
|
||||
`ghcr.io/blakeblackshear/frigate:stable-tensorrt-jp4` - Frigate build optimized for nvidia Jetson devices running Jetpack 4.6
|
||||
`ghcr.io/blakeblackshear/frigate:stable-rk` - Frigate build for SBCs with Rockchip SoC
|
||||
|
||||
:::
|
||||
|
||||
@@ -204,6 +205,8 @@ It is recommended to run Frigate in LXC for maximum performance. See [this discu
|
||||
|
||||
For details on running Frigate using ESXi, please see the instructions [here](https://williamlam.com/2023/05/frigate-nvr-with-coral-tpu-igpu-passthrough-using-esxi-on-intel-nuc.html).
|
||||
|
||||
If you're running Frigate on a rack mounted server and want to passthough the Google Coral, [read this.](https://github.com/blakeblackshear/frigate/issues/305)
|
||||
|
||||
## Synology NAS on DSM 7
|
||||
|
||||
These settings were tested on DSM 7.1.1-42962 Update 4
|
||||
|
||||
@@ -11,7 +11,7 @@ Use of the bundled go2rtc is optional. You can still configure FFmpeg to connect
|
||||
|
||||
# Setup a go2rtc stream
|
||||
|
||||
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. If you set the stream name under go2rtc to match the name of your camera, it will automatically be mapped and you will get additional live view options for the camera. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#module-streams), not just rtsp.
|
||||
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. If you set the stream name under go2rtc to match the name of your camera, it will automatically be mapped and you will get additional live view options for the camera. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.8.2#module-streams), not just rtsp.
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
@@ -24,7 +24,7 @@ The easiest live view to get working is MSE. After adding this to the config, re
|
||||
|
||||
### What if my video doesn't play?
|
||||
|
||||
If you are unable to see your video feed, first check the go2rtc logs in the Frigate UI under Logs in the sidebar. If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log. If you do not see any errors, then the video codec of the stream may not be supported in your browser. If your camera stream is set to H265, try switching to H264. You can see more information about [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#codecs-madness) in the go2rtc documentation. If you are not able to switch your camera settings from H265 to H264 or your stream is a different format such as MJPEG, you can use go2rtc to re-encode the video using the [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. Here is an example of a config that will re-encode the stream to H264 without hardware acceleration:
|
||||
If you are unable to see your video feed, first check the go2rtc logs in the Frigate UI under Logs in the sidebar. If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log. If you do not see any errors, then the video codec of the stream may not be supported in your browser. If your camera stream is set to H265, try switching to H264. You can see more information about [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.8.2#codecs-madness) in the go2rtc documentation. If you are not able to switch your camera settings from H265 to H264 or your stream is a different format such as MJPEG, you can use go2rtc to re-encode the video using the [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.8.2#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. Here is an example of a config that will re-encode the stream to H264 without hardware acceleration:
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
|
||||
@@ -3,11 +3,7 @@ id: false_positives
|
||||
title: Reducing false positives
|
||||
---
|
||||
|
||||
Tune your object filters to adjust false positives: `min_area`, `max_area`, `min_ratio`, `max_ratio`, `min_score`, `threshold`.
|
||||
|
||||
The `min_area` and `max_area` values are compared against the area (number of pixels) from a given detected object. If the area is outside this range, the object will be ignored as a false positive. This allows objects that must be too small or too large to be ignored.
|
||||
|
||||
Similarly, the `min_ratio` and `max_ratio` values are compared against a given detected object's width/height ratio (in pixels). If the ratio is outside this range, the object will be ignored as a false positive. This allows objects that are proportionally too short-and-wide (higher ratio) or too tall-and-narrow (smaller ratio) to be ignored.
|
||||
## Object Scores
|
||||
|
||||
For object filters in your configuration, any single detection below `min_score` will be ignored as a false positive. `threshold` is based on the median of the history of scores (padded to 3 values) for a tracked object. Consider the following frames when `min_score` is set to 0.6 and threshold is set to 0.85:
|
||||
|
||||
@@ -22,4 +18,32 @@ For object filters in your configuration, any single detection below `min_score`
|
||||
|
||||
In frame 2, the score is below the `min_score` value, so Frigate ignores it and it becomes a 0.0. The computed score is the median of the score history (padding to at least 3 values), and only when that computed score crosses the `threshold` is the object marked as a true positive. That happens in frame 4 in the example.
|
||||
|
||||
If you're seeing false positives from stationary objects, please see Object Masks here: https://docs.frigate.video/configuration/masks/
|
||||
### Minimum Score
|
||||
|
||||
Any detection below `min_score` will be immediately thrown out and never tracked because it is considered a false positive. If `min_score` is too low then false positives may be detected and tracked which can confuse the object tracker and may lead to wasted resources. If `min_score` is too high then lower scoring true positives like objects that are further away or partially occluded may be thrown out which can also confuse the tracker and cause valid events to be lost or disjointed.
|
||||
|
||||
### Threshold
|
||||
|
||||
`threshold` is used to determine that the object is a true positive. Once an object is detected with a score >= `threshold` object is considered a true positive. If `threshold` is too low then some higher scoring false positives may create an event. If `threshold` is too high then true positive events may be missed due to the object never scoring high enough.
|
||||
|
||||
## Object Shape
|
||||
|
||||
False positives can also be reduced by filtering a detection based on its shape.
|
||||
|
||||
### Object Area
|
||||
|
||||
`min_area` and `max_area` filter on the area of an objects bounding box in pixels and can be used to reduce false positives that are outside the range of expected sizes. For example when a leaf is detected as a dog or when a large tree is detected as a person, these can be reduced by adding a `min_area` / `max_area` filter. The recordings timeline can be used to determine the area of the bounding box in that frame by selecting a timeline item then mousing over or tapping the red box.
|
||||
|
||||
### Object Proportions
|
||||
|
||||
`min_ratio` and `max_ratio` filter on the ratio of width / height of an objects bounding box and can be used to reduce false positives. For example if a false positive is detected as very tall for a dog which is often wider, a `min_ratio` filter can be used to filter out these false positives.
|
||||
|
||||
## Other Tools
|
||||
|
||||
### Zones
|
||||
|
||||
[Required zones](/configuration/zones.md) can be a great tool to reduce false positives that may be detected in the sky or other areas that are not of interest. The required zones will only create events for objects that enter the zone.
|
||||
|
||||
### Object Masks
|
||||
|
||||
[Object Filter Masks](/configuration/masks) are a last resort but can be useful when false positives are in the relatively same place but can not be filtered due to their size or shape.
|
||||
|
||||
@@ -155,18 +155,25 @@ Version info
|
||||
|
||||
Events from the database. Accepts the following query string parameters:
|
||||
|
||||
| param | Type | Description |
|
||||
| -------------------- | ---- | --------------------------------------------- |
|
||||
| `before` | int | Epoch time |
|
||||
| `after` | int | Epoch time |
|
||||
| `cameras` | str | , separated list of cameras |
|
||||
| `labels` | str | , separated list of labels |
|
||||
| `zones` | str | , separated list of zones |
|
||||
| `limit` | int | Limit the number of events returned |
|
||||
| `has_snapshot` | int | Filter to events that have snapshots (0 or 1) |
|
||||
| `has_clip` | int | Filter to events that have clips (0 or 1) |
|
||||
| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) |
|
||||
| `in_progress` | int | Limit to events in progress (0 or 1) |
|
||||
| param | Type | Description |
|
||||
| -------------------- | ----- | ----------------------------------------------------- |
|
||||
| `before` | int | Epoch time |
|
||||
| `after` | int | Epoch time |
|
||||
| `cameras` | str | , separated list of cameras |
|
||||
| `labels` | str | , separated list of labels |
|
||||
| `zones` | str | , separated list of zones |
|
||||
| `limit` | int | Limit the number of events returned |
|
||||
| `has_snapshot` | int | Filter to events that have snapshots (0 or 1) |
|
||||
| `has_clip` | int | Filter to events that have clips (0 or 1) |
|
||||
| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) |
|
||||
| `in_progress` | int | Limit to events in progress (0 or 1) |
|
||||
| `time_range` | str | Time range in format after,before (00:00,24:00) |
|
||||
| `timezone` | str | Timezone to use for time range |
|
||||
| `min_score` | float | Minimum score of the event |
|
||||
| `max_score` | float | Maximum score of the event |
|
||||
| `is_submitted` | int | Filter events that are submitted to Frigate+ (0 or 1) |
|
||||
| `min_length` | float | Minimum length of the event |
|
||||
| `max_length` | float | Maximum length of the event |
|
||||
|
||||
### `GET /api/timeline`
|
||||
|
||||
@@ -252,10 +259,14 @@ Accepts the following query string parameters, but they are only applied when an
|
||||
|
||||
Returns the snapshot image from the latest event for the given camera and label combo. Using `any` as the label will return the latest thumbnail regardless of type.
|
||||
|
||||
### `GET /api/<camera_name>/recording/<frame_time>/snapshot.png`
|
||||
### `GET /api/<camera_name>/recordings/<frame_time>/snapshot.png`
|
||||
|
||||
Returns the snapshot image from the specific point in that cameras recordings.
|
||||
|
||||
### `GET /api/<camera_name>/grid.jpg`
|
||||
|
||||
Returns the latest camera image with the regions grid overlaid.
|
||||
|
||||
### `GET /clips/<camera>-<id>.jpg`
|
||||
|
||||
JPG snapshot for the given camera and event id.
|
||||
@@ -315,11 +326,17 @@ Get PTZ info for the camera.
|
||||
|
||||
Create a manual event with a given `label` (ex: doorbell press) to capture a specific event besides an object being detected.
|
||||
|
||||
:::caution
|
||||
|
||||
Recording retention config still applies to manual events, if frigate is configured with `mode: motion` then the manual event will only keep recording segments when motion occurred.
|
||||
|
||||
:::
|
||||
|
||||
**Optional Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"subLabel": "some_string", // add sub label to event
|
||||
"sub_label": "some_string", // add sub label to event
|
||||
"duration": 30, // predetermined length of event (default: 30 seconds) or can be to null for indeterminate length event
|
||||
"include_recording": true, // whether the event should save recordings along with the snapshot that is taken
|
||||
"draw": {
|
||||
@@ -348,3 +365,7 @@ Create a manual event with a given `label` (ex: doorbell press) to capture a spe
|
||||
### `PUT /api/events/<event_id>/end`
|
||||
|
||||
End a specific manual event without a predetermined length.
|
||||
|
||||
### `POST /api/restart`
|
||||
|
||||
Restarts Frigate process.
|
||||
|
||||
@@ -220,3 +220,33 @@ Topic to turn the PTZ autotracker for a camera on and off. Expected values are `
|
||||
### `frigate/<camera_name>/ptz_autotracker/state`
|
||||
|
||||
Topic with current state of the PTZ autotracker for a camera. Published values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/ptz_autotracker/active`
|
||||
|
||||
Topic to determine if PTZ autotracker is actively tracking an object. Published values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/birdseye/set`
|
||||
|
||||
Topic to turn Birdseye for a camera on and off. Expected values are `ON` and `OFF`. Birdseye mode
|
||||
must be enabled in the configuration.
|
||||
|
||||
### `frigate/<camera_name>/birdseye/state`
|
||||
|
||||
Topic with current state of Birdseye for a camera. Published values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/birdseye_mode/set`
|
||||
|
||||
Topic to set Birdseye mode for a camera. Birdseye offers different modes to customize under which circumstances the camera is shown.
|
||||
|
||||
_Note: Changing the value from `CONTINUOUS` -> `MOTION | OBJECTS` will take up to 30 seconds for
|
||||
the camera to be removed from the view._
|
||||
|
||||
| Command | Description |
|
||||
| ------------ | ----------------------------------------------------------------- |
|
||||
| `CONTINUOUS` | Always included |
|
||||
| `MOTION` | Show when detected motion within the last 30 seconds are included |
|
||||
| `OBJECTS` | Shown if an active object tracked within the last 30 seconds |
|
||||
|
||||
### `frigate/<camera_name>/birdseye_mode/state`
|
||||
|
||||
Topic with current state of the Birdseye mode for a camera. Published values are `CONTINUOUS`, `MOTION`, `OBJECTS`.
|
||||
|
||||
@@ -19,7 +19,7 @@ Once logged in, you can generate an API key for Frigate in Settings.
|
||||
|
||||
### Set your API key
|
||||
|
||||
In Frigate, you can set the `PLUS_API_KEY` environment variable to enable the `SEND TO FRIGATE+` buttons on the events page. You can set it in your Docker Compose file or in your Docker run command. Home Assistant Addon users can set it under Settings > Addons > Frigate NVR > Configuration > Options (be sure to toggle the "Show unused optional configuration options" switch).
|
||||
In Frigate, you can use an environment variable or a docker secret named `PLUS_API_KEY` to enable the `SEND TO FRIGATE+` buttons on the events page. Home Assistant Addon users can set it under Settings > Addons > Frigate NVR > Configuration > Options (be sure to toggle the "Show unused optional configuration options" switch).
|
||||
|
||||
:::caution
|
||||
|
||||
|
||||
@@ -9,9 +9,43 @@ With a subscription, and at each annual renewal, you will receive 12 model train
|
||||
|
||||
Information on how to integrate Frigate+ with Frigate can be found in the [integrations docs](/integrations/plus).
|
||||
|
||||
## Improving your model
|
||||
|
||||
You may find that Frigate+ models result in more false positives initially, but by submitting true and false positives, the model will improve. Because a limited number of users submitted images to Frigate+ prior to this launch, you may need to submit several hundred images per camera to see good results. With all the new images now being submitted, future base models will improve as more and more users (including you) submit examples to Frigate+.
|
||||
|
||||
False positives can be reduced by submitting **both** true positives and false positives. This will help the model differentiate between what is and isn't correct. You should aim for a target of 80% true positive submissions and 20% false positives across all of your images. If you are experiencing false positives in a specific area, submitting true positives for any object type near that area in similar lighting conditions will help teach the model what that area looks like when no objects are present.
|
||||
|
||||
You may find that it's helpful to lower your thresholds a little in order to generate more false/true positives near the threshold value. For example, if you have some false positives that are scoring at 68% and some true positives scoring at 72%, you can try lowering your threshold to 65% and submitting both true and false positives within that range. This will help the model learn and widen the gap between true and false positive scores.
|
||||
|
||||
Note that only verified images will be used when training your model. Submitting an image from Frigate as a true or false positive will not verify the image. You still must verify the image in Frigate+ in order for it to be used in training.
|
||||
|
||||
In order to request your first model, you will need to have annotated and verified at least 10 images. Each subsequent model request will require that 10 additional images are verified. However, this is the bare minimum. For the best results, you should provide at least 100 verified images per camera. Keep in mind that varying conditions should be included. You will want images from cloudy days, sunny days, dawn, dusk, and night.
|
||||
|
||||
As circumstances change, you may need to submit new examples to address new types of false positives. For example, the change from summer days to snowy winter days or other changes such as a new grill or patio furniture may require additional examples and training.
|
||||
|
||||
## Properly labeling images
|
||||
|
||||
For the best results, follow the following guidelines.
|
||||
|
||||
**Label every object in the image**: It is important that you label all objects in each image before verifying. If you don't label a car for example, the model will be taught that part of the image is _not_ a car and it will start to get confused.
|
||||
|
||||
**Make tight bounding boxes**: Tighter bounding boxes improve the recognition and ensure that accurate bounding boxes are predicted at runtime.
|
||||
|
||||
**Label the full object even when occluded**: If you have a person standing behind a car, label the full person even though a portion of their body may be hidden behind the car. This helps predict accurate bounding boxes and improves zone accuracy and filters at runtime.
|
||||
|
||||
**`amazon`, `ups`, and `fedex` should label the logo**: For a Fedex truck, label the truck as a `car` and make a different bounding box just for the Fedex logo. If there are multiple logos, label each of them.
|
||||
|
||||

|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
While developing these models, there were some common questions that arose.
|
||||
### Are my models trained just on my image uploads? How are they built?
|
||||
|
||||
Frigate+ models are built by fine tuning a base model with the images you have annotated and verified. The base model is trained from scratch from a sampling of images across all Frigate+ user submissions and takes weeks of expensive GPU resources to train. If the models were built using your image uploads alone, you would need to provide tens of thousands of examples and it would take more than a week (and considerable cost) to train. Diversity helps the model generalize.
|
||||
|
||||
### What is a training credit and how do I use them?
|
||||
|
||||
Essentially, `1 training credit = 1 trained model`. When you have uploaded, annotated, and verified additional images and you are ready to train your model, you will submit a model request which will use one credit. The model that is trained will utilize all of the verified images in your account. When new base models are available, it will require the use of a training credit to generate a new user model on the new base model.
|
||||
|
||||
### Are my video feeds sent to the cloud for analysis when using Frigate+ models?
|
||||
|
||||
@@ -64,10 +98,10 @@ objects:
|
||||
fedex:
|
||||
min_score: .75
|
||||
person:
|
||||
min_score: .8
|
||||
min_score: .65
|
||||
threshold: .85
|
||||
car:
|
||||
min_score: .8
|
||||
min_score: .65
|
||||
threshold: .85
|
||||
```
|
||||
|
||||
@@ -79,6 +113,23 @@ Frigate+ models support a more relevant set of objects for security cameras. Cur
|
||||
|
||||
Frigate has special handling for some labels when using Frigate+ models. `face`, `license_plate`, `amazon`, `ups`, and `fedex` are considered attribute labels which are not tracked like regular objects and do not generate events. In addition, the `threshold` filter will have no effect on these labels. You should adjust the `min_score` and other filter values as needed.
|
||||
|
||||
In order to have Frigate start using these attribute labels, you will need to add them to the list of objects to track:
|
||||
|
||||
```yaml
|
||||
objects:
|
||||
track:
|
||||
- person
|
||||
- face
|
||||
- license_plate
|
||||
- dog
|
||||
- cat
|
||||
- car
|
||||
- amazon
|
||||
- fedex
|
||||
- ups
|
||||
- package
|
||||
```
|
||||
|
||||
When using Frigate+ models, Frigate will choose the snapshot of a person object that has the largest visible face. For cars, the snapshot with the largest visible license plate will be selected. This aids in secondary processing such as facial and license plate recognition for person and car objects.
|
||||
|
||||

|
||||
@@ -86,31 +137,3 @@ When using Frigate+ models, Frigate will choose the snapshot of a person object
|
||||
`amazon`, `ups`, and `fedex` labels are used to automatically assign a sub label to car objects.
|
||||
|
||||

|
||||
|
||||
## Properly labeling images
|
||||
|
||||
For the best results, follow the following guidelines.
|
||||
|
||||
**Label every object in the image**: It is important that you label all objects in each image before verifying. If you don't label a car for example, the model will be taught that part of the image is _not_ a car and it will start to get confused.
|
||||
|
||||
**Make tight bounding boxes**: Tighter bounding boxes improve the recognition and ensure that accurate bounding boxes are predicted at runtime.
|
||||
|
||||
**Label the full object even when occluded**: If you have a person standing behind a car, label the full person even though a portion of their body may be hidden behind the car. This helps predict accurate bounding boxes and improves zone accuracy and filters at runtime.
|
||||
|
||||
**`amazon`, `ups`, and `fedex` should label the logo**: For a Fedex truck, label the truck as a `car` and make a different bounding box just for the Fedex logo. If there are multiple logos, label each of them.
|
||||
|
||||

|
||||
|
||||
## Improving your model
|
||||
|
||||
You may find that Frigate+ models result in more false positives initially, but by submitting true and false positives, the model will improve. This may be because your cameras don't look quite enough like the user submissions that were used to train the base model. Over time, this will improve as more and more users (including you) submit examples to Frigate+.
|
||||
|
||||
False positives can be reduced by submitting **both** true positives and false positives. This will help the model differentiate between what is and isn't correct.
|
||||
|
||||
You may find that it's helpful to lower your thresholds a little in order to generate more false/true positives near the threshold value. For example, if you have some false positives that are scoring at 68% and some true positives scoring at 72%, you can try lowering your threshold to 65% and submitting both true and false positives within that range. This will help the model learn and widen the gap between true and false positive scores.
|
||||
|
||||
Note that only verified images will be used when training your model. Submitting an image from Frigate as a true or false positive will not verify the image. You still must verify the image in Frigate+ in order for it to be used in training.
|
||||
|
||||
In order to request your first model, you will need to have annotated and verified at least 10 images. Each subsequent model request will require that 10 additional images are verified. However, this is the bare minimum. For the best results, you should provide at least 100 verified images per camera. Keep in mind that varying conditions should be included. You will want images from cloudy days, sunny days, dawn, dusk, and night.
|
||||
|
||||
As circumstances change, you may need to submit new examples to address new types of false positives. For example, the change from summer days to snowy winter days or other changes such as a new grill or patio furniture may require additional examples and training.
|
||||
|
||||
@@ -23,6 +23,17 @@ Ensure your cameras send h264 encoded video, or [transcode them](/configuration/
|
||||
|
||||
You can open `chrome://media-internals/` in another tab and then try to playback, the media internals page will give information about why playback is failing.
|
||||
|
||||
### What do I do if my cameras sub stream is not good enough?
|
||||
|
||||
Frigate generally [recommends cameras with configurable sub streams](/frigate/hardware.md). However, if your camera does not have a sub stream that a suitable resolution, the main stream can be resized.
|
||||
|
||||
To do this efficiently the following setup is required:
|
||||
1. A GPU or iGPU must be available to do the scaling.
|
||||
2. [ffmpeg presets for hwaccel](/configuration/hardware_acceleration.md) must be used
|
||||
3. Set the desired detection resolution for `detect -> width` and `detect -> height`.
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -21,8 +21,8 @@ module.exports = {
|
||||
{
|
||||
type: "link",
|
||||
label: "Go2RTC Configuration Reference",
|
||||
href: "https://github.com/AlexxIT/go2rtc/tree/v1.6.2#configuration"
|
||||
}
|
||||
href: "https://github.com/AlexxIT/go2rtc/tree/v1.8.2#configuration",
|
||||
},
|
||||
],
|
||||
Detectors: [
|
||||
"configuration/object_detectors",
|
||||
@@ -32,6 +32,7 @@ module.exports = {
|
||||
"configuration/cameras",
|
||||
"configuration/record",
|
||||
"configuration/snapshots",
|
||||
"configuration/motion_detection",
|
||||
"configuration/birdseye",
|
||||
"configuration/live",
|
||||
"configuration/restream",
|
||||
@@ -57,16 +58,11 @@ module.exports = {
|
||||
"integrations/mqtt",
|
||||
"integrations/third_party_extensions",
|
||||
],
|
||||
"Frigate+": [
|
||||
"plus/index"
|
||||
],
|
||||
Troubleshooting: [
|
||||
"troubleshooting/faqs",
|
||||
"troubleshooting/recordings",
|
||||
],
|
||||
"Frigate+": ["plus/index"],
|
||||
Troubleshooting: ["troubleshooting/faqs", "troubleshooting/recordings"],
|
||||
Development: [
|
||||
"development/contributing",
|
||||
"development/contributing-boards"
|
||||
"development/contributing-boards",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
BIN
docs/static/img/autotracking-debug.gif
vendored
Normal file
BIN
docs/static/img/autotracking-debug.gif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 MiB |
BIN
docs/static/img/frigate-autotracking-example.gif
vendored
Normal file
BIN
docs/static/img/frigate-autotracking-example.gif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 MiB |
@@ -1,3 +1,4 @@
|
||||
import argparse
|
||||
import datetime
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
@@ -20,7 +21,7 @@ from frigate.comms.dispatcher import Communicator, Dispatcher
|
||||
from frigate.comms.inter_process import InterProcessCommunicator
|
||||
from frigate.comms.mqtt import MqttClient
|
||||
from frigate.comms.ws import WebSocketClient
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
||||
from frigate.const import (
|
||||
CACHE_DIR,
|
||||
CLIPS_DIR,
|
||||
@@ -36,7 +37,7 @@ from frigate.events.external import ExternalEventProcessor
|
||||
from frigate.events.maintainer import EventProcessor
|
||||
from frigate.http import create_app
|
||||
from frigate.log import log_process, root_configurer
|
||||
from frigate.models import Event, Recordings, RecordingsToDelete, Timeline
|
||||
from frigate.models import Event, Recordings, RecordingsToDelete, Regions, Timeline
|
||||
from frigate.object_detection import ObjectDetectProcess
|
||||
from frigate.object_processing import TrackedObjectProcessor
|
||||
from frigate.output import output_frames
|
||||
@@ -49,6 +50,7 @@ from frigate.stats import StatsEmitter, stats_init
|
||||
from frigate.storage import StorageMaintainer
|
||||
from frigate.timeline import TimelineProcessor
|
||||
from frigate.types import CameraMetricsTypes, FeatureMetricsTypes, PTZMetricsTypes
|
||||
from frigate.util.object import get_camera_regions_grid
|
||||
from frigate.version import VERSION
|
||||
from frigate.video import capture_camera, track_camera
|
||||
from frigate.watchdog import FrigateWatchdog
|
||||
@@ -69,6 +71,7 @@ class FrigateApp:
|
||||
self.feature_metrics: dict[str, FeatureMetricsTypes] = {}
|
||||
self.ptz_metrics: dict[str, PTZMetricsTypes] = {}
|
||||
self.processes: dict[str, int] = {}
|
||||
self.region_grids: dict[str, list[list[dict[str, int]]]] = {}
|
||||
|
||||
def set_environment_vars(self) -> None:
|
||||
for key, value in self.config.environment_vars.items():
|
||||
@@ -161,8 +164,25 @@ class FrigateApp:
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
"frame_queue": mp.Queue(maxsize=2),
|
||||
"region_grid_queue": mp.Queue(maxsize=1),
|
||||
"capture_process": None,
|
||||
"process": None,
|
||||
"audio_rms": mp.Value("d", 0.0), # type: ignore[typeddict-item]
|
||||
"audio_dBFS": mp.Value("d", 0.0), # type: ignore[typeddict-item]
|
||||
"birdseye_enabled": mp.Value( # type: ignore[typeddict-item]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
"i",
|
||||
self.config.cameras[camera_name].birdseye.enabled,
|
||||
),
|
||||
"birdseye_mode": mp.Value( # type: ignore[typeddict-item]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
"i",
|
||||
BirdseyeModeEnum.get_index(
|
||||
self.config.cameras[camera_name].birdseye.mode.value
|
||||
),
|
||||
),
|
||||
}
|
||||
self.ptz_metrics[camera_name] = {
|
||||
"ptz_autotracker_enabled": mp.Value( # type: ignore[typeddict-item]
|
||||
@@ -171,7 +191,8 @@ class FrigateApp:
|
||||
"i",
|
||||
self.config.cameras[camera_name].onvif.autotracking.enabled,
|
||||
),
|
||||
"ptz_stopped": mp.Event(),
|
||||
"ptz_tracking_active": mp.Event(),
|
||||
"ptz_motor_stopped": mp.Event(),
|
||||
"ptz_reset": mp.Event(),
|
||||
"ptz_start_time": mp.Value("d", 0.0), # type: ignore[typeddict-item]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
@@ -179,8 +200,20 @@ class FrigateApp:
|
||||
"ptz_stop_time": mp.Value("d", 0.0), # type: ignore[typeddict-item]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
"ptz_frame_time": mp.Value("d", 0.0), # type: ignore[typeddict-item]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
"ptz_zoom_level": mp.Value("d", 0.0), # type: ignore[typeddict-item]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
"ptz_max_zoom": mp.Value("d", 0.0), # type: ignore[typeddict-item]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
"ptz_min_zoom": mp.Value("d", 0.0), # type: ignore[typeddict-item]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
}
|
||||
self.ptz_metrics[camera_name]["ptz_stopped"].set()
|
||||
self.ptz_metrics[camera_name]["ptz_motor_stopped"].set()
|
||||
self.feature_metrics[camera_name] = {
|
||||
"audio_enabled": mp.Value( # type: ignore[typeddict-item]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
@@ -246,6 +279,17 @@ class FrigateApp:
|
||||
except PermissionError:
|
||||
logger.error("Unable to write to /config to save DB state")
|
||||
|
||||
def cleanup_timeline_db(db: SqliteExtDatabase) -> None:
|
||||
db.execute_sql(
|
||||
"DELETE FROM timeline WHERE source_id NOT IN (SELECT id FROM event);"
|
||||
)
|
||||
|
||||
try:
|
||||
with open(f"{CONFIG_DIR}/.timeline", "w") as f:
|
||||
f.write(str(datetime.datetime.now().timestamp()))
|
||||
except PermissionError:
|
||||
logger.error("Unable to write to /config to save DB state")
|
||||
|
||||
# Migrate DB location
|
||||
old_db_path = DEFAULT_DB_PATH
|
||||
if not os.path.isfile(self.config.database.path) and os.path.isfile(
|
||||
@@ -261,6 +305,11 @@ class FrigateApp:
|
||||
router = Router(migrate_db)
|
||||
router.run()
|
||||
|
||||
# this is a temporary check to clean up user DB from beta
|
||||
# will be removed before final release
|
||||
if not os.path.exists(f"{CONFIG_DIR}/.timeline"):
|
||||
cleanup_timeline_db(migrate_db)
|
||||
|
||||
# check if vacuum needs to be run
|
||||
if os.path.exists(f"{CONFIG_DIR}/.vacuum"):
|
||||
with open(f"{CONFIG_DIR}/.vacuum") as f:
|
||||
@@ -319,7 +368,7 @@ class FrigateApp:
|
||||
60, 10 * len([c for c in self.config.cameras.values() if c.enabled])
|
||||
),
|
||||
)
|
||||
models = [Event, Recordings, RecordingsToDelete, Timeline]
|
||||
models = [Event, Recordings, RecordingsToDelete, Regions, Timeline]
|
||||
self.db.bind(models)
|
||||
|
||||
def init_stats(self) -> None:
|
||||
@@ -412,6 +461,7 @@ class FrigateApp:
|
||||
self.config,
|
||||
self.onvif_controller,
|
||||
self.ptz_metrics,
|
||||
self.dispatcher,
|
||||
self.stop_event,
|
||||
)
|
||||
self.ptz_autotracker_thread.start()
|
||||
@@ -437,6 +487,7 @@ class FrigateApp:
|
||||
args=(
|
||||
self.config,
|
||||
self.video_output_queue,
|
||||
self.camera_metrics,
|
||||
),
|
||||
)
|
||||
output_processor.daemon = True
|
||||
@@ -444,6 +495,19 @@ class FrigateApp:
|
||||
output_processor.start()
|
||||
logger.info(f"Output process started: {output_processor.pid}")
|
||||
|
||||
def init_historical_regions(self) -> None:
|
||||
# delete region grids for removed or renamed cameras
|
||||
cameras = list(self.config.cameras.keys())
|
||||
Regions.delete().where(~(Regions.camera << cameras)).execute()
|
||||
|
||||
# create or update region grids for each camera
|
||||
for camera in self.config.cameras.values():
|
||||
self.region_grids[camera.name] = get_camera_regions_grid(
|
||||
camera.name,
|
||||
camera.detect,
|
||||
max(self.config.model.width, self.config.model.height),
|
||||
)
|
||||
|
||||
def start_camera_processors(self) -> None:
|
||||
for name, config in self.config.cameras.items():
|
||||
if not self.config.cameras[name].enabled:
|
||||
@@ -461,8 +525,10 @@ class FrigateApp:
|
||||
self.detection_queue,
|
||||
self.detection_out_events[name],
|
||||
self.detected_frames_queue,
|
||||
self.inter_process_queue,
|
||||
self.camera_metrics[name],
|
||||
self.ptz_metrics[name],
|
||||
self.region_grids[name],
|
||||
),
|
||||
)
|
||||
camera_process.daemon = True
|
||||
@@ -494,6 +560,7 @@ class FrigateApp:
|
||||
args=(
|
||||
self.config,
|
||||
self.audio_recordings_info_queue,
|
||||
self.camera_metrics,
|
||||
self.feature_metrics,
|
||||
self.inter_process_communicator,
|
||||
),
|
||||
@@ -562,6 +629,13 @@ class FrigateApp:
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="Frigate",
|
||||
description="An NVR with realtime local object detection for IP cameras.",
|
||||
)
|
||||
parser.add_argument("--validate-config", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
self.init_logger()
|
||||
logger.info(f"Starting Frigate ({VERSION})")
|
||||
try:
|
||||
@@ -585,6 +659,12 @@ class FrigateApp:
|
||||
print("*************************************************************")
|
||||
self.log_process.terminate()
|
||||
sys.exit(1)
|
||||
if args.validate_config:
|
||||
print("*************************************************************")
|
||||
print("*** Your config file is valid. ***")
|
||||
print("*************************************************************")
|
||||
self.log_process.terminate()
|
||||
sys.exit(0)
|
||||
self.set_environment_vars()
|
||||
self.set_log_levels()
|
||||
self.init_queues()
|
||||
@@ -602,6 +682,7 @@ class FrigateApp:
|
||||
self.start_detectors()
|
||||
self.start_video_output_processor()
|
||||
self.start_ptz_autotracker()
|
||||
self.init_historical_regions()
|
||||
self.start_detected_frames_processor()
|
||||
self.start_camera_processors()
|
||||
self.start_camera_capture_processes()
|
||||
|
||||
@@ -4,11 +4,12 @@ import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Callable
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import INSERT_MANY_RECORDINGS
|
||||
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
||||
from frigate.const import INSERT_MANY_RECORDINGS, REQUEST_REGION_GRID
|
||||
from frigate.models import Recordings
|
||||
from frigate.ptz.onvif import OnvifCommandEnum, OnvifController
|
||||
from frigate.types import CameraMetricsTypes, FeatureMetricsTypes, PTZMetricsTypes
|
||||
from frigate.util.object import get_camera_regions_grid
|
||||
from frigate.util.services import restart_frigate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -62,6 +63,8 @@ class Dispatcher:
|
||||
"motion_threshold": self._on_motion_threshold_command,
|
||||
"recordings": self._on_recordings_command,
|
||||
"snapshots": self._on_snapshots_command,
|
||||
"birdseye": self._on_birdseye_command,
|
||||
"birdseye_mode": self._on_birdseye_mode_command,
|
||||
}
|
||||
|
||||
for comm in self.comms:
|
||||
@@ -90,6 +93,15 @@ class Dispatcher:
|
||||
restart_frigate()
|
||||
elif topic == INSERT_MANY_RECORDINGS:
|
||||
Recordings.insert_many(payload).execute()
|
||||
elif topic == REQUEST_REGION_GRID:
|
||||
camera = payload
|
||||
self.camera_metrics[camera]["region_grid_queue"].put(
|
||||
get_camera_regions_grid(
|
||||
camera,
|
||||
self.config.cameras[camera].detect,
|
||||
max(self.config.model.width, self.config.model.height),
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.publish(topic, payload, retain=False)
|
||||
|
||||
@@ -176,11 +188,13 @@ class Dispatcher:
|
||||
if not self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value:
|
||||
logger.info(f"Turning on ptz autotracker for {camera_name}")
|
||||
self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value = True
|
||||
self.ptz_metrics[camera_name]["ptz_start_time"].value = 0
|
||||
ptz_autotracker_settings.enabled = True
|
||||
elif payload == "OFF":
|
||||
if self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value:
|
||||
logger.info(f"Turning off ptz autotracker for {camera_name}")
|
||||
self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value = False
|
||||
self.ptz_metrics[camera_name]["ptz_start_time"].value = 0
|
||||
ptz_autotracker_settings.enabled = False
|
||||
|
||||
self.publish(f"{camera_name}/ptz_autotracker/state", payload, retain=True)
|
||||
@@ -288,3 +302,43 @@ class Dispatcher:
|
||||
logger.info(f"Setting ptz command to {command} for {camera_name}")
|
||||
except KeyError as k:
|
||||
logger.error(f"Invalid PTZ command {payload}: {k}")
|
||||
|
||||
def _on_birdseye_command(self, camera_name: str, payload: str) -> None:
|
||||
"""Callback for birdseye topic."""
|
||||
birdseye_settings = self.config.cameras[camera_name].birdseye
|
||||
|
||||
if payload == "ON":
|
||||
if not self.camera_metrics[camera_name]["birdseye_enabled"].value:
|
||||
logger.info(f"Turning on birdseye for {camera_name}")
|
||||
self.camera_metrics[camera_name]["birdseye_enabled"].value = True
|
||||
birdseye_settings.enabled = True
|
||||
|
||||
elif payload == "OFF":
|
||||
if self.camera_metrics[camera_name]["birdseye_enabled"].value:
|
||||
logger.info(f"Turning off birdseye for {camera_name}")
|
||||
self.camera_metrics[camera_name]["birdseye_enabled"].value = False
|
||||
birdseye_settings.enabled = False
|
||||
|
||||
self.publish(f"{camera_name}/birdseye/state", payload, retain=True)
|
||||
|
||||
def _on_birdseye_mode_command(self, camera_name: str, payload: str) -> None:
|
||||
"""Callback for birdseye mode topic."""
|
||||
|
||||
if payload not in ["CONTINUOUS", "MOTION", "OBJECTS"]:
|
||||
logger.info(f"Invalid birdseye_mode command: {payload}")
|
||||
return
|
||||
|
||||
birdseye_config = self.config.cameras[camera_name].birdseye
|
||||
if not birdseye_config.enabled:
|
||||
logger.info(f"Birdseye mode not enabled for {camera_name}")
|
||||
return
|
||||
|
||||
new_birdseye_mode = BirdseyeModeEnum(payload.lower())
|
||||
logger.info(f"Setting birdseye mode for {camera_name} to {new_birdseye_mode}")
|
||||
|
||||
# update the metric (need the mode converted to an int)
|
||||
self.camera_metrics[camera_name][
|
||||
"birdseye_mode"
|
||||
].value = BirdseyeModeEnum.get_index(new_birdseye_mode)
|
||||
|
||||
self.publish(f"{camera_name}/birdseye_mode/state", payload, retain=True)
|
||||
|
||||
@@ -89,6 +89,18 @@ class MqttClient(Communicator): # type: ignore[misc]
|
||||
"OFF",
|
||||
retain=False,
|
||||
)
|
||||
self.publish(
|
||||
f"{camera_name}/birdseye/state",
|
||||
"ON" if camera.birdseye.enabled else "OFF",
|
||||
retain=True,
|
||||
)
|
||||
self.publish(
|
||||
f"{camera_name}/birdseye_mode/state",
|
||||
camera.birdseye.mode.value.upper()
|
||||
if camera.birdseye.enabled
|
||||
else "OFF",
|
||||
retain=True,
|
||||
)
|
||||
|
||||
self.publish("available", "online", retain=True)
|
||||
|
||||
@@ -160,6 +172,8 @@ class MqttClient(Communicator): # type: ignore[misc]
|
||||
"ptz_autotracker",
|
||||
"motion_threshold",
|
||||
"motion_contour_area",
|
||||
"birdseye",
|
||||
"birdseye_mode",
|
||||
]
|
||||
|
||||
for name in self.config.cameras.keys():
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Websocket communicator."""
|
||||
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
@@ -12,7 +13,7 @@ from ws4py.server.wsgirefserver import (
|
||||
WSGIServer,
|
||||
)
|
||||
from ws4py.server.wsgiutils import WebSocketWSGIApplication
|
||||
from ws4py.websocket import WebSocket
|
||||
from ws4py.websocket import WebSocket as WebSocket_
|
||||
|
||||
from frigate.comms.dispatcher import Communicator
|
||||
from frigate.config import FrigateConfig
|
||||
@@ -20,6 +21,18 @@ from frigate.config import FrigateConfig
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebSocket(WebSocket_):
|
||||
def unhandled_error(self, error):
|
||||
"""
|
||||
Handles the unfriendly socket closures on the server side
|
||||
without showing a confusing error message
|
||||
"""
|
||||
if hasattr(error, "errno") and error.errno == errno.ECONNRESET:
|
||||
pass
|
||||
else:
|
||||
logging.getLogger("ws4py").exception("Failed to receive data")
|
||||
|
||||
|
||||
class WebSocketClient(Communicator): # type: ignore[misc]
|
||||
"""Frigate wrapper for ws client."""
|
||||
|
||||
@@ -85,7 +98,10 @@ class WebSocketClient(Communicator): # type: ignore[misc]
|
||||
logger.debug(f"payload for {topic} wasn't text. Skipping...")
|
||||
return
|
||||
|
||||
self.websocket_server.manager.broadcast(ws_message)
|
||||
try:
|
||||
self.websocket_server.manager.broadcast(ws_message)
|
||||
except ConnectionResetError:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
self.websocket_server.manager.close_all()
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
@@ -13,8 +14,10 @@ from pydantic import BaseModel, Extra, Field, parse_obj_as, validator
|
||||
from pydantic.fields import PrivateAttr
|
||||
|
||||
from frigate.const import (
|
||||
ALL_ATTRIBUTE_LABELS,
|
||||
AUDIO_MIN_CONFIDENCE,
|
||||
CACHE_DIR,
|
||||
CACHE_SEGMENT_FORMAT,
|
||||
DEFAULT_DB_PATH,
|
||||
REGEX_CAMERA_NAME,
|
||||
YAML_EXT,
|
||||
@@ -46,6 +49,13 @@ DEFAULT_TIME_FORMAT = "%m/%d/%Y %H:%M:%S"
|
||||
# DEFAULT_TIME_FORMAT = "%d.%m.%Y %H:%M:%S"
|
||||
|
||||
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
|
||||
# read docker secret files as env vars too
|
||||
if os.path.isdir("/run/secrets"):
|
||||
for secret_file in os.listdir("/run/secrets"):
|
||||
if secret_file.startswith("FRIGATE_"):
|
||||
FRIGATE_ENV_VARS[secret_file] = Path(
|
||||
os.path.join("/run/secrets", secret_file)
|
||||
).read_text()
|
||||
|
||||
DEFAULT_TRACKED_OBJECTS = ["person"]
|
||||
DEFAULT_LISTEN_AUDIO = ["bark", "fire_alarm", "scream", "speech", "yell"]
|
||||
@@ -138,8 +148,26 @@ class MqttConfig(FrigateBaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class ZoomingModeEnum(str, Enum):
|
||||
disabled = "disabled"
|
||||
absolute = "absolute"
|
||||
relative = "relative"
|
||||
|
||||
|
||||
class PtzAutotrackConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=False, title="Enable PTZ object autotracking.")
|
||||
calibrate_on_startup: bool = Field(
|
||||
default=False, title="Perform a camera calibration when Frigate starts."
|
||||
)
|
||||
zooming: ZoomingModeEnum = Field(
|
||||
default=ZoomingModeEnum.disabled, title="Autotracker zooming mode."
|
||||
)
|
||||
zoom_factor: float = Field(
|
||||
default=0.3,
|
||||
title="Zooming factor (0.1-0.75).",
|
||||
ge=0.1,
|
||||
le=0.75,
|
||||
)
|
||||
track: List[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.")
|
||||
required_zones: List[str] = Field(
|
||||
default_factory=list,
|
||||
@@ -152,6 +180,27 @@ class PtzAutotrackConfig(FrigateBaseModel):
|
||||
timeout: int = Field(
|
||||
default=10, title="Seconds to delay before returning to preset."
|
||||
)
|
||||
movement_weights: Optional[Union[str, List[str]]] = Field(
|
||||
default=[],
|
||||
title="Internal value used for PTZ movements based on the speed of your camera's motor.",
|
||||
)
|
||||
|
||||
@validator("movement_weights", pre=True)
|
||||
def validate_weights(cls, v):
|
||||
if v is None:
|
||||
return None
|
||||
|
||||
if isinstance(v, str):
|
||||
weights = list(map(float, v.split(",")))
|
||||
elif isinstance(v, list):
|
||||
weights = [float(val) for val in v]
|
||||
else:
|
||||
raise ValueError("Invalid type for movement_weights")
|
||||
|
||||
if len(weights) != 5:
|
||||
raise ValueError("movement_weights must have exactly 5 floats")
|
||||
|
||||
return weights
|
||||
|
||||
|
||||
class OnvifConfig(FrigateBaseModel):
|
||||
@@ -312,6 +361,9 @@ class DetectConfig(FrigateBaseModel):
|
||||
default=5, title="Number of frames per second to process through detection."
|
||||
)
|
||||
enabled: bool = Field(default=True, title="Detection Enabled.")
|
||||
min_initialized: Optional[int] = Field(
|
||||
title="Minimum number of consecutive hits for an object to be initialized by the tracker."
|
||||
)
|
||||
max_disappeared: Optional[int] = Field(
|
||||
title="Maximum number of frames the object can dissapear before detection ends."
|
||||
)
|
||||
@@ -434,7 +486,7 @@ class ZoneConfig(BaseModel):
|
||||
|
||||
class ObjectConfig(FrigateBaseModel):
|
||||
track: List[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.")
|
||||
filters: Optional[Dict[str, FilterConfig]] = Field(title="Object filters.")
|
||||
filters: Dict[str, FilterConfig] = Field(default={}, title="Object filters.")
|
||||
mask: Union[str, List[str]] = Field(default="", title="Object mask.")
|
||||
|
||||
|
||||
@@ -461,6 +513,14 @@ class BirdseyeModeEnum(str, Enum):
|
||||
motion = "motion"
|
||||
continuous = "continuous"
|
||||
|
||||
@classmethod
|
||||
def get_index(cls, type):
|
||||
return list(cls).index(type)
|
||||
|
||||
@classmethod
|
||||
def get(cls, index):
|
||||
return list(cls)[index]
|
||||
|
||||
|
||||
class BirdseyeConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=True, title="Enable birdseye view.")
|
||||
@@ -680,6 +740,9 @@ class CameraConfig(FrigateBaseModel):
|
||||
default=60,
|
||||
title="How long to wait for the image with the highest confidence score.",
|
||||
)
|
||||
webui_url: Optional[str] = Field(
|
||||
title="URL to visit the camera directly from system page",
|
||||
)
|
||||
zones: Dict[str, ZoneConfig] = Field(
|
||||
default_factory=dict, title="Zone configuration."
|
||||
)
|
||||
@@ -803,7 +866,7 @@ class CameraConfig(FrigateBaseModel):
|
||||
|
||||
ffmpeg_output_args = (
|
||||
record_args
|
||||
+ [f"{os.path.join(CACHE_DIR, self.name)}-%Y%m%d%H%M%S.mp4"]
|
||||
+ [f"{os.path.join(CACHE_DIR, self.name)}@{CACHE_SEGMENT_FORMAT}.mp4"]
|
||||
+ ffmpeg_output_args
|
||||
)
|
||||
|
||||
@@ -1038,6 +1101,13 @@ class FrigateConfig(FrigateBaseModel):
|
||||
config.mqtt.user = config.mqtt.user.format(**FRIGATE_ENV_VARS)
|
||||
config.mqtt.password = config.mqtt.password.format(**FRIGATE_ENV_VARS)
|
||||
|
||||
# set default min_score for object attributes
|
||||
for attribute in ALL_ATTRIBUTE_LABELS:
|
||||
if not config.objects.filters.get(attribute):
|
||||
config.objects.filters[attribute] = FilterConfig(min_score=0.7)
|
||||
elif config.objects.filters[attribute].min_score == 0.5:
|
||||
config.objects.filters[attribute].min_score = 0.7
|
||||
|
||||
# Global config to propagate down to camera level
|
||||
global_config = config.dict(
|
||||
include={
|
||||
@@ -1088,6 +1158,11 @@ class FrigateConfig(FrigateBaseModel):
|
||||
else DEFAULT_DETECT_DIMENSIONS["height"]
|
||||
)
|
||||
|
||||
# Default min_initialized configuration
|
||||
min_initialized = camera_config.detect.fps / 2
|
||||
if camera_config.detect.min_initialized is None:
|
||||
camera_config.detect.min_initialized = min_initialized
|
||||
|
||||
# Default max_disappeared configuration
|
||||
max_disappeared = camera_config.detect.fps * 5
|
||||
if camera_config.detect.max_disappeared is None:
|
||||
|
||||
@@ -12,7 +12,7 @@ FRIGATE_LOCALHOST = "http://127.0.0.1:5000"
|
||||
PLUS_ENV_VAR = "PLUS_API_KEY"
|
||||
PLUS_API_HOST = "https://api.frigate.video"
|
||||
|
||||
# Attributes
|
||||
# Attribute & Object Consts
|
||||
|
||||
ATTRIBUTE_LABEL_MAP = {
|
||||
"person": ["face", "amazon"],
|
||||
@@ -21,6 +21,11 @@ ATTRIBUTE_LABEL_MAP = {
|
||||
ALL_ATTRIBUTE_LABELS = [
|
||||
item for sublist in ATTRIBUTE_LABEL_MAP.values() for item in sublist
|
||||
]
|
||||
LABEL_CONSOLIDATION_MAP = {
|
||||
"car": 0.8,
|
||||
"face": 0.5,
|
||||
}
|
||||
LABEL_CONSOLIDATION_DEFAULT = 0.9
|
||||
|
||||
# Audio Consts
|
||||
|
||||
@@ -45,9 +50,21 @@ DRIVER_INTEL_iHD = "iHD"
|
||||
|
||||
# Record Values
|
||||
|
||||
CACHE_SEGMENT_FORMAT = "%Y%m%d%H%M%S%z"
|
||||
MAX_SEGMENT_DURATION = 600
|
||||
MAX_PLAYLIST_SECONDS = 7200 # support 2 hour segments for a single playlist to account for cameras with inconsistent segment times
|
||||
|
||||
# Internal Comms Topics
|
||||
|
||||
INSERT_MANY_RECORDINGS = "insert_many_recordings"
|
||||
REQUEST_REGION_GRID = "request_region_grid"
|
||||
|
||||
# Autotracking
|
||||
|
||||
AUTOTRACKING_MAX_AREA_RATIO = 0.6
|
||||
AUTOTRACKING_MOTION_MIN_DISTANCE = 20
|
||||
AUTOTRACKING_MOTION_MAX_POINTS = 500
|
||||
AUTOTRACKING_MAX_MOVE_METRICS = 500
|
||||
AUTOTRACKING_ZOOM_OUT_HYSTERESIS = 1.2
|
||||
AUTOTRACKING_ZOOM_IN_HYSTERESIS = 0.9
|
||||
AUTOTRACKING_ZOOM_EDGE_THRESHOLD = 0.05
|
||||
|
||||
122
frigate/detectors/plugins/rknn.py
Normal file
122
frigate/detectors/plugins/rknn.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
import cv2
|
||||
import cv2.dnn
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from hide_warnings import hide_warnings
|
||||
except: # noqa: E722
|
||||
|
||||
def hide_warnings(func):
|
||||
pass
|
||||
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from frigate.detectors.detection_api import DetectionApi
|
||||
from frigate.detectors.detector_config import BaseDetectorConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DETECTOR_KEY = "rknn"
|
||||
|
||||
|
||||
class RknnDetectorConfig(BaseDetectorConfig):
|
||||
type: Literal[DETECTOR_KEY]
|
||||
score_thresh: float = Field(
|
||||
default=0.5, ge=0, le=1, title="Minimal confidence for detection."
|
||||
)
|
||||
nms_thresh: float = Field(
|
||||
default=0.45, ge=0, le=1, title="IoU threshold for non-maximum suppression."
|
||||
)
|
||||
|
||||
|
||||
class Rknn(DetectionApi):
|
||||
type_key = DETECTOR_KEY
|
||||
|
||||
def __init__(self, config: RknnDetectorConfig):
|
||||
self.height = config.model.height
|
||||
self.width = config.model.width
|
||||
self.score_thresh = config.score_thresh
|
||||
self.nms_thresh = config.nms_thresh
|
||||
|
||||
self.model_path = config.model.path or "/models/yolov8n-320x320.rknn"
|
||||
|
||||
from rknnlite.api import RKNNLite
|
||||
|
||||
self.rknn = RKNNLite(verbose=False)
|
||||
if self.rknn.load_rknn(self.model_path) != 0:
|
||||
logger.error("Error initializing rknn model.")
|
||||
if self.rknn.init_runtime() != 0:
|
||||
logger.error("Error initializing rknn runtime.")
|
||||
|
||||
def __del__(self):
|
||||
self.rknn.release()
|
||||
|
||||
def postprocess(self, results):
|
||||
"""
|
||||
Processes yolov8 output.
|
||||
|
||||
Args:
|
||||
results: array with shape: (1, 84, n, 1) where n depends on yolov8 model size (for 320x320 model n=2100)
|
||||
|
||||
Returns:
|
||||
detections: array with shape (20, 6) with 20 rows of (class, confidence, y_min, x_min, y_max, x_max)
|
||||
"""
|
||||
|
||||
results = np.transpose(results[0, :, :, 0]) # array shape (2100, 84)
|
||||
classes = np.argmax(
|
||||
results[:, 4:], axis=1
|
||||
) # array shape (2100,); index of class with max confidence of each row
|
||||
scores = np.max(
|
||||
results[:, 4:], axis=1
|
||||
) # array shape (2100,); max confidence of each row
|
||||
|
||||
# array shape (2100, 4); bounding box of each row
|
||||
boxes = np.transpose(
|
||||
np.vstack(
|
||||
(
|
||||
results[:, 0] - 0.5 * results[:, 2],
|
||||
results[:, 1] - 0.5 * results[:, 3],
|
||||
results[:, 2],
|
||||
results[:, 3],
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# indices of rows with confidence > SCORE_THRESH with Non-maximum Suppression (NMS)
|
||||
result_boxes = cv2.dnn.NMSBoxes(
|
||||
boxes, scores, self.score_thresh, self.nms_thresh, 0.5
|
||||
)
|
||||
|
||||
detections = np.zeros((20, 6), np.float32)
|
||||
|
||||
for i in range(len(result_boxes)):
|
||||
if i >= 20:
|
||||
break
|
||||
|
||||
index = result_boxes[i]
|
||||
detections[i] = [
|
||||
classes[index],
|
||||
scores[index],
|
||||
(boxes[index][1]) / self.height,
|
||||
(boxes[index][0]) / self.width,
|
||||
(boxes[index][1] + boxes[index][3]) / self.height,
|
||||
(boxes[index][0] + boxes[index][2]) / self.width,
|
||||
]
|
||||
|
||||
return detections
|
||||
|
||||
@hide_warnings
|
||||
def inference(self, tensor_input):
|
||||
return self.rknn.inference(inputs=tensor_input)
|
||||
|
||||
def detect_raw(self, tensor_input):
|
||||
output = self.inference(
|
||||
[
|
||||
tensor_input,
|
||||
]
|
||||
)
|
||||
return self.postprocess(output[0])
|
||||
@@ -303,6 +303,7 @@ class TensorRtDetector(DetectionApi):
|
||||
ordered[:, 3] = np.clip(ordered[:, 3] + ordered[:, 1], 0, 1)
|
||||
# put result into the correct order and limit to top 20
|
||||
detections = ordered[:, [5, 4, 1, 0, 3, 2]][:20]
|
||||
|
||||
# pad to 20x6 shape
|
||||
append_cnt = 20 - len(detections)
|
||||
if append_cnt > 0:
|
||||
|
||||
@@ -14,7 +14,7 @@ import requests
|
||||
from setproctitle import setproctitle
|
||||
|
||||
from frigate.comms.inter_process import InterProcessCommunicator
|
||||
from frigate.config import CameraConfig, FrigateConfig
|
||||
from frigate.config import CameraConfig, CameraInput, FfmpegConfig, FrigateConfig
|
||||
from frigate.const import (
|
||||
AUDIO_DURATION,
|
||||
AUDIO_FORMAT,
|
||||
@@ -26,7 +26,7 @@ from frigate.const import (
|
||||
from frigate.ffmpeg_presets import parse_preset_input
|
||||
from frigate.log import LogPipe
|
||||
from frigate.object_detection import load_labels
|
||||
from frigate.types import FeatureMetricsTypes
|
||||
from frigate.types import CameraMetricsTypes, FeatureMetricsTypes
|
||||
from frigate.util.builtin import get_ffmpeg_arg_list
|
||||
from frigate.util.services import listen
|
||||
from frigate.video import start_or_restart_ffmpeg, stop_ffmpeg
|
||||
@@ -39,19 +39,36 @@ except ModuleNotFoundError:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ffmpeg_command(input_args: list[str], input_path: str) -> list[str]:
|
||||
return get_ffmpeg_arg_list(
|
||||
f"ffmpeg {{}} -i {{}} -f {AUDIO_FORMAT} -ar {AUDIO_SAMPLE_RATE} -ac 1 -y {{}}".format(
|
||||
" ".join(input_args),
|
||||
input_path,
|
||||
def get_ffmpeg_command(ffmpeg: FfmpegConfig) -> list[str]:
|
||||
ffmpeg_input: CameraInput = [i for i in ffmpeg.inputs if "audio" in i.roles][0]
|
||||
input_args = get_ffmpeg_arg_list(ffmpeg.global_args) + (
|
||||
parse_preset_input(ffmpeg_input.input_args, 1)
|
||||
or ffmpeg_input.input_args
|
||||
or parse_preset_input(ffmpeg.input_args, 1)
|
||||
or ffmpeg.input_args
|
||||
)
|
||||
return (
|
||||
["ffmpeg", "-vn"]
|
||||
+ input_args
|
||||
+ ["-i"]
|
||||
+ [ffmpeg_input.path]
|
||||
+ [
|
||||
"-f",
|
||||
f"{AUDIO_FORMAT}",
|
||||
"-ar",
|
||||
f"{AUDIO_SAMPLE_RATE}",
|
||||
"-ac",
|
||||
"1",
|
||||
"-y",
|
||||
"pipe:",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def listen_to_audio(
|
||||
config: FrigateConfig,
|
||||
recordings_info_queue: mp.Queue,
|
||||
camera_metrics: dict[str, CameraMetricsTypes],
|
||||
process_info: dict[str, FeatureMetricsTypes],
|
||||
inter_process_communicator: InterProcessCommunicator,
|
||||
) -> None:
|
||||
@@ -80,6 +97,7 @@ def listen_to_audio(
|
||||
audio = AudioEventMaintainer(
|
||||
camera,
|
||||
recordings_info_queue,
|
||||
camera_metrics,
|
||||
process_info,
|
||||
stop_event,
|
||||
inter_process_communicator,
|
||||
@@ -153,6 +171,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self,
|
||||
camera: CameraConfig,
|
||||
recordings_info_queue: mp.Queue,
|
||||
camera_metrics: dict[str, CameraMetricsTypes],
|
||||
feature_metrics: dict[str, FeatureMetricsTypes],
|
||||
stop_event: mp.Event,
|
||||
inter_process_communicator: InterProcessCommunicator,
|
||||
@@ -161,19 +180,16 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.name = f"{camera.name}_audio_event_processor"
|
||||
self.config = camera
|
||||
self.recordings_info_queue = recordings_info_queue
|
||||
self.camera_metrics = camera_metrics
|
||||
self.feature_metrics = feature_metrics
|
||||
self.inter_process_communicator = inter_process_communicator
|
||||
self.detections: dict[dict[str, any]] = feature_metrics
|
||||
self.detections: dict[dict[str, any]] = {}
|
||||
self.stop_event = stop_event
|
||||
self.detector = AudioTfl(stop_event, self.config.audio.num_threads)
|
||||
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
|
||||
self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2))
|
||||
self.logger = logging.getLogger(f"audio.{self.config.name}")
|
||||
self.ffmpeg_cmd = get_ffmpeg_command(
|
||||
get_ffmpeg_arg_list(self.config.ffmpeg.global_args)
|
||||
+ parse_preset_input("preset-rtsp-audio-only", 1),
|
||||
[i.path for i in self.config.ffmpeg.inputs if "audio" in i.roles][0],
|
||||
)
|
||||
self.ffmpeg_cmd = get_ffmpeg_command(self.config.ffmpeg)
|
||||
self.logpipe = LogPipe(f"ffmpeg.{self.config.name}.audio")
|
||||
self.audio_listener = None
|
||||
|
||||
@@ -184,18 +200,19 @@ class AudioEventMaintainer(threading.Thread):
|
||||
audio_as_float = audio.astype(np.float32)
|
||||
rms, dBFS = self.calculate_audio_levels(audio_as_float)
|
||||
|
||||
self.camera_metrics[self.config.name]["audio_rms"].value = rms
|
||||
self.camera_metrics[self.config.name]["audio_dBFS"].value = dBFS
|
||||
|
||||
# only run audio detection when volume is above min_volume
|
||||
if rms >= self.config.audio.min_volume:
|
||||
# add audio info to recordings queue
|
||||
self.recordings_info_queue.put(
|
||||
(self.config.name, datetime.datetime.now().timestamp(), dBFS)
|
||||
)
|
||||
|
||||
# create waveform relative to max range and look for detections
|
||||
waveform = (audio / AUDIO_MAX_BIT_RANGE).astype(np.float32)
|
||||
model_detections = self.detector.detect(waveform)
|
||||
audio_detections = []
|
||||
|
||||
for label, score, _ in model_detections:
|
||||
logger.debug(f"Heard {label} with a score of {score}")
|
||||
|
||||
if label not in self.config.audio.listen:
|
||||
continue
|
||||
|
||||
@@ -203,6 +220,17 @@ class AudioEventMaintainer(threading.Thread):
|
||||
"threshold", 0.8
|
||||
):
|
||||
self.handle_detection(label, score)
|
||||
audio_detections.append(label)
|
||||
|
||||
# add audio info to recordings queue
|
||||
self.recordings_info_queue.put(
|
||||
(
|
||||
self.config.name,
|
||||
datetime.datetime.now().timestamp(),
|
||||
dBFS,
|
||||
audio_detections,
|
||||
)
|
||||
)
|
||||
|
||||
self.expire_detections()
|
||||
|
||||
@@ -212,7 +240,10 @@ class AudioEventMaintainer(threading.Thread):
|
||||
rms = np.sqrt(np.mean(np.absolute(np.square(audio_as_float))))
|
||||
|
||||
# Transform RMS to dBFS (decibels relative to full scale)
|
||||
dBFS = 20 * np.log10(np.abs(rms) / AUDIO_MAX_BIT_RANGE)
|
||||
if rms > 0:
|
||||
dBFS = 20 * np.log10(np.abs(rms) / AUDIO_MAX_BIT_RANGE)
|
||||
else:
|
||||
dBFS = 0
|
||||
|
||||
self.inter_process_communicator.queue.put(
|
||||
(f"{self.config.name}/audio/dBFS", float(dBFS))
|
||||
|
||||
@@ -83,18 +83,23 @@ class EventCleanup(threading.Thread):
|
||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||
).timestamp()
|
||||
# grab all events after specific time
|
||||
expired_events = Event.select(
|
||||
Event.id,
|
||||
Event.camera,
|
||||
).where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
expired_events = (
|
||||
Event.select(
|
||||
Event.id,
|
||||
Event.camera,
|
||||
)
|
||||
.where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
)
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
# delete the media from disk
|
||||
for event in expired_events:
|
||||
media_name = f"{event.camera}-{event.id}"
|
||||
for expired in expired_events:
|
||||
media_name = f"{expired.camera}-{expired.id}"
|
||||
media_path = Path(
|
||||
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
|
||||
)
|
||||
@@ -136,14 +141,19 @@ class EventCleanup(threading.Thread):
|
||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||
).timestamp()
|
||||
# grab all events after specific time
|
||||
expired_events = Event.select(
|
||||
Event.id,
|
||||
Event.camera,
|
||||
).where(
|
||||
Event.camera == name,
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
expired_events = (
|
||||
Event.select(
|
||||
Event.id,
|
||||
Event.camera,
|
||||
)
|
||||
.where(
|
||||
Event.camera == name,
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
)
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
# delete the grabbed clips from disk
|
||||
|
||||
@@ -106,10 +106,10 @@ class ExternalEventProcessor:
|
||||
# write jpg snapshot with optional annotations
|
||||
if draw.get("boxes") and isinstance(draw.get("boxes"), list):
|
||||
for box in draw.get("boxes"):
|
||||
x = box["box"][0] * camera_config.detect.width
|
||||
y = box["box"][1] * camera_config.detect.height
|
||||
width = box["box"][2] * camera_config.detect.width
|
||||
height = box["box"][3] * camera_config.detect.height
|
||||
x = int(box["box"][0] * camera_config.detect.width)
|
||||
y = int(box["box"][1] * camera_config.detect.height)
|
||||
width = int(box["box"][2] * camera_config.detect.width)
|
||||
height = int(box["box"][3] * camera_config.detect.height)
|
||||
|
||||
draw_box_with_label(
|
||||
img_frame,
|
||||
|
||||
@@ -42,6 +42,9 @@ def should_update_state(prev_event: Event, current_event: Event) -> bool:
|
||||
if prev_event["stationary"] != current_event["stationary"]:
|
||||
return True
|
||||
|
||||
if prev_event["attributes"] != current_event["attributes"]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ _user_agent_args = [
|
||||
]
|
||||
|
||||
PRESETS_HW_ACCEL_DECODE = {
|
||||
"preset-rpi-32-h264": "-c:v:1 h264_v4l2m2m",
|
||||
"preset-rpi-64-h264": "-c:v:1 h264_v4l2m2m",
|
||||
"preset-vaapi": f"-hwaccel_flags allow_profile_mismatch -hwaccel vaapi -hwaccel_device {_gpu_selector.get_selected_gpu()} -hwaccel_output_format vaapi",
|
||||
"preset-intel-qsv-h264": f"-hwaccel qsv -qsv_device {_gpu_selector.get_selected_gpu()} -hwaccel_output_format qsv -c:v h264_qsv",
|
||||
@@ -68,7 +67,6 @@ PRESETS_HW_ACCEL_DECODE = {
|
||||
}
|
||||
|
||||
PRESETS_HW_ACCEL_SCALE = {
|
||||
"preset-rpi-32-h264": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||
"preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||
"preset-vaapi": "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=yuv420p",
|
||||
"preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=framerate={0}:w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
|
||||
@@ -81,7 +79,6 @@ PRESETS_HW_ACCEL_SCALE = {
|
||||
}
|
||||
|
||||
PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = {
|
||||
"preset-rpi-32-h264": "ffmpeg -hide_banner {0} -c:v h264_v4l2m2m {1}",
|
||||
"preset-rpi-64-h264": "ffmpeg -hide_banner {0} -c:v h264_v4l2m2m {1}",
|
||||
"preset-vaapi": "ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {2} {0} -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf format=vaapi|nv12,hwupload {1}",
|
||||
"preset-intel-qsv-h264": "ffmpeg -hide_banner {0} -c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1 {1}",
|
||||
@@ -94,8 +91,7 @@ PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = {
|
||||
}
|
||||
|
||||
PRESETS_HW_ACCEL_ENCODE_TIMELAPSE = {
|
||||
"preset-rpi-32-h264": "ffmpeg -hide_banner {0} -c:v h264_v4l2m2m {1}",
|
||||
"preset-rpi-64-h264": "ffmpeg -hide_banner {0} -c:v h264_v4l2m2m {1}",
|
||||
"preset-rpi-64-h264": "ffmpeg -hide_banner {0} -c:v h264_v4l2m2m -pix_fmt yuv420p {1}",
|
||||
"preset-vaapi": "ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {2} {0} -c:v h264_vaapi {1}",
|
||||
"preset-intel-qsv-h264": "ffmpeg -hide_banner {0} -c:v h264_qsv -profile:v high -level:v 4.1 -async_depth:v 1 {1}",
|
||||
"preset-intel-qsv-h265": "ffmpeg -hide_banner {0} -c:v hevc_qsv -profile:v high -level:v 4.1 -async_depth:v 1 {1}",
|
||||
@@ -256,13 +252,6 @@ PRESETS_INPUT = {
|
||||
"-use_wallclock_as_timestamps",
|
||||
"1",
|
||||
],
|
||||
"preset-rtsp-audio-only": [
|
||||
"-rtsp_transport",
|
||||
"tcp",
|
||||
TIMEOUT_PARAM,
|
||||
"5000000",
|
||||
"-vn",
|
||||
],
|
||||
"preset-rtsp-restream": _user_agent_args
|
||||
+ [
|
||||
"-rtsp_transport",
|
||||
|
||||
486
frigate/http.py
486
frigate/http.py
@@ -20,6 +20,7 @@ from flask import (
|
||||
Flask,
|
||||
Response,
|
||||
current_app,
|
||||
escape,
|
||||
jsonify,
|
||||
make_response,
|
||||
request,
|
||||
@@ -28,6 +29,7 @@ from peewee import DoesNotExist, fn, operator
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
from tzlocal import get_localzone_name
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import (
|
||||
@@ -39,7 +41,7 @@ from frigate.const import (
|
||||
RECORD_DIR,
|
||||
)
|
||||
from frigate.events.external import ExternalEventProcessor
|
||||
from frigate.models import Event, Recordings, Timeline
|
||||
from frigate.models import Event, Recordings, Regions, Timeline
|
||||
from frigate.object_processing import TrackedObject
|
||||
from frigate.plus import PlusApi
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
@@ -56,6 +58,8 @@ from frigate.version import VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TIME_RANGE = "00:00,24:00"
|
||||
|
||||
bp = Blueprint("frigate", __name__)
|
||||
|
||||
|
||||
@@ -71,6 +75,13 @@ def create_app(
|
||||
):
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.before_request
|
||||
def check_csrf():
|
||||
if request.method in ["GET", "HEAD", "OPTIONS", "TRACE"]:
|
||||
pass
|
||||
if "origin" in request.headers and "x-csrf-token" not in request.headers:
|
||||
return jsonify({"success": False, "message": "Missing CSRF header"}), 401
|
||||
|
||||
@app.before_request
|
||||
def _db_connect():
|
||||
if database.is_closed():
|
||||
@@ -104,7 +115,7 @@ def is_healthy():
|
||||
@bp.route("/events/summary")
|
||||
def events_summary():
|
||||
tz_name = request.args.get("timezone", default="utc", type=str)
|
||||
hour_modifier, minute_modifier = get_tz_modifiers(tz_name)
|
||||
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
|
||||
has_clip = request.args.get("has_clip", type=int)
|
||||
has_snapshot = request.args.get("has_snapshot", type=int)
|
||||
|
||||
@@ -138,12 +149,7 @@ def events_summary():
|
||||
Event.camera,
|
||||
Event.label,
|
||||
Event.sub_label,
|
||||
fn.strftime(
|
||||
"%Y-%m-%d",
|
||||
fn.datetime(
|
||||
Event.start_time, "unixepoch", hour_modifier, minute_modifier
|
||||
),
|
||||
),
|
||||
(Event.start_time + seconds_offset).cast("int") / (3600 * 24),
|
||||
Event.zones,
|
||||
)
|
||||
)
|
||||
@@ -250,7 +256,7 @@ def send_to_plus(id):
|
||||
except Exception as ex:
|
||||
logger.exception(ex)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": str(ex)}),
|
||||
jsonify({"success": False, "message": "Error uploading image"}),
|
||||
400,
|
||||
)
|
||||
|
||||
@@ -270,7 +276,7 @@ def send_to_plus(id):
|
||||
except Exception as ex:
|
||||
logger.exception(ex)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": str(ex)}),
|
||||
jsonify({"success": False, "message": "Error uploading annotation"}),
|
||||
400,
|
||||
)
|
||||
|
||||
@@ -341,7 +347,7 @@ def false_positive(id):
|
||||
except Exception as ex:
|
||||
logger.exception(ex)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": str(ex)}),
|
||||
jsonify({"success": False, "message": "Error uploading false positive"}),
|
||||
400,
|
||||
)
|
||||
|
||||
@@ -444,8 +450,9 @@ def get_labels():
|
||||
else:
|
||||
events = Event.select(Event.label).distinct()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": f"Failed to get labels: {e}"}), 404
|
||||
jsonify({"success": False, "message": "Failed to get labels"}), 404
|
||||
)
|
||||
|
||||
labels = sorted([e.label for e in events])
|
||||
@@ -458,9 +465,9 @@ def get_sub_labels():
|
||||
|
||||
try:
|
||||
events = Event.select(Event.sub_label).distinct()
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": f"Failed to get sub_labels: {e}"}),
|
||||
jsonify({"success": False, "message": "Failed to get sub_labels"}),
|
||||
404,
|
||||
)
|
||||
|
||||
@@ -505,6 +512,7 @@ def delete_event(id):
|
||||
media.unlink(missing_ok=True)
|
||||
|
||||
event.delete_instance()
|
||||
Timeline.delete().where(Timeline.source_id == id).execute()
|
||||
return make_response(
|
||||
jsonify({"success": True, "message": "Event " + id + " deleted"}), 200
|
||||
)
|
||||
@@ -530,10 +538,14 @@ def event_thumbnail(id, max_cache_age=2592000):
|
||||
if tracked_obj is not None:
|
||||
thumbnail_bytes = tracked_obj.get_thumbnail()
|
||||
except Exception:
|
||||
return "Event not found", 404
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Event not found"}), 404
|
||||
)
|
||||
|
||||
if thumbnail_bytes is None:
|
||||
return "Event not found", 404
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Event not found"}), 404
|
||||
)
|
||||
|
||||
# android notifications prefer a 2:1 ratio
|
||||
if format == "android":
|
||||
@@ -628,10 +640,12 @@ def event_snapshot(id):
|
||||
event = Event.get(Event.id == id, Event.end_time != None)
|
||||
event_complete = True
|
||||
if not event.has_snapshot:
|
||||
return "Snapshot not available", 404
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Snapshot not available"}), 404
|
||||
)
|
||||
# read snapshot from disk
|
||||
with open(
|
||||
os.path.join(CLIPS_DIR, f"{event.camera}-{id}.jpg"), "rb"
|
||||
os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg"), "rb"
|
||||
) as image_file:
|
||||
jpg_bytes = image_file.read()
|
||||
except DoesNotExist:
|
||||
@@ -650,12 +664,18 @@ def event_snapshot(id):
|
||||
quality=request.args.get("quality", default=70, type=int),
|
||||
)
|
||||
except Exception:
|
||||
return "Event not found", 404
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Event not found"}), 404
|
||||
)
|
||||
except Exception:
|
||||
return "Event not found", 404
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Event not found"}), 404
|
||||
)
|
||||
|
||||
if jpg_bytes is None:
|
||||
return "Event not found", 404
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Event not found"}), 404
|
||||
)
|
||||
|
||||
response = make_response(jpg_bytes)
|
||||
response.headers["Content-Type"] = "image/jpeg"
|
||||
@@ -701,6 +721,112 @@ def label_snapshot(camera_name, label):
|
||||
return response
|
||||
|
||||
|
||||
@bp.route("/<camera_name>/grid.jpg")
|
||||
def grid_snapshot(camera_name):
|
||||
request.args.get("type", default="region")
|
||||
|
||||
if camera_name in current_app.frigate_config.cameras:
|
||||
detect = current_app.frigate_config.cameras[camera_name].detect
|
||||
frame = current_app.detected_frames_processor.get_current_frame(camera_name, {})
|
||||
retry_interval = float(
|
||||
current_app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval
|
||||
or 10
|
||||
)
|
||||
|
||||
if frame is None or datetime.now().timestamp() > (
|
||||
current_app.detected_frames_processor.get_current_frame_time(camera_name)
|
||||
+ retry_interval
|
||||
):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Unable to get valid frame"}),
|
||||
500,
|
||||
)
|
||||
|
||||
try:
|
||||
grid = (
|
||||
Regions.select(Regions.grid)
|
||||
.where(Regions.camera == camera_name)
|
||||
.get()
|
||||
.grid
|
||||
)
|
||||
except DoesNotExist:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Unable to get region grid"}),
|
||||
500,
|
||||
)
|
||||
|
||||
grid_size = len(grid)
|
||||
grid_coef = 1.0 / grid_size
|
||||
width = detect.width
|
||||
height = detect.height
|
||||
for x in range(grid_size):
|
||||
for y in range(grid_size):
|
||||
cell = grid[x][y]
|
||||
|
||||
if len(cell["sizes"]) == 0:
|
||||
continue
|
||||
|
||||
std_dev = round(cell["std_dev"] * width, 2)
|
||||
mean = round(cell["mean"] * width, 2)
|
||||
cv2.rectangle(
|
||||
frame,
|
||||
(int(x * grid_coef * width), int(y * grid_coef * height)),
|
||||
(
|
||||
int((x + 1) * grid_coef * width),
|
||||
int((y + 1) * grid_coef * height),
|
||||
),
|
||||
(0, 255, 0),
|
||||
2,
|
||||
)
|
||||
cv2.putText(
|
||||
frame,
|
||||
f"#: {len(cell['sizes'])}",
|
||||
(
|
||||
int(x * grid_coef * width + 10),
|
||||
int((y * grid_coef + 0.02) * height),
|
||||
),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
fontScale=0.5,
|
||||
color=(0, 255, 0),
|
||||
thickness=2,
|
||||
)
|
||||
cv2.putText(
|
||||
frame,
|
||||
f"std: {std_dev}",
|
||||
(
|
||||
int(x * grid_coef * width + 10),
|
||||
int((y * grid_coef + 0.05) * height),
|
||||
),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
fontScale=0.5,
|
||||
color=(0, 255, 0),
|
||||
thickness=2,
|
||||
)
|
||||
cv2.putText(
|
||||
frame,
|
||||
f"avg: {mean}",
|
||||
(
|
||||
int(x * grid_coef * width + 10),
|
||||
int((y * grid_coef + 0.08) * height),
|
||||
),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
fontScale=0.5,
|
||||
color=(0, 255, 0),
|
||||
thickness=2,
|
||||
)
|
||||
|
||||
ret, jpg = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
|
||||
response = make_response(jpg.tobytes())
|
||||
response.headers["Content-Type"] = "image/jpeg"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
else:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Camera not found"}),
|
||||
404,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/events/<id>/clip.mp4")
|
||||
def event_clip(id):
|
||||
download = request.args.get("download", type=bool)
|
||||
@@ -708,12 +834,16 @@ def event_clip(id):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == id)
|
||||
except DoesNotExist:
|
||||
return "Event not found.", 404
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Event not found"}), 404
|
||||
)
|
||||
|
||||
if not event.has_clip:
|
||||
return "Clip not available", 404
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Clip not available"}), 404
|
||||
)
|
||||
|
||||
file_name = f"{event.camera}-{id}.mp4"
|
||||
file_name = f"{event.camera}-{event.id}.mp4"
|
||||
clip_path = os.path.join(CLIPS_DIR, file_name)
|
||||
|
||||
if not os.path.isfile(clip_path):
|
||||
@@ -769,11 +899,17 @@ def events():
|
||||
limit = request.args.get("limit", 100)
|
||||
after = request.args.get("after", type=float)
|
||||
before = request.args.get("before", type=float)
|
||||
time_range = request.args.get("time_range", DEFAULT_TIME_RANGE)
|
||||
has_clip = request.args.get("has_clip", type=int)
|
||||
has_snapshot = request.args.get("has_snapshot", type=int)
|
||||
in_progress = request.args.get("in_progress", type=int)
|
||||
include_thumbnails = request.args.get("include_thumbnails", default=1, type=int)
|
||||
favorites = request.args.get("favorites", type=int)
|
||||
min_score = request.args.get("min_score", type=float)
|
||||
max_score = request.args.get("max_score", type=float)
|
||||
is_submitted = request.args.get("is_submitted", type=int)
|
||||
min_length = request.args.get("min_length", type=float)
|
||||
max_length = request.args.get("max_length", type=float)
|
||||
|
||||
clauses = []
|
||||
|
||||
@@ -851,6 +987,36 @@ def events():
|
||||
if before:
|
||||
clauses.append((Event.start_time < before))
|
||||
|
||||
if time_range != DEFAULT_TIME_RANGE:
|
||||
# get timezone arg to ensure browser times are used
|
||||
tz_name = request.args.get("timezone", default="utc", type=str)
|
||||
hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name)
|
||||
|
||||
times = time_range.split(",")
|
||||
time_after = times[0]
|
||||
time_before = times[1]
|
||||
|
||||
start_hour_fun = fn.strftime(
|
||||
"%H:%M",
|
||||
fn.datetime(Event.start_time, "unixepoch", hour_modifier, minute_modifier),
|
||||
)
|
||||
|
||||
# cases where user wants events overnight, ex: from 20:00 to 06:00
|
||||
# should use or operator
|
||||
if time_after > time_before:
|
||||
clauses.append(
|
||||
(
|
||||
reduce(
|
||||
operator.or_,
|
||||
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
|
||||
)
|
||||
)
|
||||
)
|
||||
# all other cases should be and operator
|
||||
else:
|
||||
clauses.append((start_hour_fun > time_after))
|
||||
clauses.append((start_hour_fun < time_before))
|
||||
|
||||
if has_clip is not None:
|
||||
clauses.append((Event.has_clip == has_clip))
|
||||
|
||||
@@ -866,6 +1032,24 @@ def events():
|
||||
if favorites:
|
||||
clauses.append((Event.retain_indefinitely == favorites))
|
||||
|
||||
if max_score is not None:
|
||||
clauses.append((Event.data["score"] <= max_score))
|
||||
|
||||
if min_score is not None:
|
||||
clauses.append((Event.data["score"] >= min_score))
|
||||
|
||||
if min_length is not None:
|
||||
clauses.append(((Event.end_time - Event.start_time) >= min_length))
|
||||
|
||||
if max_length is not None:
|
||||
clauses.append(((Event.end_time - Event.start_time) <= max_length))
|
||||
|
||||
if is_submitted is not None:
|
||||
if is_submitted == 0:
|
||||
clauses.append((Event.plus_id.is_null()))
|
||||
elif is_submitted > 0:
|
||||
clauses.append((Event.plus_id != ""))
|
||||
|
||||
if len(clauses) == 0:
|
||||
clauses.append((True))
|
||||
|
||||
@@ -875,9 +1059,10 @@ def events():
|
||||
.order_by(Event.start_time.desc())
|
||||
.limit(limit)
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
return jsonify([e for e in events])
|
||||
return jsonify(list(events))
|
||||
|
||||
|
||||
@bp.route("/events/<camera_name>/<label>/create", methods=["POST"])
|
||||
@@ -912,8 +1097,9 @@ def create_event(camera_name, label):
|
||||
frame,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": f"An unknown error occurred: {e}"}),
|
||||
jsonify({"success": False, "message": "An unknown error occurred"}),
|
||||
500,
|
||||
)
|
||||
|
||||
@@ -986,7 +1172,9 @@ def config_raw():
|
||||
config_file = config_file_yaml
|
||||
|
||||
if not os.path.isfile(config_file):
|
||||
return "Could not find file", 410
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Could not find file"}), 404
|
||||
)
|
||||
|
||||
with open(config_file, "r") as f:
|
||||
raw_config = f.read()
|
||||
@@ -1002,7 +1190,12 @@ def config_save():
|
||||
new_config = request.get_data().decode()
|
||||
|
||||
if not new_config:
|
||||
return "Config with body param is required", 400
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Config with body param is required"}
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
# Validate the config schema
|
||||
try:
|
||||
@@ -1012,7 +1205,7 @@ def config_save():
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"\nConfig Error:\n\n{str(traceback.format_exc())}",
|
||||
"message": f"\nConfig Error:\n\n{escape(str(traceback.format_exc()))}",
|
||||
}
|
||||
),
|
||||
400,
|
||||
@@ -1047,14 +1240,30 @@ def config_save():
|
||||
restart_frigate()
|
||||
except Exception as e:
|
||||
logging.error(f"Error restarting Frigate: {e}")
|
||||
return "Config successfully saved, unable to restart Frigate", 200
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Config successfully saved, unable to restart Frigate",
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
return (
|
||||
"Config successfully saved, restarting (this can take up to one minute)...",
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Config successfully saved, restarting (this can take up to one minute)...",
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
else:
|
||||
return "Config successfully saved.", 200
|
||||
return make_response(
|
||||
jsonify({"success": True, "message": "Config successfully saved."}),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/config/set", methods=["PUT"])
|
||||
@@ -1083,20 +1292,32 @@ def config_set():
|
||||
with open(config_file, "w") as f:
|
||||
f.write(old_raw_config)
|
||||
f.close()
|
||||
logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}")
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"\nConfig Error:\n\n{str(traceback.format_exc())}",
|
||||
"message": "Error parsing config. Check logs for error message.",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Error updating config: {e}")
|
||||
return "Error updating config", 500
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Error updating config"}),
|
||||
500,
|
||||
)
|
||||
|
||||
return "Config successfully updated, restart to apply", 200
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Config successfully updated, restart to apply",
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/config/schema.json")
|
||||
@@ -1146,7 +1367,10 @@ def mjpeg_feed(camera_name):
|
||||
mimetype="multipart/x-mixed-replace; boundary=frame",
|
||||
)
|
||||
else:
|
||||
return "Camera named {} not found".format(camera_name), 404
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Camera not found"}),
|
||||
404,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<camera_name>/ptz/info")
|
||||
@@ -1154,7 +1378,10 @@ def camera_ptz_info(camera_name):
|
||||
if camera_name in current_app.frigate_config.cameras:
|
||||
return jsonify(current_app.onvif.get_camera_info(camera_name))
|
||||
else:
|
||||
return "Camera named {} not found".format(camera_name), 404
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Camera not found"}),
|
||||
404,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<camera_name>/latest.jpg")
|
||||
@@ -1196,7 +1423,10 @@ def latest_frame(camera_name):
|
||||
width = int(height * frame.shape[1] / frame.shape[0])
|
||||
|
||||
if frame is None:
|
||||
return "Unable to get valid frame from {}".format(camera_name), 500
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Unable to get valid frame"}),
|
||||
500,
|
||||
)
|
||||
|
||||
if height < 1 or width < 1:
|
||||
return (
|
||||
@@ -1232,13 +1462,19 @@ def latest_frame(camera_name):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
else:
|
||||
return "Camera named {} not found".format(camera_name), 404
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Camera not found"}),
|
||||
404,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<camera_name>/recordings/<frame_time>/snapshot.png")
|
||||
def get_snapshot_from_recording(camera_name: str, frame_time: str):
|
||||
if camera_name not in current_app.frigate_config.cameras:
|
||||
return "Camera named {} not found".format(camera_name), 404
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Camera not found"}),
|
||||
404,
|
||||
)
|
||||
|
||||
frame_time = float(frame_time)
|
||||
recording_query = (
|
||||
@@ -1247,9 +1483,14 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str):
|
||||
Recordings.start_time,
|
||||
)
|
||||
.where(
|
||||
((frame_time > Recordings.start_time) & (frame_time < Recordings.end_time))
|
||||
(
|
||||
(frame_time >= Recordings.start_time)
|
||||
& (frame_time <= Recordings.end_time)
|
||||
)
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.order_by(Recordings.start_time.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -1282,7 +1523,15 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str):
|
||||
response.headers["Content-Type"] = "image/png"
|
||||
return response
|
||||
except DoesNotExist:
|
||||
return "Recording not found for {} at {}".format(camera_name, frame_time), 404
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Recording not found at {}".format(frame_time),
|
||||
}
|
||||
),
|
||||
404,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/recordings/storage", methods=["GET"])
|
||||
@@ -1315,7 +1564,7 @@ def get_recordings_storage_usage():
|
||||
@bp.route("/<camera_name>/recordings/summary")
|
||||
def recordings_summary(camera_name):
|
||||
tz_name = request.args.get("timezone", default="utc", type=str)
|
||||
hour_modifier, minute_modifier = get_tz_modifiers(tz_name)
|
||||
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
|
||||
recording_groups = (
|
||||
Recordings.select(
|
||||
fn.strftime(
|
||||
@@ -1329,22 +1578,9 @@ def recordings_summary(camera_name):
|
||||
fn.SUM(Recordings.objects).alias("objects"),
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.group_by(
|
||||
fn.strftime(
|
||||
"%Y-%m-%d %H",
|
||||
fn.datetime(
|
||||
Recordings.start_time, "unixepoch", hour_modifier, minute_modifier
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(
|
||||
fn.strftime(
|
||||
"%Y-%m-%d H",
|
||||
fn.datetime(
|
||||
Recordings.start_time, "unixepoch", hour_modifier, minute_modifier
|
||||
),
|
||||
).desc()
|
||||
)
|
||||
.group_by((Recordings.start_time + seconds_offset).cast("int") / 3600)
|
||||
.order_by(Recordings.start_time.desc())
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
event_groups = (
|
||||
@@ -1358,22 +1594,15 @@ def recordings_summary(camera_name):
|
||||
fn.COUNT(Event.id).alias("count"),
|
||||
)
|
||||
.where(Event.camera == camera_name, Event.has_clip)
|
||||
.group_by(
|
||||
fn.strftime(
|
||||
"%Y-%m-%d %H",
|
||||
fn.datetime(
|
||||
Event.start_time, "unixepoch", hour_modifier, minute_modifier
|
||||
),
|
||||
),
|
||||
)
|
||||
.objects()
|
||||
.group_by((Event.start_time + seconds_offset).cast("int") / 3600)
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
event_map = {g.hour: g.count for g in event_groups}
|
||||
|
||||
days = {}
|
||||
|
||||
for recording_group in recording_groups.objects():
|
||||
for recording_group in recording_groups:
|
||||
parts = recording_group.hour.split()
|
||||
hour = parts[1]
|
||||
day = parts[0]
|
||||
@@ -1417,9 +1646,11 @@ def recordings(camera_name):
|
||||
Recordings.start_time <= before,
|
||||
)
|
||||
.order_by(Recordings.start_time)
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
return jsonify([e for e in recordings.dicts()])
|
||||
return jsonify(list(recordings))
|
||||
|
||||
|
||||
@bp.route("/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/clip.mp4")
|
||||
@@ -1453,7 +1684,7 @@ def recording_clip(camera_name, start_ts, end_ts):
|
||||
if clip.end_time > end_ts:
|
||||
playlist_lines.append(f"outpoint {int(end_ts - clip.start_time)}")
|
||||
|
||||
file_name = f"clip_{camera_name}_{start_ts}-{end_ts}.mp4"
|
||||
file_name = secure_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4")
|
||||
path = os.path.join(CACHE_DIR, file_name)
|
||||
|
||||
if not os.path.exists(path):
|
||||
@@ -1484,7 +1715,15 @@ def recording_clip(camera_name, start_ts, end_ts):
|
||||
|
||||
if p.returncode != 0:
|
||||
logger.error(p.stderr)
|
||||
return f"Could not create clip from recordings for {camera_name}.", 500
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Could not create clip from recordings",
|
||||
}
|
||||
),
|
||||
500,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Ignoring subsequent request for {path} as it already exists in the cache."
|
||||
@@ -1516,6 +1755,7 @@ def vod_ts(camera_name, start_ts, end_ts):
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
.iterator()
|
||||
)
|
||||
|
||||
clips = []
|
||||
@@ -1540,7 +1780,15 @@ def vod_ts(camera_name, start_ts, end_ts):
|
||||
|
||||
if not clips:
|
||||
logger.error("No recordings found for the requested time range")
|
||||
return "No recordings found.", 404
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "No recordings found.",
|
||||
}
|
||||
),
|
||||
404,
|
||||
)
|
||||
|
||||
hour_ago = datetime.now() - timedelta(hours=1)
|
||||
return jsonify(
|
||||
@@ -1583,22 +1831,39 @@ def vod_event(id):
|
||||
event: Event = Event.get(Event.id == id)
|
||||
except DoesNotExist:
|
||||
logger.error(f"Event not found: {id}")
|
||||
return "Event not found.", 404
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Event not found.",
|
||||
}
|
||||
),
|
||||
404,
|
||||
)
|
||||
|
||||
if not event.has_clip:
|
||||
logger.error(f"Event does not have recordings: {id}")
|
||||
return "Recordings not available", 404
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Recordings not available.",
|
||||
}
|
||||
),
|
||||
404,
|
||||
)
|
||||
|
||||
clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{id}.mp4")
|
||||
clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.mp4")
|
||||
|
||||
if not os.path.isfile(clip_path):
|
||||
end_ts = (
|
||||
datetime.now().timestamp() if event.end_time is None else event.end_time
|
||||
)
|
||||
vod_response = vod_ts(event.camera, event.start_time, end_ts)
|
||||
# If the recordings are not found, set has_clip to false
|
||||
# If the recordings are not found and the event started more than 5 minutes ago, set has_clip to false
|
||||
if (
|
||||
type(vod_response) == tuple
|
||||
event.start_time < datetime.now().timestamp() - 300
|
||||
and type(vod_response) == tuple
|
||||
and len(vod_response) == 2
|
||||
and vod_response[1] == 404
|
||||
):
|
||||
@@ -1664,12 +1929,21 @@ def export_recording(camera_name: str, start_time, end_time):
|
||||
else PlaybackFactorEnum.realtime,
|
||||
)
|
||||
exporter.start()
|
||||
return "Starting export of recording", 200
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Starting export of recording.",
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/export/<file_name>", methods=["DELETE"])
|
||||
def export_delete(file_name: str):
|
||||
file = os.path.join(EXPORT_DIR, file_name)
|
||||
safe_file_name = secure_filename(file_name)
|
||||
file = os.path.join(EXPORT_DIR, safe_file_name)
|
||||
|
||||
if not os.path.exists(file):
|
||||
return make_response(
|
||||
@@ -1678,7 +1952,15 @@ def export_delete(file_name: str):
|
||||
)
|
||||
|
||||
os.unlink(file)
|
||||
return "Successfully deleted file", 200
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Successfully deleted file.",
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
|
||||
@@ -1778,8 +2060,11 @@ def logs(service: str):
|
||||
}
|
||||
service_location = log_locations.get(service)
|
||||
|
||||
if not service:
|
||||
return f"{service} is not a valid service", 404
|
||||
if not service_location:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Not a valid service"}),
|
||||
404,
|
||||
)
|
||||
|
||||
try:
|
||||
file = open(service_location, "r")
|
||||
@@ -1787,4 +2072,35 @@ def logs(service: str):
|
||||
file.close()
|
||||
return contents, 200
|
||||
except FileNotFoundError as e:
|
||||
return f"Could not find log file: {e}", 500
|
||||
logger.error(e)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Could not find log file"}),
|
||||
500,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/restart", methods=["POST"])
|
||||
def restart():
|
||||
try:
|
||||
restart_frigate()
|
||||
except Exception as e:
|
||||
logging.error(f"Error restarting Frigate: {e}")
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Unable to restart Frigate.",
|
||||
}
|
||||
),
|
||||
500,
|
||||
)
|
||||
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Restarting (this can take up to one minute)...",
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
@@ -57,6 +57,12 @@ class Timeline(Model): # type: ignore[misc]
|
||||
data = JSONField() # ex: tracked object id, region, box, etc.
|
||||
|
||||
|
||||
class Regions(Model): # type: ignore[misc]
|
||||
camera = CharField(null=False, primary_key=True, max_length=20)
|
||||
grid = JSONField() # json blob of grid
|
||||
last_update = DateTimeField()
|
||||
|
||||
|
||||
class Recordings(Model): # type: ignore[misc]
|
||||
id = CharField(null=False, primary_key=True, max_length=30)
|
||||
camera = CharField(index=True, max_length=20)
|
||||
|
||||
@@ -20,3 +20,7 @@ class MotionDetector(ABC):
|
||||
@abstractmethod
|
||||
def detect(self, frame):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_calibrating(self):
|
||||
pass
|
||||
|
||||
@@ -38,6 +38,9 @@ class FrigateMotionDetector(MotionDetector):
|
||||
self.threshold = threshold
|
||||
self.contour_area = contour_area
|
||||
|
||||
def is_calibrating(self):
|
||||
return False
|
||||
|
||||
def detect(self, frame):
|
||||
motion_boxes = []
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
import cv2
|
||||
import imutils
|
||||
import numpy as np
|
||||
@@ -6,6 +8,8 @@ from scipy.ndimage import gaussian_filter
|
||||
from frigate.config import MotionConfig
|
||||
from frigate.motion import MotionDetector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImprovedMotionDetector(MotionDetector):
|
||||
def __init__(
|
||||
@@ -49,6 +53,9 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
self.contrast_values[:, 1:2] = 255
|
||||
self.contrast_values_index = 0
|
||||
|
||||
def is_calibrating(self):
|
||||
return self.calibrating
|
||||
|
||||
def detect(self, frame):
|
||||
motion_boxes = []
|
||||
|
||||
@@ -135,13 +142,12 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
self.motion_frame_size[0] * self.motion_frame_size[1]
|
||||
)
|
||||
|
||||
# once the motion drops to less than 1% for the first time, assume its calibrated
|
||||
if pct_motion < 0.01:
|
||||
# once the motion is less than 5% and the number of contours is < 4, assume its calibrated
|
||||
if pct_motion < 0.05 and len(motion_boxes) <= 4:
|
||||
self.calibrating = False
|
||||
|
||||
# if calibrating or the motion contours are > 80% of the image area (lightning, ir, ptz) recalibrate
|
||||
if self.calibrating or pct_motion > self.config.lightning_threshold:
|
||||
motion_boxes = []
|
||||
self.calibrating = True
|
||||
|
||||
if self.save_images:
|
||||
|
||||
@@ -105,6 +105,10 @@ class TrackedObject:
|
||||
def __init__(
|
||||
self, camera, colormap, camera_config: CameraConfig, frame_cache, obj_data
|
||||
):
|
||||
# set the score history then remove as it is not part of object state
|
||||
self.score_history = obj_data["score_history"]
|
||||
del obj_data["score_history"]
|
||||
|
||||
self.obj_data = obj_data
|
||||
self.camera = camera
|
||||
self.colormap = colormap
|
||||
@@ -136,11 +140,8 @@ class TrackedObject:
|
||||
return self.computed_score < threshold
|
||||
|
||||
def compute_score(self):
|
||||
scores = self.score_history[:]
|
||||
# pad with zeros if you dont have at least 3 scores
|
||||
if len(scores) < 3:
|
||||
scores += [0.0] * (3 - len(scores))
|
||||
return median(scores)
|
||||
"""get median of scores for object."""
|
||||
return median(self.score_history)
|
||||
|
||||
def update(self, current_frame_time, obj_data):
|
||||
thumb_update = False
|
||||
@@ -151,6 +152,7 @@ class TrackedObject:
|
||||
self.score_history.append(0.0)
|
||||
else:
|
||||
self.score_history.append(obj_data["score"])
|
||||
|
||||
# only keep the last 10 scores
|
||||
if len(self.score_history) > 10:
|
||||
self.score_history = self.score_history[-10:]
|
||||
@@ -232,6 +234,9 @@ class TrackedObject:
|
||||
if self.obj_data["position_changes"] != obj_data["position_changes"]:
|
||||
significant_change = True
|
||||
|
||||
if self.obj_data["attributes"] != obj_data["attributes"]:
|
||||
significant_change = True
|
||||
|
||||
# if the motionless_count reaches the stationary threshold
|
||||
if (
|
||||
self.obj_data["motionless_count"]
|
||||
@@ -243,10 +248,8 @@ class TrackedObject:
|
||||
if self.obj_data["frame_time"] - self.previous["frame_time"] > 60:
|
||||
significant_change = True
|
||||
|
||||
# update autotrack at half fps
|
||||
if self.obj_data["frame_time"] - self.previous["frame_time"] > (
|
||||
1 / (self.camera_config.detect.fps / 2)
|
||||
):
|
||||
# update autotrack at most 3 objects per second
|
||||
if self.obj_data["frame_time"] - self.previous["frame_time"] >= (1 / 3):
|
||||
autotracker_update = True
|
||||
|
||||
self.obj_data.update(obj_data)
|
||||
@@ -496,6 +499,9 @@ class CameraState:
|
||||
# draw thicker box around ptz autotracked object
|
||||
if (
|
||||
self.camera_config.onvif.autotracking.enabled
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init[
|
||||
self.name
|
||||
]
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||
self.name
|
||||
]
|
||||
@@ -504,6 +510,7 @@ class CameraState:
|
||||
== self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||
self.name
|
||||
].obj_data["id"]
|
||||
and obj["frame_time"] == frame_time
|
||||
):
|
||||
thickness = 5
|
||||
color = self.config.model.colormap[obj["label"]]
|
||||
|
||||
@@ -20,10 +20,11 @@ from ws4py.server.wsgirefserver import (
|
||||
WSGIServer,
|
||||
)
|
||||
from ws4py.server.wsgiutils import WebSocketWSGIApplication
|
||||
from ws4py.websocket import WebSocket
|
||||
|
||||
from frigate.comms.ws import WebSocket
|
||||
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
||||
from frigate.const import BASE_DIR, BIRDSEYE_PIPE
|
||||
from frigate.types import CameraMetricsTypes
|
||||
from frigate.util.image import (
|
||||
SharedMemoryFrameManager,
|
||||
copy_yuv_to_position,
|
||||
@@ -33,12 +34,15 @@ from frigate.util.image import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_standard_aspect_ratio(width, height) -> tuple[int, int]:
|
||||
def get_standard_aspect_ratio(width: int, height: int) -> tuple[int, int]:
|
||||
"""Ensure that only standard aspect ratios are used."""
|
||||
# it is imoprtant that all ratios have the same scale
|
||||
known_aspects = [
|
||||
(16, 9),
|
||||
(9, 16),
|
||||
(32, 9),
|
||||
(20, 10),
|
||||
(16, 6), # reolink duo 2
|
||||
(32, 9), # panoramic cameras
|
||||
(12, 9),
|
||||
(9, 12),
|
||||
] # aspects are scaled to have common relative size
|
||||
@@ -52,6 +56,22 @@ def get_standard_aspect_ratio(width, height) -> tuple[int, int]:
|
||||
return known_aspects[known_aspects_ratios.index(closest)]
|
||||
|
||||
|
||||
def get_canvas_shape(width: int, height: int) -> tuple[int, int]:
|
||||
"""Get birdseye canvas shape."""
|
||||
canvas_width = width
|
||||
canvas_height = height
|
||||
a_w, a_h = get_standard_aspect_ratio(width, height)
|
||||
|
||||
if round(a_w / a_h, 2) != round(width / height, 2):
|
||||
canvas_width = int(width // 4 * 4)
|
||||
canvas_height = int((canvas_width / a_w * a_h) // 4 * 4)
|
||||
logger.warning(
|
||||
f"The birdseye resolution is a non-standard aspect ratio, forcing birdseye resolution to {canvas_width} x {canvas_height}"
|
||||
)
|
||||
|
||||
return (canvas_width, canvas_height)
|
||||
|
||||
|
||||
class Canvas:
|
||||
def __init__(self, canvas_width: int, canvas_height: int) -> None:
|
||||
gcd = math.gcd(canvas_width, canvas_height)
|
||||
@@ -88,9 +108,12 @@ class Canvas:
|
||||
return camera_aspect
|
||||
|
||||
|
||||
class FFMpegConverter:
|
||||
class FFMpegConverter(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
camera: str,
|
||||
input_queue: queue.Queue,
|
||||
stop_event: mp.Event,
|
||||
in_width: int,
|
||||
in_height: int,
|
||||
out_width: int,
|
||||
@@ -98,6 +121,11 @@ class FFMpegConverter:
|
||||
quality: int,
|
||||
birdseye_rtsp: bool = False,
|
||||
):
|
||||
threading.Thread.__init__(self)
|
||||
self.name = f"{camera}_output_converter"
|
||||
self.camera = camera
|
||||
self.input_queue = input_queue
|
||||
self.stop_event = stop_event
|
||||
self.bd_pipe = None
|
||||
|
||||
if birdseye_rtsp:
|
||||
@@ -147,7 +175,7 @@ class FFMpegConverter:
|
||||
os.close(stdin)
|
||||
self.reading_birdseye = False
|
||||
|
||||
def write(self, b) -> None:
|
||||
def __write(self, b) -> None:
|
||||
self.process.stdin.write(b)
|
||||
|
||||
if self.bd_pipe:
|
||||
@@ -183,9 +211,25 @@ class FFMpegConverter:
|
||||
self.process.kill()
|
||||
self.process.communicate()
|
||||
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
frame = self.input_queue.get(True, timeout=1)
|
||||
self.__write(frame)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
self.exit()
|
||||
|
||||
|
||||
class BroadcastThread(threading.Thread):
|
||||
def __init__(self, camera, converter, websocket_server, stop_event):
|
||||
def __init__(
|
||||
self,
|
||||
camera: str,
|
||||
converter: FFMpegConverter,
|
||||
websocket_server,
|
||||
stop_event: mp.Event,
|
||||
):
|
||||
super(BroadcastThread, self).__init__()
|
||||
self.camera = camera
|
||||
self.converter = converter
|
||||
@@ -222,17 +266,18 @@ class BirdsEyeFrameManager:
|
||||
config: FrigateConfig,
|
||||
frame_manager: SharedMemoryFrameManager,
|
||||
stop_event: mp.Event,
|
||||
camera_metrics: dict[str, CameraMetricsTypes],
|
||||
):
|
||||
self.config = config
|
||||
self.mode = config.birdseye.mode
|
||||
self.frame_manager = frame_manager
|
||||
width = config.birdseye.width
|
||||
height = config.birdseye.height
|
||||
width, height = get_canvas_shape(config.birdseye.width, config.birdseye.height)
|
||||
self.frame_shape = (height, width)
|
||||
self.yuv_shape = (height * 3 // 2, width)
|
||||
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
|
||||
self.canvas = Canvas(width, height)
|
||||
self.stop_event = stop_event
|
||||
self.camera_metrics = camera_metrics
|
||||
|
||||
# initialize the frame as black and with the Frigate logo
|
||||
self.blank_frame = np.zeros(self.yuv_shape, np.uint8)
|
||||
@@ -442,7 +487,7 @@ class BirdsEyeFrameManager:
|
||||
def calculate_layout(self, cameras_to_add: list[str], coefficient) -> tuple[any]:
|
||||
"""Calculate the optimal layout for 2+ cameras."""
|
||||
|
||||
def map_layout(row_height: int):
|
||||
def map_layout(camera_layout: list[list[any]], row_height: int):
|
||||
"""Map the calculated layout."""
|
||||
candidate_layout = []
|
||||
starting_x = 0
|
||||
@@ -471,7 +516,7 @@ class BirdsEyeFrameManager:
|
||||
x + scaled_width > self.canvas.width
|
||||
or y + scaled_height > self.canvas.height
|
||||
):
|
||||
return 0, 0, None
|
||||
return x + scaled_width, y + scaled_height, None
|
||||
|
||||
final_row.append((cameras[0], (x, y, scaled_width, scaled_height)))
|
||||
x += scaled_width
|
||||
@@ -479,6 +524,9 @@ class BirdsEyeFrameManager:
|
||||
y += row_height
|
||||
candidate_layout.append(final_row)
|
||||
|
||||
if max_width == 0:
|
||||
max_width = x
|
||||
|
||||
return max_width, y, candidate_layout
|
||||
|
||||
canvas_aspect_x, canvas_aspect_y = self.canvas.get_aspect(coefficient)
|
||||
@@ -540,18 +588,35 @@ class BirdsEyeFrameManager:
|
||||
return None
|
||||
|
||||
row_height = int(self.canvas.height / coefficient)
|
||||
total_width, total_height, standard_candidate_layout = map_layout(row_height)
|
||||
total_width, total_height, standard_candidate_layout = map_layout(
|
||||
camera_layout, row_height
|
||||
)
|
||||
|
||||
if not standard_candidate_layout:
|
||||
# if standard layout didn't work
|
||||
# try reducing row_height by the % overflow
|
||||
scale_down_percent = max(
|
||||
total_width / self.canvas.width,
|
||||
total_height / self.canvas.height,
|
||||
)
|
||||
row_height = int(row_height / scale_down_percent)
|
||||
total_width, total_height, standard_candidate_layout = map_layout(
|
||||
camera_layout, row_height
|
||||
)
|
||||
|
||||
if not standard_candidate_layout:
|
||||
return None
|
||||
|
||||
# layout can't be optimized more
|
||||
if total_width / self.canvas.width >= 0.99:
|
||||
return standard_candidate_layout
|
||||
|
||||
scale_up_percent = min(
|
||||
1 - (total_width / self.canvas.width),
|
||||
1 - (total_height / self.canvas.height),
|
||||
1 / (total_width / self.canvas.width),
|
||||
1 / (total_height / self.canvas.height),
|
||||
)
|
||||
row_height = int(row_height * (1 + round(scale_up_percent, 1)))
|
||||
_, _, scaled_layout = map_layout(row_height)
|
||||
row_height = int(row_height * scale_up_percent)
|
||||
_, _, scaled_layout = map_layout(camera_layout, row_height)
|
||||
|
||||
if scaled_layout:
|
||||
return scaled_layout
|
||||
@@ -564,9 +629,25 @@ class BirdsEyeFrameManager:
|
||||
if not camera_config.enabled:
|
||||
return False
|
||||
|
||||
# get our metrics (sync'd across processes)
|
||||
# which allows us to control it via mqtt (or any other dispatcher)
|
||||
camera_metrics = self.camera_metrics[camera]
|
||||
|
||||
# disabling birdseye is a little tricky
|
||||
if not camera_metrics["birdseye_enabled"].value:
|
||||
# if we've rendered a frame (we have a value for last_active_frame)
|
||||
# then we need to set it to zero
|
||||
if self.cameras[camera]["last_active_frame"] > 0:
|
||||
self.cameras[camera]["last_active_frame"] = 0
|
||||
|
||||
return False
|
||||
|
||||
# get the birdseye mode state from camera metrics
|
||||
birdseye_mode = BirdseyeModeEnum.get(camera_metrics["birdseye_mode"].value)
|
||||
|
||||
# update the last active frame for the camera
|
||||
self.cameras[camera]["current_frame"] = frame_time
|
||||
if self.camera_active(camera_config.mode, object_count, motion_count):
|
||||
if self.camera_active(birdseye_mode, object_count, motion_count):
|
||||
self.cameras[camera]["last_active_frame"] = frame_time
|
||||
|
||||
now = datetime.datetime.now().timestamp()
|
||||
@@ -590,7 +671,11 @@ class BirdsEyeFrameManager:
|
||||
return False
|
||||
|
||||
|
||||
def output_frames(config: FrigateConfig, video_output_queue):
|
||||
def output_frames(
|
||||
config: FrigateConfig,
|
||||
video_output_queue,
|
||||
camera_metrics: dict[str, CameraMetricsTypes],
|
||||
):
|
||||
threading.current_thread().name = "output"
|
||||
setproctitle("frigate.output")
|
||||
|
||||
@@ -617,15 +702,20 @@ def output_frames(config: FrigateConfig, video_output_queue):
|
||||
websocket_server.initialize_websockets_manager()
|
||||
websocket_thread = threading.Thread(target=websocket_server.serve_forever)
|
||||
|
||||
inputs: dict[str, queue.Queue] = {}
|
||||
converters = {}
|
||||
broadcasters = {}
|
||||
|
||||
for camera, cam_config in config.cameras.items():
|
||||
inputs[camera] = queue.Queue(maxsize=cam_config.detect.fps)
|
||||
width = int(
|
||||
cam_config.live.height
|
||||
* (cam_config.frame_shape[1] / cam_config.frame_shape[0])
|
||||
)
|
||||
converters[camera] = FFMpegConverter(
|
||||
camera,
|
||||
inputs[camera],
|
||||
stop_event,
|
||||
cam_config.frame_shape[1],
|
||||
cam_config.frame_shape[0],
|
||||
width,
|
||||
@@ -637,7 +727,11 @@ def output_frames(config: FrigateConfig, video_output_queue):
|
||||
)
|
||||
|
||||
if config.birdseye.enabled:
|
||||
inputs["birdseye"] = queue.Queue(maxsize=10)
|
||||
converters["birdseye"] = FFMpegConverter(
|
||||
"birdseye",
|
||||
inputs["birdseye"],
|
||||
stop_event,
|
||||
config.birdseye.width,
|
||||
config.birdseye.height,
|
||||
config.birdseye.width,
|
||||
@@ -646,15 +740,23 @@ def output_frames(config: FrigateConfig, video_output_queue):
|
||||
config.birdseye.restream,
|
||||
)
|
||||
broadcasters["birdseye"] = BroadcastThread(
|
||||
"birdseye", converters["birdseye"], websocket_server, stop_event
|
||||
"birdseye",
|
||||
converters["birdseye"],
|
||||
websocket_server,
|
||||
stop_event,
|
||||
)
|
||||
|
||||
websocket_thread.start()
|
||||
|
||||
for t in converters.values():
|
||||
t.start()
|
||||
|
||||
for t in broadcasters.values():
|
||||
t.start()
|
||||
|
||||
birdseye_manager = BirdsEyeFrameManager(config, frame_manager, stop_event)
|
||||
birdseye_manager = BirdsEyeFrameManager(
|
||||
config, frame_manager, stop_event, camera_metrics
|
||||
)
|
||||
|
||||
if config.birdseye.restream:
|
||||
birdseye_buffer = frame_manager.create(
|
||||
@@ -683,7 +785,11 @@ def output_frames(config: FrigateConfig, video_output_queue):
|
||||
ws.environ["PATH_INFO"].endswith(camera) for ws in websocket_server.manager
|
||||
):
|
||||
# write to the converter for the camera if clients are listening to the specific camera
|
||||
converters[camera].write(frame.tobytes())
|
||||
try:
|
||||
inputs[camera].put_nowait(frame.tobytes())
|
||||
except queue.Full:
|
||||
# drop frames if queue is full
|
||||
pass
|
||||
|
||||
if config.birdseye.enabled and (
|
||||
config.birdseye.restream
|
||||
@@ -704,7 +810,11 @@ def output_frames(config: FrigateConfig, video_output_queue):
|
||||
if config.birdseye.restream:
|
||||
birdseye_buffer[:] = frame_bytes
|
||||
|
||||
converters["birdseye"].write(frame_bytes)
|
||||
try:
|
||||
inputs["birdseye"].put_nowait(frame_bytes)
|
||||
except queue.Full:
|
||||
# drop frames if queue is full
|
||||
pass
|
||||
|
||||
if camera in previous_frames:
|
||||
frame_manager.delete(f"{camera}{previous_frames[camera]}")
|
||||
@@ -724,10 +834,9 @@ def output_frames(config: FrigateConfig, video_output_queue):
|
||||
frame = frame_manager.get(frame_id, config.cameras[camera].frame_shape_yuv)
|
||||
frame_manager.delete(frame_id)
|
||||
|
||||
for c in converters.values():
|
||||
c.exit()
|
||||
for b in broadcasters.values():
|
||||
b.join()
|
||||
|
||||
websocket_server.manager.close_all()
|
||||
websocket_server.manager.stop()
|
||||
websocket_server.manager.join()
|
||||
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
import cv2
|
||||
@@ -36,6 +37,10 @@ class PlusApi:
|
||||
self.key = None
|
||||
if PLUS_ENV_VAR in os.environ:
|
||||
self.key = os.environ.get(PLUS_ENV_VAR)
|
||||
elif os.path.isdir("/run/secrets") and PLUS_ENV_VAR in os.listdir(
|
||||
"/run/secrets"
|
||||
):
|
||||
self.key = Path(os.path.join("/run/secrets", PLUS_ENV_VAR)).read_text()
|
||||
# check for the addon options file
|
||||
elif os.path.isfile("/data/options.json"):
|
||||
with open("/data/options.json") as f:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
"""Configure and control camera via onvif."""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import site
|
||||
from enum import Enum
|
||||
@@ -8,8 +7,9 @@ from enum import Enum
|
||||
import numpy
|
||||
from onvif import ONVIFCamera, ONVIFError
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config import FrigateConfig, ZoomingModeEnum
|
||||
from frigate.types import PTZMetricsTypes
|
||||
from frigate.util.builtin import find_by_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,6 +33,7 @@ class OnvifController:
|
||||
self, config: FrigateConfig, ptz_metrics: dict[str, PTZMetricsTypes]
|
||||
) -> None:
|
||||
self.cams: dict[str, ONVIFCamera] = {}
|
||||
self.config = config
|
||||
self.ptz_metrics = ptz_metrics
|
||||
|
||||
for cam_name, cam in config.cameras.items():
|
||||
@@ -73,11 +74,21 @@ class OnvifController:
|
||||
return False
|
||||
|
||||
ptz = onvif.create_ptz_service()
|
||||
|
||||
request = ptz.create_type("GetConfigurations")
|
||||
configs = ptz.GetConfigurations(request)[0]
|
||||
logger.debug(f"Onvif configs for {camera_name}: {configs}")
|
||||
|
||||
request = ptz.create_type("GetConfigurationOptions")
|
||||
request.ConfigurationToken = profile.PTZConfiguration.token
|
||||
ptz_config = ptz.GetConfigurationOptions(request)
|
||||
logger.debug(f"Onvif config for {camera_name}: {ptz_config}")
|
||||
|
||||
service_capabilities_request = ptz.create_type("GetServiceCapabilities")
|
||||
self.cams[camera_name][
|
||||
"service_capabilities_request"
|
||||
] = service_capabilities_request
|
||||
|
||||
fov_space_id = next(
|
||||
(
|
||||
i
|
||||
@@ -89,6 +100,31 @@ class OnvifController:
|
||||
None,
|
||||
)
|
||||
|
||||
# status request for autotracking and filling ptz-parameters
|
||||
status_request = ptz.create_type("GetStatus")
|
||||
status_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["status_request"] = status_request
|
||||
try:
|
||||
status = ptz.GetStatus(status_request)
|
||||
logger.debug(f"Onvif status config for {camera_name}: {status}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Unable to get status from camera: {camera_name}: {e}")
|
||||
status = None
|
||||
|
||||
# autoracking relative panning/tilting needs a relative zoom value set to 0
|
||||
# if camera supports relative movement
|
||||
if self.config.cameras[camera_name].onvif.autotracking.zooming:
|
||||
zoom_space_id = next(
|
||||
(
|
||||
i
|
||||
for i, space in enumerate(
|
||||
ptz_config.Spaces.RelativeZoomTranslationSpace
|
||||
)
|
||||
if "TranslationGenericSpace" in space["URI"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# setup continuous moving request
|
||||
move_request = ptz.create_type("ContinuousMove")
|
||||
move_request.ProfileToken = profile.token
|
||||
@@ -98,35 +134,42 @@ class OnvifController:
|
||||
move_request = ptz.create_type("RelativeMove")
|
||||
move_request.ProfileToken = profile.token
|
||||
if move_request.Translation is None and fov_space_id is not None:
|
||||
move_request.Translation = ptz.GetStatus(
|
||||
{"ProfileToken": profile.token}
|
||||
).Position
|
||||
move_request.Translation = status.Position
|
||||
move_request.Translation.PanTilt.space = ptz_config["Spaces"][
|
||||
"RelativePanTiltTranslationSpace"
|
||||
][fov_space_id]["URI"]
|
||||
|
||||
# try setting relative zoom translation space
|
||||
try:
|
||||
move_request.Translation.Zoom.space = ptz_config["Spaces"][
|
||||
"RelativeZoomTranslationSpace"
|
||||
][0]["URI"]
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
== ZoomingModeEnum.relative
|
||||
):
|
||||
if zoom_space_id is not None:
|
||||
move_request.Translation.Zoom.space = ptz_config["Spaces"][
|
||||
"RelativeZoomTranslationSpace"
|
||||
][0]["URI"]
|
||||
except Exception:
|
||||
# camera does not support relative zoom
|
||||
pass
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
== ZoomingModeEnum.relative
|
||||
):
|
||||
self.config.cameras[
|
||||
camera_name
|
||||
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported"
|
||||
)
|
||||
|
||||
if move_request.Speed is None:
|
||||
move_request.Speed = ptz.GetStatus({"ProfileToken": profile.token}).Position
|
||||
move_request.Speed = status.Position if status else None
|
||||
self.cams[camera_name]["relative_move_request"] = move_request
|
||||
|
||||
# setup relative moving request for autotracking
|
||||
# setup absolute moving request for autotracking zooming
|
||||
move_request = ptz.create_type("AbsoluteMove")
|
||||
move_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["absolute_move_request"] = move_request
|
||||
|
||||
# status request for autotracking
|
||||
status_request = ptz.create_type("GetStatus")
|
||||
status_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["status_request"] = status_request
|
||||
|
||||
# setup existing presets
|
||||
try:
|
||||
presets: list[dict] = ptz.GetPresets({"ProfileToken": profile.token})
|
||||
@@ -135,7 +178,9 @@ class OnvifController:
|
||||
presets = []
|
||||
|
||||
for preset in presets:
|
||||
self.cams[camera_name]["presets"][preset["Name"].lower()] = preset["token"]
|
||||
self.cams[camera_name]["presets"][
|
||||
(getattr(preset, "Name") or f"preset {preset['token']}").lower()
|
||||
] = preset["token"]
|
||||
|
||||
# get list of supported features
|
||||
ptz_config = ptz.GetConfigurationOptions(request)
|
||||
@@ -152,15 +197,43 @@ class OnvifController:
|
||||
|
||||
if ptz_config.Spaces and ptz_config.Spaces.RelativeZoomTranslationSpace:
|
||||
supported_features.append("zoom-r")
|
||||
try:
|
||||
# get camera's zoom limits from onvif config
|
||||
self.cams[camera_name][
|
||||
"relative_zoom_range"
|
||||
] = ptz_config.Spaces.RelativeZoomTranslationSpace[0]
|
||||
except Exception:
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
== ZoomingModeEnum.relative
|
||||
):
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming = False
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported"
|
||||
)
|
||||
|
||||
if ptz_config.Spaces and ptz_config.Spaces.AbsoluteZoomPositionSpace:
|
||||
supported_features.append("zoom-a")
|
||||
try:
|
||||
# get camera's zoom limits from onvif config
|
||||
self.cams[camera_name][
|
||||
"absolute_zoom_range"
|
||||
] = ptz_config.Spaces.AbsoluteZoomPositionSpace[0]
|
||||
self.cams[camera_name]["zoom_limits"] = configs.ZoomLimits
|
||||
except Exception:
|
||||
if self.config.cameras[camera_name].onvif.autotracking.zooming:
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming = False
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported"
|
||||
)
|
||||
|
||||
# set relative pan/tilt space for autotracker
|
||||
if fov_space_id is not None:
|
||||
supported_features.append("pt-r-fov")
|
||||
self.cams[camera_name][
|
||||
"relative_fov_range"
|
||||
] = ptz_config.Spaces.RelativePanTiltTranslationSpace[fov_space_id]
|
||||
|
||||
self.cams[camera_name]["relative_fov_supported"] = fov_space_id is not None
|
||||
|
||||
self.cams[camera_name]["features"] = supported_features
|
||||
|
||||
self.cams[camera_name]["init"] = True
|
||||
@@ -210,12 +283,14 @@ class OnvifController:
|
||||
|
||||
onvif.get_service("ptz").ContinuousMove(move_request)
|
||||
|
||||
def _move_relative(self, camera_name: str, pan, tilt, speed) -> None:
|
||||
if not self.cams[camera_name]["relative_fov_supported"]:
|
||||
def _move_relative(self, camera_name: str, pan, tilt, zoom, speed) -> None:
|
||||
if "pt-r-fov" not in self.cams[camera_name]["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
|
||||
return
|
||||
|
||||
logger.debug(f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt}")
|
||||
logger.debug(
|
||||
f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt} zoom: {zoom}"
|
||||
)
|
||||
|
||||
if self.cams[camera_name]["active"]:
|
||||
logger.warning(
|
||||
@@ -224,11 +299,13 @@ class OnvifController:
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
self.ptz_metrics[camera_name]["ptz_stopped"].clear()
|
||||
logger.debug(f"PTZ start time: {datetime.datetime.now().timestamp()}")
|
||||
self.ptz_metrics[camera_name][
|
||||
"ptz_start_time"
|
||||
].value = datetime.datetime.now().timestamp()
|
||||
self.ptz_metrics[camera_name]["ptz_motor_stopped"].clear()
|
||||
logger.debug(
|
||||
f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name]['ptz_frame_time'].value}"
|
||||
)
|
||||
self.ptz_metrics[camera_name]["ptz_start_time"].value = self.ptz_metrics[
|
||||
camera_name
|
||||
]["ptz_frame_time"].value
|
||||
self.ptz_metrics[camera_name]["ptz_stop_time"].value = 0
|
||||
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
||||
move_request = self.cams[camera_name]["relative_move_request"]
|
||||
@@ -257,15 +334,30 @@ class OnvifController:
|
||||
"x": speed,
|
||||
"y": speed,
|
||||
},
|
||||
"Zoom": 0,
|
||||
}
|
||||
|
||||
move_request.Translation.PanTilt.x = pan
|
||||
move_request.Translation.PanTilt.y = tilt
|
||||
move_request.Translation.Zoom.x = 0
|
||||
|
||||
if "zoom-r" in self.cams[camera_name]["features"]:
|
||||
move_request.Speed = {
|
||||
"PanTilt": {
|
||||
"x": speed,
|
||||
"y": speed,
|
||||
},
|
||||
"Zoom": {"x": speed},
|
||||
}
|
||||
move_request.Translation.Zoom.x = zoom
|
||||
|
||||
onvif.get_service("ptz").RelativeMove(move_request)
|
||||
|
||||
# reset after the move request
|
||||
move_request.Translation.PanTilt.x = 0
|
||||
move_request.Translation.PanTilt.y = 0
|
||||
|
||||
if "zoom-r" in self.cams[camera_name]["features"]:
|
||||
move_request.Translation.Zoom.x = 0
|
||||
|
||||
self.cams[camera_name]["active"] = False
|
||||
|
||||
def _move_to_preset(self, camera_name: str, preset: str) -> None:
|
||||
@@ -274,7 +366,9 @@ class OnvifController:
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
self.ptz_metrics[camera_name]["ptz_stopped"].clear()
|
||||
self.ptz_metrics[camera_name]["ptz_motor_stopped"].clear()
|
||||
self.ptz_metrics[camera_name]["ptz_start_time"].value = 0
|
||||
self.ptz_metrics[camera_name]["ptz_stop_time"].value = 0
|
||||
move_request = self.cams[camera_name]["move_request"]
|
||||
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
||||
preset_token = self.cams[camera_name]["presets"][preset]
|
||||
@@ -284,7 +378,7 @@ class OnvifController:
|
||||
"PresetToken": preset_token,
|
||||
}
|
||||
)
|
||||
self.ptz_metrics[camera_name]["ptz_stopped"].set()
|
||||
|
||||
self.cams[camera_name]["active"] = False
|
||||
|
||||
def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||
@@ -305,6 +399,50 @@ class OnvifController:
|
||||
|
||||
onvif.get_service("ptz").ContinuousMove(move_request)
|
||||
|
||||
def _zoom_absolute(self, camera_name: str, zoom, speed) -> None:
|
||||
if "zoom-a" not in self.cams[camera_name]["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF AbsoluteMove zooming.")
|
||||
return
|
||||
|
||||
logger.debug(f"{camera_name} called AbsoluteMove: zoom: {zoom}")
|
||||
|
||||
if self.cams[camera_name]["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, not moving..."
|
||||
)
|
||||
return
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
self.ptz_metrics[camera_name]["ptz_motor_stopped"].clear()
|
||||
logger.debug(
|
||||
f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name]['ptz_frame_time'].value}"
|
||||
)
|
||||
self.ptz_metrics[camera_name]["ptz_start_time"].value = self.ptz_metrics[
|
||||
camera_name
|
||||
]["ptz_frame_time"].value
|
||||
self.ptz_metrics[camera_name]["ptz_stop_time"].value = 0
|
||||
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
||||
move_request = self.cams[camera_name]["absolute_move_request"]
|
||||
|
||||
# function takes in 0 to 1 for zoom, interpolate to the values of the camera.
|
||||
zoom = numpy.interp(
|
||||
zoom,
|
||||
[0, 1],
|
||||
[
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
|
||||
],
|
||||
)
|
||||
|
||||
move_request.Speed = {"Zoom": speed}
|
||||
move_request.Position = {"Zoom": zoom}
|
||||
|
||||
logger.debug(f"{camera_name}: Absolute zoom: {zoom}")
|
||||
|
||||
onvif.get_service("ptz").AbsoluteMove(move_request)
|
||||
|
||||
self.cams[camera_name]["active"] = False
|
||||
|
||||
def handle_command(
|
||||
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
|
||||
) -> None:
|
||||
@@ -344,7 +482,30 @@ class OnvifController:
|
||||
"presets": list(self.cams[camera_name]["presets"].keys()),
|
||||
}
|
||||
|
||||
def get_camera_status(self, camera_name: str) -> dict[str, any]:
|
||||
def get_service_capabilities(self, camera_name: str) -> None:
|
||||
if camera_name not in self.cams.keys():
|
||||
logger.error(f"Onvif is not setup for {camera_name}")
|
||||
return {}
|
||||
|
||||
if not self.cams[camera_name]["init"]:
|
||||
self._init_onvif(camera_name)
|
||||
|
||||
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
||||
service_capabilities_request = self.cams[camera_name][
|
||||
"service_capabilities_request"
|
||||
]
|
||||
service_capabilities = onvif.get_service("ptz").GetServiceCapabilities(
|
||||
service_capabilities_request
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Onvif service capabilities for {camera_name}: {service_capabilities}"
|
||||
)
|
||||
|
||||
# MoveStatus is required for autotracking - should return "true" if supported
|
||||
return find_by_key(vars(service_capabilities), "MoveStatus")
|
||||
|
||||
def get_camera_status(self, camera_name: str) -> None:
|
||||
if camera_name not in self.cams.keys():
|
||||
logger.error(f"Onvif is not setup for {camera_name}")
|
||||
return {}
|
||||
@@ -354,34 +515,89 @@ class OnvifController:
|
||||
|
||||
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
||||
status_request = self.cams[camera_name]["status_request"]
|
||||
status = onvif.get_service("ptz").GetStatus(status_request)
|
||||
try:
|
||||
status = onvif.get_service("ptz").GetStatus(status_request)
|
||||
except Exception:
|
||||
pass # We're unsupported, that'll be reported in the next check.
|
||||
|
||||
if status.MoveStatus.PanTilt == "IDLE" and status.MoveStatus.Zoom == "IDLE":
|
||||
# there doesn't seem to be an onvif standard with this optional parameter
|
||||
# some cameras can report MoveStatus with or without PanTilt or Zoom attributes
|
||||
pan_tilt_status = getattr(status.MoveStatus, "PanTilt", None)
|
||||
zoom_status = getattr(status.MoveStatus, "Zoom", None)
|
||||
|
||||
# if it's not an attribute, see if MoveStatus even exists in the status result
|
||||
if pan_tilt_status is None:
|
||||
pan_tilt_status = getattr(status, "MoveStatus", None)
|
||||
|
||||
# we're unsupported
|
||||
if pan_tilt_status is None or pan_tilt_status.lower() not in [
|
||||
"idle",
|
||||
"moving",
|
||||
]:
|
||||
logger.error(
|
||||
f"Camera {camera_name} does not support the ONVIF GetStatus method. Autotracking will not function correctly and must be disabled in your config."
|
||||
)
|
||||
return
|
||||
|
||||
if pan_tilt_status.lower() == "idle" and (
|
||||
zoom_status is None or zoom_status.lower() == "idle"
|
||||
):
|
||||
self.cams[camera_name]["active"] = False
|
||||
if not self.ptz_metrics[camera_name]["ptz_stopped"].is_set():
|
||||
self.ptz_metrics[camera_name]["ptz_stopped"].set()
|
||||
if not self.ptz_metrics[camera_name]["ptz_motor_stopped"].is_set():
|
||||
self.ptz_metrics[camera_name]["ptz_motor_stopped"].set()
|
||||
|
||||
logger.debug(f"PTZ stop time: {datetime.datetime.now().timestamp()}")
|
||||
logger.debug(
|
||||
f"{camera_name}: PTZ stop time: {self.ptz_metrics[camera_name]['ptz_frame_time'].value}"
|
||||
)
|
||||
|
||||
self.ptz_metrics[camera_name][
|
||||
"ptz_stop_time"
|
||||
].value = datetime.datetime.now().timestamp()
|
||||
self.ptz_metrics[camera_name]["ptz_stop_time"].value = self.ptz_metrics[
|
||||
camera_name
|
||||
]["ptz_frame_time"].value
|
||||
else:
|
||||
self.cams[camera_name]["active"] = True
|
||||
if self.ptz_metrics[camera_name]["ptz_stopped"].is_set():
|
||||
self.ptz_metrics[camera_name]["ptz_stopped"].clear()
|
||||
if self.ptz_metrics[camera_name]["ptz_motor_stopped"].is_set():
|
||||
self.ptz_metrics[camera_name]["ptz_motor_stopped"].clear()
|
||||
|
||||
logger.debug(f"PTZ start time: {datetime.datetime.now().timestamp()}")
|
||||
logger.debug(
|
||||
f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name]['ptz_frame_time'].value}"
|
||||
)
|
||||
|
||||
self.ptz_metrics[camera_name][
|
||||
"ptz_start_time"
|
||||
].value = datetime.datetime.now().timestamp()
|
||||
].value = self.ptz_metrics[camera_name]["ptz_frame_time"].value
|
||||
self.ptz_metrics[camera_name]["ptz_stop_time"].value = 0
|
||||
|
||||
return {
|
||||
"pan": status.Position.PanTilt.x,
|
||||
"tilt": status.Position.PanTilt.y,
|
||||
"zoom": status.Position.Zoom.x,
|
||||
"pantilt_moving": status.MoveStatus.PanTilt,
|
||||
"zoom_moving": status.MoveStatus.Zoom,
|
||||
}
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
!= ZoomingModeEnum.disabled
|
||||
):
|
||||
# store absolute zoom level as 0 to 1 interpolated from the values of the camera
|
||||
self.ptz_metrics[camera_name]["ptz_zoom_level"].value = numpy.interp(
|
||||
round(status.Position.Zoom.x, 2),
|
||||
[0, 1],
|
||||
[
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
|
||||
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
|
||||
],
|
||||
)
|
||||
logger.debug(
|
||||
f'{camera_name}: Camera zoom level: {self.ptz_metrics[camera_name]["ptz_zoom_level"].value}'
|
||||
)
|
||||
|
||||
# some hikvision cams won't update MoveStatus, so warn if it hasn't changed
|
||||
if (
|
||||
not self.ptz_metrics[camera_name]["ptz_motor_stopped"].is_set()
|
||||
and not self.ptz_metrics[camera_name]["ptz_reset"].is_set()
|
||||
and self.ptz_metrics[camera_name]["ptz_start_time"].value != 0
|
||||
and self.ptz_metrics[camera_name]["ptz_frame_time"].value
|
||||
> (self.ptz_metrics[camera_name]["ptz_start_time"].value + 10)
|
||||
and self.ptz_metrics[camera_name]["ptz_stop_time"].value == 0
|
||||
):
|
||||
logger.debug(
|
||||
f'Start time: {self.ptz_metrics[camera_name]["ptz_start_time"].value}, Stop time: {self.ptz_metrics[camera_name]["ptz_stop_time"].value}, Frame time: {self.ptz_metrics[camera_name]["ptz_frame_time"].value}'
|
||||
)
|
||||
# set the stop time so we don't come back into this again and spam the logs
|
||||
self.ptz_metrics[camera_name]["ptz_stop_time"].value = self.ptz_metrics[
|
||||
camera_name
|
||||
]["ptz_frame_time"].value
|
||||
logger.warning(f"Camera {camera_name} is still in ONVIF 'MOVING' status.")
|
||||
|
||||
@@ -3,17 +3,15 @@
|
||||
import datetime
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from pathlib import Path
|
||||
|
||||
from peewee import DatabaseError, chunked
|
||||
|
||||
from frigate.config import FrigateConfig, RetainModeEnum
|
||||
from frigate.const import CACHE_DIR, RECORD_DIR
|
||||
from frigate.models import Event, Recordings, RecordingsToDelete
|
||||
from frigate.record.util import remove_empty_directories
|
||||
from frigate.models import Event, Recordings
|
||||
from frigate.record.util import remove_empty_directories, sync_recordings
|
||||
from frigate.util.builtin import clear_and_unlink, get_tomorrow_at_time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,11 +31,7 @@ class RecordingCleanup(threading.Thread):
|
||||
logger.debug(f"Checking tmp clip {p}.")
|
||||
if p.stat().st_mtime < (datetime.datetime.now().timestamp() - 60 * 1):
|
||||
logger.debug("Deleting tmp clip.")
|
||||
|
||||
# empty contents of file before unlinking https://github.com/blakeblackshear/frigate/issues/4769
|
||||
with open(p, "w"):
|
||||
pass
|
||||
p.unlink(missing_ok=True)
|
||||
clear_and_unlink(p)
|
||||
|
||||
def expire_recordings(self) -> None:
|
||||
"""Delete recordings based on retention config."""
|
||||
@@ -48,12 +42,17 @@ class RecordingCleanup(threading.Thread):
|
||||
expire_before = (
|
||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||
).timestamp()
|
||||
no_camera_recordings: Recordings = Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.path,
|
||||
).where(
|
||||
Recordings.camera.not_in(list(self.config.cameras.keys())),
|
||||
Recordings.end_time < expire_before,
|
||||
no_camera_recordings: Recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.path,
|
||||
)
|
||||
.where(
|
||||
Recordings.camera.not_in(list(self.config.cameras.keys())),
|
||||
Recordings.end_time < expire_before,
|
||||
)
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
deleted_recordings = set()
|
||||
@@ -95,6 +94,8 @@ class RecordingCleanup(threading.Thread):
|
||||
Recordings.end_time < expire_date,
|
||||
)
|
||||
.order_by(Recordings.start_time)
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
# Get all the events to check against
|
||||
@@ -111,14 +112,14 @@ class RecordingCleanup(threading.Thread):
|
||||
Event.has_clip,
|
||||
)
|
||||
.order_by(Event.start_time)
|
||||
.objects()
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
# loop over recordings and see if they overlap with any non-expired events
|
||||
# TODO: expire segments based on segment stats according to config
|
||||
event_start = 0
|
||||
deleted_recordings = set()
|
||||
for recording in recordings.objects().iterator():
|
||||
for recording in recordings:
|
||||
keep = False
|
||||
# Now look for a reason to keep this recording segment
|
||||
for idx in range(event_start, len(events)):
|
||||
@@ -173,76 +174,25 @@ class RecordingCleanup(threading.Thread):
|
||||
logger.debug("End all cameras.")
|
||||
logger.debug("End expire recordings.")
|
||||
|
||||
def sync_recordings(self) -> None:
|
||||
"""Check the db for stale recordings entries that don't exist in the filesystem."""
|
||||
logger.debug("Start sync recordings.")
|
||||
|
||||
# get all recordings in the db
|
||||
recordings = Recordings.select(Recordings.id, Recordings.path)
|
||||
|
||||
# get all recordings files on disk and put them in a set
|
||||
files_on_disk = {
|
||||
os.path.join(root, file)
|
||||
for root, _, files in os.walk(RECORD_DIR)
|
||||
for file in files
|
||||
}
|
||||
|
||||
# Use pagination to process records in chunks
|
||||
page_size = 1000
|
||||
num_pages = (recordings.count() + page_size - 1) // page_size
|
||||
recordings_to_delete = set()
|
||||
|
||||
for page in range(num_pages):
|
||||
for recording in recordings.paginate(page, page_size):
|
||||
if recording.path not in files_on_disk:
|
||||
recordings_to_delete.add(recording.id)
|
||||
|
||||
# convert back to list of dictionaries for insertion
|
||||
recordings_to_delete = [
|
||||
{"id": recording_id} for recording_id in recordings_to_delete
|
||||
]
|
||||
|
||||
if len(recordings_to_delete) / max(1, recordings.count()) > 0.5:
|
||||
logger.debug(
|
||||
f"Deleting {(len(recordings_to_delete) / recordings.count()):2f}% of recordings could be due to configuration error. Aborting..."
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"Deleting {len(recordings_to_delete)} recordings with missing files"
|
||||
)
|
||||
|
||||
# create a temporary table for deletion
|
||||
RecordingsToDelete.create_table(temporary=True)
|
||||
|
||||
# insert ids to the temporary table
|
||||
max_inserts = 1000
|
||||
for batch in chunked(recordings_to_delete, max_inserts):
|
||||
RecordingsToDelete.insert_many(batch).execute()
|
||||
|
||||
try:
|
||||
# delete records in the main table that exist in the temporary table
|
||||
query = Recordings.delete().where(
|
||||
Recordings.id.in_(RecordingsToDelete.select(RecordingsToDelete.id))
|
||||
)
|
||||
query.execute()
|
||||
except DatabaseError as e:
|
||||
logger.error(f"Database error during delete: {e}")
|
||||
|
||||
logger.debug("End sync recordings.")
|
||||
|
||||
def run(self) -> None:
|
||||
# on startup sync recordings with disk if enabled
|
||||
if self.config.record.sync_on_startup:
|
||||
self.sync_recordings()
|
||||
sync_recordings(limited=False)
|
||||
|
||||
next_sync = get_tomorrow_at_time(3)
|
||||
|
||||
# Expire tmp clips every minute, recordings and clean directories every hour.
|
||||
for counter in itertools.cycle(range(self.config.record.expire_interval)):
|
||||
if self.stop_event.wait(60):
|
||||
logger.info("Exiting recording cleanup...")
|
||||
break
|
||||
|
||||
self.clean_tmp_clips()
|
||||
|
||||
if datetime.datetime.now().astimezone(datetime.timezone.utc) > next_sync:
|
||||
sync_recordings(limited=True)
|
||||
next_sync = get_tomorrow_at_time(3)
|
||||
|
||||
if counter == 0:
|
||||
self.expire_recordings()
|
||||
remove_empty_directories(RECORD_DIR)
|
||||
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
import subprocess as sp
|
||||
import threading
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import EXPORT_DIR, MAX_PLAYLIST_SECONDS
|
||||
@@ -50,7 +51,7 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
def get_datetime_from_timestamp(self, timestamp: int) -> str:
|
||||
"""Convenience fun to get a simple date time from timestamp."""
|
||||
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y_%m_%d_%H:%M")
|
||||
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y_%m_%d_%H_%M")
|
||||
|
||||
def run(self) -> None:
|
||||
logger.debug(
|
||||
@@ -121,6 +122,7 @@ class RecordingExporter(threading.Thread):
|
||||
f"Failed to export recording for command {' '.join(ffmpeg_cmd)}"
|
||||
)
|
||||
logger.error(p.stderr)
|
||||
Path(file_name).unlink(missing_ok=True)
|
||||
return
|
||||
|
||||
logger.debug(f"Updating finalized export {file_name}")
|
||||
|
||||
@@ -20,6 +20,7 @@ import psutil
|
||||
from frigate.config import FrigateConfig, RetainModeEnum
|
||||
from frigate.const import (
|
||||
CACHE_DIR,
|
||||
CACHE_SEGMENT_FORMAT,
|
||||
INSERT_MANY_RECORDINGS,
|
||||
MAX_SEGMENT_DURATION,
|
||||
RECORD_DIR,
|
||||
@@ -31,6 +32,8 @@ from frigate.util.services import get_video_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
QUEUE_READ_TIMEOUT = 0.00001 # seconds
|
||||
|
||||
|
||||
class SegmentInfo:
|
||||
def __init__(
|
||||
@@ -74,15 +77,13 @@ class RecordingMaintainer(threading.Thread):
|
||||
self.end_time_cache: dict[str, Tuple[datetime.datetime, float]] = {}
|
||||
|
||||
async def move_files(self) -> None:
|
||||
cache_files = sorted(
|
||||
[
|
||||
d
|
||||
for d in os.listdir(CACHE_DIR)
|
||||
if os.path.isfile(os.path.join(CACHE_DIR, d))
|
||||
and d.endswith(".mp4")
|
||||
and not d.startswith("clip_")
|
||||
]
|
||||
)
|
||||
cache_files = [
|
||||
d
|
||||
for d in os.listdir(CACHE_DIR)
|
||||
if os.path.isfile(os.path.join(CACHE_DIR, d))
|
||||
and d.endswith(".mp4")
|
||||
and not d.startswith("clip_")
|
||||
]
|
||||
|
||||
files_in_use = []
|
||||
for process in psutil.process_iter():
|
||||
@@ -106,8 +107,12 @@ class RecordingMaintainer(threading.Thread):
|
||||
|
||||
cache_path = os.path.join(CACHE_DIR, cache)
|
||||
basename = os.path.splitext(cache)[0]
|
||||
camera, date = basename.rsplit("-", maxsplit=1)
|
||||
start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
|
||||
camera, date = basename.rsplit("@", maxsplit=1)
|
||||
|
||||
# important that start_time is utc because recordings are stored and compared in utc
|
||||
start_time = datetime.datetime.strptime(
|
||||
date, CACHE_SEGMENT_FORMAT
|
||||
).astimezone(datetime.timezone.utc)
|
||||
|
||||
grouped_recordings[camera].append(
|
||||
{
|
||||
@@ -119,6 +124,11 @@ class RecordingMaintainer(threading.Thread):
|
||||
# delete all cached files past the most recent 5
|
||||
keep_count = 5
|
||||
for camera in grouped_recordings.keys():
|
||||
# sort based on start time
|
||||
grouped_recordings[camera] = sorted(
|
||||
grouped_recordings[camera], key=lambda s: s["start_time"]
|
||||
)
|
||||
|
||||
segment_count = len(grouped_recordings[camera])
|
||||
if segment_count > keep_count:
|
||||
logger.warning(
|
||||
@@ -216,7 +226,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
# if cached file's start_time is earlier than the retain days for the camera
|
||||
if start_time <= (
|
||||
(
|
||||
datetime.datetime.now()
|
||||
datetime.datetime.now().astimezone(datetime.timezone.utc)
|
||||
- datetime.timedelta(
|
||||
days=self.config.cameras[camera].record.retain.days
|
||||
)
|
||||
@@ -254,20 +264,36 @@ class RecordingMaintainer(threading.Thread):
|
||||
# if it ends more than the configured pre_capture for the camera
|
||||
else:
|
||||
pre_capture = self.config.cameras[camera].record.events.pre_capture
|
||||
most_recently_processed_frame_time = self.object_recordings_info[
|
||||
camera
|
||||
][-1][0]
|
||||
retain_cutoff = most_recently_processed_frame_time - pre_capture
|
||||
if end_time.timestamp() < retain_cutoff:
|
||||
camera_info = self.object_recordings_info[camera]
|
||||
most_recently_processed_frame_time = (
|
||||
camera_info[-1][0] if len(camera_info) > 0 else 0
|
||||
)
|
||||
retain_cutoff = datetime.datetime.fromtimestamp(
|
||||
most_recently_processed_frame_time - pre_capture
|
||||
).astimezone(datetime.timezone.utc)
|
||||
if end_time < retain_cutoff:
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
# else retain days includes this segment
|
||||
else:
|
||||
record_mode = self.config.cameras[camera].record.retain.mode
|
||||
return await self.move_segment(
|
||||
camera, start_time, end_time, duration, cache_path, record_mode
|
||||
# assume that empty means the relevant recording info has not been received yet
|
||||
camera_info = self.object_recordings_info[camera]
|
||||
most_recently_processed_frame_time = (
|
||||
camera_info[-1][0] if len(camera_info) > 0 else 0
|
||||
)
|
||||
|
||||
# ensure delayed segment info does not lead to lost segments
|
||||
if (
|
||||
datetime.datetime.fromtimestamp(
|
||||
most_recently_processed_frame_time
|
||||
).astimezone(datetime.timezone.utc)
|
||||
>= end_time
|
||||
):
|
||||
record_mode = self.config.cameras[camera].record.retain.mode
|
||||
return await self.move_segment(
|
||||
camera, start_time, end_time, duration, cache_path, record_mode
|
||||
)
|
||||
|
||||
def segment_stats(
|
||||
self, camera: str, start_time: datetime.datetime, end_time: datetime.datetime
|
||||
) -> SegmentInfo:
|
||||
@@ -301,6 +327,10 @@ class RecordingMaintainer(threading.Thread):
|
||||
if frame[0] < start_time.timestamp():
|
||||
continue
|
||||
|
||||
# add active audio label count to count of active objects
|
||||
active_count += len(frame[2])
|
||||
|
||||
# add sound level to audio values
|
||||
audio_values.append(frame[1])
|
||||
|
||||
average_dBFS = 0 if not audio_values else np.average(audio_values)
|
||||
@@ -324,18 +354,18 @@ class RecordingMaintainer(threading.Thread):
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
return
|
||||
|
||||
# directory will be in utc due to start_time being in utc
|
||||
directory = os.path.join(
|
||||
RECORD_DIR,
|
||||
start_time.astimezone(tz=datetime.timezone.utc).strftime("%Y-%m-%d/%H"),
|
||||
start_time.strftime("%Y-%m-%d/%H"),
|
||||
camera,
|
||||
)
|
||||
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
|
||||
file_name = (
|
||||
f"{start_time.replace(tzinfo=datetime.timezone.utc).strftime('%M.%S.mp4')}"
|
||||
)
|
||||
# file will be in utc due to start_time being in utc
|
||||
file_name = f"{start_time.strftime('%M.%S.mp4')}"
|
||||
file_path = os.path.join(directory, file_name)
|
||||
|
||||
try:
|
||||
@@ -355,6 +385,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
"+faststart",
|
||||
file_path,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await p.wait()
|
||||
|
||||
@@ -405,11 +436,13 @@ class RecordingMaintainer(threading.Thread):
|
||||
return None
|
||||
|
||||
def run(self) -> None:
|
||||
camera_count = sum(camera.enabled for camera in self.config.cameras.values())
|
||||
# Check for new files every 5 seconds
|
||||
wait_time = 0.0
|
||||
while not self.stop_event.wait(wait_time):
|
||||
run_start = datetime.datetime.now().timestamp()
|
||||
|
||||
stale_frame_count = 0
|
||||
stale_frame_count_threshold = 10
|
||||
# empty the object recordings info queue
|
||||
while True:
|
||||
try:
|
||||
@@ -419,7 +452,12 @@ class RecordingMaintainer(threading.Thread):
|
||||
current_tracked_objects,
|
||||
motion_boxes,
|
||||
regions,
|
||||
) = self.object_recordings_info_queue.get(False)
|
||||
) = self.object_recordings_info_queue.get(
|
||||
True, timeout=QUEUE_READ_TIMEOUT
|
||||
)
|
||||
|
||||
if frame_time < run_start - stale_frame_count_threshold:
|
||||
stale_frame_count += 1
|
||||
|
||||
if self.process_info[camera]["record_enabled"].value:
|
||||
self.object_recordings_info[camera].append(
|
||||
@@ -431,28 +469,55 @@ class RecordingMaintainer(threading.Thread):
|
||||
)
|
||||
)
|
||||
except queue.Empty:
|
||||
q_size = self.object_recordings_info_queue.qsize()
|
||||
if q_size > camera_count:
|
||||
logger.debug(
|
||||
f"object_recordings_info loop queue not empty ({q_size})."
|
||||
)
|
||||
break
|
||||
|
||||
if stale_frame_count > 0:
|
||||
logger.debug(f"Found {stale_frame_count} old frames.")
|
||||
|
||||
# empty the audio recordings info queue if audio is enabled
|
||||
if self.audio_recordings_info_queue:
|
||||
stale_frame_count = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
(
|
||||
camera,
|
||||
frame_time,
|
||||
dBFS,
|
||||
) = self.audio_recordings_info_queue.get(False)
|
||||
audio_detections,
|
||||
) = self.audio_recordings_info_queue.get(
|
||||
True, timeout=QUEUE_READ_TIMEOUT
|
||||
)
|
||||
|
||||
if frame_time < run_start - stale_frame_count_threshold:
|
||||
stale_frame_count += 1
|
||||
|
||||
if self.process_info[camera]["record_enabled"].value:
|
||||
self.audio_recordings_info[camera].append(
|
||||
(
|
||||
frame_time,
|
||||
dBFS,
|
||||
audio_detections,
|
||||
)
|
||||
)
|
||||
except queue.Empty:
|
||||
q_size = self.audio_recordings_info_queue.qsize()
|
||||
if q_size > camera_count:
|
||||
logger.debug(
|
||||
f"object_recordings_info loop audio queue not empty ({q_size})."
|
||||
)
|
||||
break
|
||||
|
||||
if stale_frame_count > 0:
|
||||
logger.error(
|
||||
f"Found {stale_frame_count} old audio frames, segments from recordings may be missing"
|
||||
)
|
||||
|
||||
try:
|
||||
asyncio.run(self.move_files())
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
"""Recordings Utilities."""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
|
||||
from peewee import DatabaseError, chunked
|
||||
|
||||
from frigate.const import RECORD_DIR
|
||||
from frigate.models import Recordings, RecordingsToDelete
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def remove_empty_directories(directory: str) -> None:
|
||||
# list all directories recursively and sort them by path,
|
||||
@@ -17,3 +26,107 @@ def remove_empty_directories(directory: str) -> None:
|
||||
continue
|
||||
if len(os.listdir(path)) == 0:
|
||||
os.rmdir(path)
|
||||
|
||||
|
||||
def sync_recordings(limited: bool) -> None:
|
||||
"""Check the db for stale recordings entries that don't exist in the filesystem."""
|
||||
|
||||
def delete_db_entries_without_file(files_on_disk: list[str]) -> bool:
|
||||
"""Delete db entries where file was deleted outside of frigate."""
|
||||
|
||||
if limited:
|
||||
recordings = Recordings.select(Recordings.id, Recordings.path).where(
|
||||
Recordings.start_time
|
||||
>= (datetime.datetime.now() - datetime.timedelta(hours=36)).timestamp()
|
||||
)
|
||||
else:
|
||||
# get all recordings in the db
|
||||
recordings = Recordings.select(Recordings.id, Recordings.path)
|
||||
|
||||
# Use pagination to process records in chunks
|
||||
page_size = 1000
|
||||
num_pages = (recordings.count() + page_size - 1) // page_size
|
||||
recordings_to_delete = set()
|
||||
|
||||
for page in range(num_pages):
|
||||
for recording in recordings.paginate(page, page_size):
|
||||
if recording.path not in files_on_disk:
|
||||
recordings_to_delete.add(recording.id)
|
||||
|
||||
# convert back to list of dictionaries for insertion
|
||||
recordings_to_delete = [
|
||||
{"id": recording_id} for recording_id in recordings_to_delete
|
||||
]
|
||||
|
||||
if float(len(recordings_to_delete)) / max(1, recordings.count()) > 0.5:
|
||||
logger.debug(
|
||||
f"Deleting {(float(len(recordings_to_delete)) / recordings.count()):2f}% of recordings DB entries, could be due to configuration error. Aborting..."
|
||||
)
|
||||
return False
|
||||
|
||||
logger.debug(
|
||||
f"Deleting {len(recordings_to_delete)} recording DB entries with missing files"
|
||||
)
|
||||
|
||||
# create a temporary table for deletion
|
||||
RecordingsToDelete.create_table(temporary=True)
|
||||
|
||||
# insert ids to the temporary table
|
||||
max_inserts = 1000
|
||||
for batch in chunked(recordings_to_delete, max_inserts):
|
||||
RecordingsToDelete.insert_many(batch).execute()
|
||||
|
||||
try:
|
||||
# delete records in the main table that exist in the temporary table
|
||||
query = Recordings.delete().where(
|
||||
Recordings.id.in_(RecordingsToDelete.select(RecordingsToDelete.id))
|
||||
)
|
||||
query.execute()
|
||||
except DatabaseError as e:
|
||||
logger.error(f"Database error during recordings db cleanup: {e}")
|
||||
|
||||
return True
|
||||
|
||||
def delete_files_without_db_entry(files_on_disk: list[str]):
|
||||
"""Delete files where file is not inside frigate db."""
|
||||
files_to_delete = []
|
||||
|
||||
for file in files_on_disk:
|
||||
if not Recordings.select().where(Recordings.path == file).exists():
|
||||
files_to_delete.append(file)
|
||||
|
||||
if float(len(files_to_delete)) / max(1, len(files_on_disk)) > 0.5:
|
||||
logger.debug(
|
||||
f"Deleting {(float(len(files_to_delete)) / len(files_on_disk)):2f}% of recordings DB entries, could be due to configuration error. Aborting..."
|
||||
)
|
||||
return
|
||||
|
||||
for file in files_to_delete:
|
||||
os.unlink(file)
|
||||
|
||||
logger.debug("Start sync recordings.")
|
||||
|
||||
if limited:
|
||||
# get recording files from last 36 hours
|
||||
hour_check = f"{RECORD_DIR}/{(datetime.datetime.now().astimezone(datetime.timezone.utc) - datetime.timedelta(hours=36)).strftime('%Y-%m-%d/%H')}"
|
||||
files_on_disk = {
|
||||
os.path.join(root, file)
|
||||
for root, _, files in os.walk(RECORD_DIR)
|
||||
for file in files
|
||||
if root > hour_check
|
||||
}
|
||||
else:
|
||||
# get all recordings files on disk and put them in a set
|
||||
files_on_disk = {
|
||||
os.path.join(root, file)
|
||||
for root, _, files in os.walk(RECORD_DIR)
|
||||
for file in files
|
||||
}
|
||||
|
||||
db_success = delete_db_entries_without_file(files_on_disk)
|
||||
|
||||
# only try to cleanup files if db cleanup was successful
|
||||
if db_success:
|
||||
delete_files_without_db_entry(files_on_disk)
|
||||
|
||||
logger.debug("End sync recordings.")
|
||||
|
||||
@@ -176,6 +176,8 @@ async def set_gpu_stats(
|
||||
stats[nvidia_usage[i]["name"]] = {
|
||||
"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)) + "%",
|
||||
}
|
||||
|
||||
else:
|
||||
@@ -246,6 +248,7 @@ def stats_snapshot(
|
||||
|
||||
total_detection_fps = 0
|
||||
|
||||
stats["cameras"] = {}
|
||||
for name, camera_stats in camera_metrics.items():
|
||||
total_detection_fps += camera_stats["detection_fps"].value
|
||||
pid = camera_stats["process"].pid if camera_stats["process"] else None
|
||||
@@ -257,7 +260,7 @@ def stats_snapshot(
|
||||
if camera_stats["capture_process"]
|
||||
else None
|
||||
)
|
||||
stats[name] = {
|
||||
stats["cameras"][name] = {
|
||||
"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),
|
||||
@@ -266,6 +269,8 @@ def stats_snapshot(
|
||||
"pid": pid,
|
||||
"capture_pid": cpid,
|
||||
"ffmpeg_pid": ffmpeg_pid,
|
||||
"audio_rms": round(camera_stats["audio_rms"].value, 4),
|
||||
"audio_dBFS": round(camera_stats["audio_dBFS"].value, 4),
|
||||
}
|
||||
|
||||
stats["detectors"] = {}
|
||||
@@ -298,6 +303,7 @@ def stats_snapshot(
|
||||
storage_stats = shutil.disk_usage(path)
|
||||
except FileNotFoundError:
|
||||
stats["service"]["storage"][path] = {}
|
||||
continue
|
||||
|
||||
stats["service"]["storage"][path] = {
|
||||
"total": round(storage_stats.total / pow(2, 20), 1),
|
||||
|
||||
@@ -10,6 +10,7 @@ from peewee import fn
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import RECORD_DIR
|
||||
from frigate.models import Event, Recordings
|
||||
from frigate.util.builtin import clear_and_unlink
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
bandwidth_equation = Recordings.segment_size / (
|
||||
@@ -35,7 +36,7 @@ class StorageMaintainer(threading.Thread):
|
||||
if self.camera_storage_stats.get(camera, {}).get("needs_refresh", True):
|
||||
self.camera_storage_stats[camera] = {
|
||||
"needs_refresh": (
|
||||
Recordings.select(fn.COUNT(Recordings.id))
|
||||
Recordings.select(fn.COUNT("*"))
|
||||
.where(Recordings.camera == camera, Recordings.segment_size > 0)
|
||||
.scalar()
|
||||
< 50
|
||||
@@ -99,13 +100,19 @@ class StorageMaintainer(threading.Thread):
|
||||
[b["bandwidth"] for b in self.camera_storage_stats.values()]
|
||||
)
|
||||
|
||||
recordings: Recordings = Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
Recordings.segment_size,
|
||||
Recordings.path,
|
||||
).order_by(Recordings.start_time.asc())
|
||||
recordings: Recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
Recordings.segment_size,
|
||||
Recordings.path,
|
||||
)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
retained_events: Event = (
|
||||
Event.select(
|
||||
Event.start_time,
|
||||
@@ -116,12 +123,12 @@ class StorageMaintainer(threading.Thread):
|
||||
Event.has_clip,
|
||||
)
|
||||
.order_by(Event.start_time.asc())
|
||||
.objects()
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
event_start = 0
|
||||
deleted_recordings = set()
|
||||
for recording in recordings.objects().iterator():
|
||||
for recording in recordings:
|
||||
# check if 1 hour of storage has been reclaimed
|
||||
if deleted_segments_size > hourly_bandwidth:
|
||||
break
|
||||
@@ -153,28 +160,43 @@ class StorageMaintainer(threading.Thread):
|
||||
|
||||
# Delete recordings not retained indefinitely
|
||||
if not keep:
|
||||
deleted_segments_size += recording.segment_size
|
||||
Path(recording.path).unlink(missing_ok=True)
|
||||
deleted_recordings.add(recording.id)
|
||||
try:
|
||||
clear_and_unlink(Path(recording.path), missing_ok=False)
|
||||
deleted_recordings.add(recording.id)
|
||||
deleted_segments_size += recording.segment_size
|
||||
except FileNotFoundError:
|
||||
# this file was not found so we must assume no space was cleaned up
|
||||
pass
|
||||
|
||||
# check if need to delete retained segments
|
||||
if deleted_segments_size < hourly_bandwidth:
|
||||
logger.error(
|
||||
f"Could not clear {hourly_bandwidth} MB, currently {deleted_segments_size} MB have been cleared. Retained recordings must be deleted."
|
||||
)
|
||||
recordings = Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.path,
|
||||
Recordings.segment_size,
|
||||
).order_by(Recordings.start_time.asc())
|
||||
recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.path,
|
||||
Recordings.segment_size,
|
||||
)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
for recording in recordings.objects().iterator():
|
||||
for recording in recordings:
|
||||
if deleted_segments_size > hourly_bandwidth:
|
||||
break
|
||||
|
||||
deleted_segments_size += recording.segment_size
|
||||
Path(recording.path).unlink(missing_ok=True)
|
||||
deleted_recordings.add(recording.id)
|
||||
try:
|
||||
clear_and_unlink(Path(recording.path), missing_ok=False)
|
||||
deleted_segments_size += recording.segment_size
|
||||
deleted_recordings.add(recording.id)
|
||||
except FileNotFoundError:
|
||||
# this file was not found so we must assume no space was cleaned up
|
||||
pass
|
||||
else:
|
||||
logger.info(f"Cleaned up {deleted_segments_size} MB of recordings")
|
||||
|
||||
logger.debug(f"Expiring {len(deleted_recordings)} recordings")
|
||||
# delete up to 100,000 at a time
|
||||
|
||||
47
frigate/test/test_birdseye.py
Normal file
47
frigate/test/test_birdseye.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Test camera user and password cleanup."""
|
||||
|
||||
import unittest
|
||||
|
||||
from frigate.output import get_canvas_shape
|
||||
|
||||
|
||||
class TestBirdseye(unittest.TestCase):
|
||||
def test_16x9(self):
|
||||
"""Test 16x9 aspect ratio works as expected for birdseye."""
|
||||
width = 1280
|
||||
height = 720
|
||||
canvas_width, canvas_height = get_canvas_shape(width, height)
|
||||
assert canvas_width == width
|
||||
assert canvas_height == height
|
||||
|
||||
def test_4x3(self):
|
||||
"""Test 4x3 aspect ratio works as expected for birdseye."""
|
||||
width = 1280
|
||||
height = 960
|
||||
canvas_width, canvas_height = get_canvas_shape(width, height)
|
||||
assert canvas_width == width
|
||||
assert canvas_height == height
|
||||
|
||||
def test_32x9(self):
|
||||
"""Test 32x9 aspect ratio works as expected for birdseye."""
|
||||
width = 2560
|
||||
height = 720
|
||||
canvas_width, canvas_height = get_canvas_shape(width, height)
|
||||
assert canvas_width == width
|
||||
assert canvas_height == height
|
||||
|
||||
def test_9x16(self):
|
||||
"""Test 9x16 aspect ratio works as expected for birdseye."""
|
||||
width = 720
|
||||
height = 1280
|
||||
canvas_width, canvas_height = get_canvas_shape(width, height)
|
||||
assert canvas_width == width
|
||||
assert canvas_height == height
|
||||
|
||||
def test_non_16x9(self):
|
||||
"""Test non 16x9 aspect ratio fails for birdseye."""
|
||||
width = 1280
|
||||
height = 840
|
||||
canvas_width, canvas_height = get_canvas_shape(width, height)
|
||||
assert canvas_width == width # width will be the same
|
||||
assert canvas_height != height
|
||||
@@ -1027,7 +1027,12 @@ class TestConfig(unittest.TestCase):
|
||||
"roles": ["detect"],
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"height": 720,
|
||||
"width": 1280,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1082,6 +1087,11 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
"snapshots": {
|
||||
"height": 100,
|
||||
},
|
||||
@@ -1107,7 +1117,12 @@ class TestConfig(unittest.TestCase):
|
||||
"roles": ["detect"],
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1132,6 +1147,11 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
"snapshots": {
|
||||
"height": 150,
|
||||
"enabled": True,
|
||||
@@ -1160,6 +1180,11 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1181,7 +1206,12 @@ class TestConfig(unittest.TestCase):
|
||||
"roles": ["detect"],
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1205,6 +1235,11 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
"rtmp": {
|
||||
"enabled": True,
|
||||
},
|
||||
@@ -1234,6 +1269,11 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1257,6 +1297,11 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1278,7 +1323,12 @@ class TestConfig(unittest.TestCase):
|
||||
"roles": ["detect"],
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1302,6 +1352,11 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
"live": {
|
||||
"quality": 7,
|
||||
},
|
||||
@@ -1329,6 +1384,11 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1350,7 +1410,12 @@ class TestConfig(unittest.TestCase):
|
||||
"roles": ["detect"],
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1375,6 +1440,11 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
"timestamp_style": {"position": "bl", "thickness": 4},
|
||||
}
|
||||
},
|
||||
@@ -1400,6 +1470,11 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1423,6 +1498,11 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1450,6 +1530,11 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
],
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1475,6 +1560,11 @@ class TestConfig(unittest.TestCase):
|
||||
},
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
"zones": {
|
||||
"steps": {
|
||||
"coordinates": "0,0,0,0",
|
||||
@@ -1536,6 +1626,60 @@ class TestConfig(unittest.TestCase):
|
||||
assert runtime_config.cameras["back"].objects.filters["dog"].min_ratio == 0.2
|
||||
assert runtime_config.cameras["back"].objects.filters["dog"].max_ratio == 10.1
|
||||
|
||||
def test_valid_movement_weights(self):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"cameras": {
|
||||
"back": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
"onvif": {
|
||||
"autotracking": {"movement_weights": "0, 1, 1.23, 2.34, 0.50"}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].onvif.autotracking.movement_weights == [
|
||||
"0.0",
|
||||
"1.0",
|
||||
"1.23",
|
||||
"2.34",
|
||||
"0.5",
|
||||
]
|
||||
|
||||
def test_fails_invalid_movement_weights(self):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"cameras": {
|
||||
"back": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
"onvif": {"autotracking": {"movement_weights": "1.234, 2.345a"}},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
self.assertRaises(ValueError, lambda: FrigateConfig(**config))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -236,6 +236,44 @@ class TestHttp(unittest.TestCase):
|
||||
assert event["id"] == id
|
||||
assert event["retain_indefinitely"] is False
|
||||
|
||||
def test_event_time_filtering(self):
|
||||
app = create_app(
|
||||
FrigateConfig(**self.minimal_config),
|
||||
self.db,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
PlusApi(),
|
||||
)
|
||||
morning_id = "123456.random"
|
||||
evening_id = "654321.random"
|
||||
morning = 1656590400 # 06/30/2022 6 am (GMT)
|
||||
evening = 1656633600 # 06/30/2022 6 pm (GMT)
|
||||
|
||||
with app.test_client() as client:
|
||||
_insert_mock_event(morning_id, morning)
|
||||
_insert_mock_event(evening_id, evening)
|
||||
# both events come back
|
||||
events = client.get("/events").json
|
||||
assert events
|
||||
assert len(events) == 2
|
||||
# morning event is excluded
|
||||
events = client.get(
|
||||
"/events",
|
||||
query_string={"time_range": "07:00,24:00"},
|
||||
).json
|
||||
assert events
|
||||
# assert len(events) == 1
|
||||
# evening event is excluded
|
||||
events = client.get(
|
||||
"/events",
|
||||
query_string={"time_range": "00:00,18:00"},
|
||||
).json
|
||||
assert events
|
||||
assert len(events) == 1
|
||||
|
||||
def test_set_delete_sub_label(self):
|
||||
app = create_app(
|
||||
FrigateConfig(**self.minimal_config),
|
||||
@@ -351,14 +389,17 @@ class TestHttp(unittest.TestCase):
|
||||
assert stats == self.test_stats
|
||||
|
||||
|
||||
def _insert_mock_event(id: str) -> Event:
|
||||
def _insert_mock_event(
|
||||
id: str,
|
||||
start_time: datetime.datetime = datetime.datetime.now().timestamp(),
|
||||
) -> Event:
|
||||
"""Inserts a basic event model with a given id."""
|
||||
return Event.insert(
|
||||
id=id,
|
||||
label="Mock",
|
||||
camera="front_door",
|
||||
start_time=datetime.datetime.now().timestamp(),
|
||||
end_time=datetime.datetime.now().timestamp() + 20,
|
||||
start_time=start_time,
|
||||
end_time=start_time + 20,
|
||||
top_score=100,
|
||||
false_positive=False,
|
||||
zones=list(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from unittest import TestCase, main
|
||||
|
||||
from frigate.video import box_overlaps, reduce_boxes
|
||||
from frigate.util.object import box_overlaps, reduce_boxes
|
||||
|
||||
|
||||
class TestBoxOverlaps(TestCase):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -26,6 +27,7 @@ class TestHttp(unittest.TestCase):
|
||||
self.db = SqliteQueueDatabase(TEST_DB)
|
||||
models = [Event, Recordings]
|
||||
self.db.bind(models)
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
|
||||
self.minimal_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
@@ -94,6 +96,7 @@ class TestHttp(unittest.TestCase):
|
||||
rec_bd_id = "1234568.backdoor"
|
||||
_insert_mock_recording(
|
||||
rec_fd_id,
|
||||
os.path.join(self.test_dir, f"{rec_fd_id}.tmp"),
|
||||
time_keep,
|
||||
time_keep + 10,
|
||||
camera="front_door",
|
||||
@@ -102,6 +105,7 @@ class TestHttp(unittest.TestCase):
|
||||
)
|
||||
_insert_mock_recording(
|
||||
rec_bd_id,
|
||||
os.path.join(self.test_dir, f"{rec_bd_id}.tmp"),
|
||||
time_keep + 10,
|
||||
time_keep + 20,
|
||||
camera="back_door",
|
||||
@@ -123,6 +127,7 @@ class TestHttp(unittest.TestCase):
|
||||
rec_fd_id = "1234567.frontdoor"
|
||||
_insert_mock_recording(
|
||||
rec_fd_id,
|
||||
os.path.join(self.test_dir, f"{rec_fd_id}.tmp"),
|
||||
time_keep,
|
||||
time_keep + 10,
|
||||
camera="front_door",
|
||||
@@ -141,13 +146,33 @@ class TestHttp(unittest.TestCase):
|
||||
|
||||
id = "123456.keep"
|
||||
time_keep = datetime.datetime.now().timestamp()
|
||||
_insert_mock_event(id, time_keep, time_keep + 30, True)
|
||||
_insert_mock_event(
|
||||
id,
|
||||
time_keep,
|
||||
time_keep + 30,
|
||||
True,
|
||||
)
|
||||
rec_k_id = "1234567.keep"
|
||||
rec_k2_id = "1234568.keep"
|
||||
rec_k3_id = "1234569.keep"
|
||||
_insert_mock_recording(rec_k_id, time_keep, time_keep + 10)
|
||||
_insert_mock_recording(rec_k2_id, time_keep + 10, time_keep + 20)
|
||||
_insert_mock_recording(rec_k3_id, time_keep + 20, time_keep + 30)
|
||||
_insert_mock_recording(
|
||||
rec_k_id,
|
||||
os.path.join(self.test_dir, f"{rec_k_id}.tmp"),
|
||||
time_keep,
|
||||
time_keep + 10,
|
||||
)
|
||||
_insert_mock_recording(
|
||||
rec_k2_id,
|
||||
os.path.join(self.test_dir, f"{rec_k2_id}.tmp"),
|
||||
time_keep + 10,
|
||||
time_keep + 20,
|
||||
)
|
||||
_insert_mock_recording(
|
||||
rec_k3_id,
|
||||
os.path.join(self.test_dir, f"{rec_k3_id}.tmp"),
|
||||
time_keep + 20,
|
||||
time_keep + 30,
|
||||
)
|
||||
|
||||
id2 = "7890.delete"
|
||||
time_delete = datetime.datetime.now().timestamp() - 360
|
||||
@@ -155,9 +180,24 @@ class TestHttp(unittest.TestCase):
|
||||
rec_d_id = "78901.delete"
|
||||
rec_d2_id = "78902.delete"
|
||||
rec_d3_id = "78903.delete"
|
||||
_insert_mock_recording(rec_d_id, time_delete, time_delete + 10)
|
||||
_insert_mock_recording(rec_d2_id, time_delete + 10, time_delete + 20)
|
||||
_insert_mock_recording(rec_d3_id, time_delete + 20, time_delete + 30)
|
||||
_insert_mock_recording(
|
||||
rec_d_id,
|
||||
os.path.join(self.test_dir, f"{rec_d_id}.tmp"),
|
||||
time_delete,
|
||||
time_delete + 10,
|
||||
)
|
||||
_insert_mock_recording(
|
||||
rec_d2_id,
|
||||
os.path.join(self.test_dir, f"{rec_d2_id}.tmp"),
|
||||
time_delete + 10,
|
||||
time_delete + 20,
|
||||
)
|
||||
_insert_mock_recording(
|
||||
rec_d3_id,
|
||||
os.path.join(self.test_dir, f"{rec_d3_id}.tmp"),
|
||||
time_delete + 20,
|
||||
time_delete + 30,
|
||||
)
|
||||
|
||||
storage.calculate_camera_bandwidth()
|
||||
storage.reduce_storage_consumption()
|
||||
@@ -176,18 +216,42 @@ class TestHttp(unittest.TestCase):
|
||||
|
||||
id = "123456.keep"
|
||||
time_keep = datetime.datetime.now().timestamp()
|
||||
_insert_mock_event(id, time_keep, time_keep + 30, True)
|
||||
_insert_mock_event(
|
||||
id,
|
||||
time_keep,
|
||||
time_keep + 30,
|
||||
True,
|
||||
)
|
||||
rec_k_id = "1234567.keep"
|
||||
rec_k2_id = "1234568.keep"
|
||||
rec_k3_id = "1234569.keep"
|
||||
_insert_mock_recording(rec_k_id, time_keep, time_keep + 10)
|
||||
_insert_mock_recording(rec_k2_id, time_keep + 10, time_keep + 20)
|
||||
_insert_mock_recording(rec_k3_id, time_keep + 20, time_keep + 30)
|
||||
_insert_mock_recording(
|
||||
rec_k_id,
|
||||
os.path.join(self.test_dir, f"{rec_k_id}.tmp"),
|
||||
time_keep,
|
||||
time_keep + 10,
|
||||
)
|
||||
_insert_mock_recording(
|
||||
rec_k2_id,
|
||||
os.path.join(self.test_dir, f"{rec_k2_id}.tmp"),
|
||||
time_keep + 10,
|
||||
time_keep + 20,
|
||||
)
|
||||
_insert_mock_recording(
|
||||
rec_k3_id,
|
||||
os.path.join(self.test_dir, f"{rec_k3_id}.tmp"),
|
||||
time_keep + 20,
|
||||
time_keep + 30,
|
||||
)
|
||||
|
||||
time_delete = datetime.datetime.now().timestamp() - 7200
|
||||
for i in range(0, 59):
|
||||
id = f"{123456 + i}.delete"
|
||||
_insert_mock_recording(
|
||||
f"{123456 + i}.delete", time_delete, time_delete + 600
|
||||
id,
|
||||
os.path.join(self.test_dir, f"{id}.tmp"),
|
||||
time_delete,
|
||||
time_delete + 600,
|
||||
)
|
||||
|
||||
storage.calculate_camera_bandwidth()
|
||||
@@ -219,13 +283,23 @@ def _insert_mock_event(id: str, start: int, end: int, retain: bool) -> Event:
|
||||
|
||||
|
||||
def _insert_mock_recording(
|
||||
id: str, start: int, end: int, camera="front_door", seg_size=8, seg_dur=10
|
||||
id: str,
|
||||
file: str,
|
||||
start: int,
|
||||
end: int,
|
||||
camera="front_door",
|
||||
seg_size=8,
|
||||
seg_dur=10,
|
||||
) -> Event:
|
||||
"""Inserts a basic recording model with a given id."""
|
||||
# we must open the file so storage maintainer will delete it
|
||||
with open(file, "w"):
|
||||
pass
|
||||
|
||||
return Recordings.insert(
|
||||
id=id,
|
||||
camera=camera,
|
||||
path=f"/recordings/{id}",
|
||||
path=file,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
duration=seg_dur,
|
||||
|
||||
@@ -6,10 +6,12 @@ from norfair.drawing.color import Palette
|
||||
from norfair.drawing.drawer import Drawer
|
||||
|
||||
from frigate.util.image import intersection
|
||||
from frigate.video import (
|
||||
from frigate.util.object import (
|
||||
get_cluster_boundary,
|
||||
get_cluster_candidates,
|
||||
get_cluster_region,
|
||||
get_region_from_grid,
|
||||
reduce_detections,
|
||||
)
|
||||
|
||||
|
||||
@@ -190,3 +192,125 @@ class TestObjectBoundingBoxes(unittest.TestCase):
|
||||
|
||||
assert intersection(box_a, box_b) == None
|
||||
assert intersection(box_b, box_c) == (899, 128, 985, 151)
|
||||
|
||||
def test_overlapping_objects_reduced(self):
|
||||
"""Test that object not on edge of region is used when a higher scoring object at the edge of region is provided."""
|
||||
detections = [
|
||||
(
|
||||
"car",
|
||||
0.81,
|
||||
(1209, 73, 1437, 163),
|
||||
20520,
|
||||
2.53333333,
|
||||
(1150, 0, 1500, 200),
|
||||
),
|
||||
(
|
||||
"car",
|
||||
0.88,
|
||||
(1238, 73, 1401, 171),
|
||||
15974,
|
||||
1.663265306122449,
|
||||
(1242, 0, 1602, 360),
|
||||
),
|
||||
]
|
||||
frame_shape = (720, 2560)
|
||||
consolidated_detections = reduce_detections(frame_shape, detections)
|
||||
assert consolidated_detections == [
|
||||
(
|
||||
"car",
|
||||
0.81,
|
||||
(1209, 73, 1437, 163),
|
||||
20520,
|
||||
2.53333333,
|
||||
(1150, 0, 1500, 200),
|
||||
)
|
||||
]
|
||||
|
||||
def test_non_overlapping_objects_not_reduced(self):
|
||||
"""Test that non overlapping objects are not reduced."""
|
||||
detections = [
|
||||
(
|
||||
"car",
|
||||
0.81,
|
||||
(1209, 73, 1437, 163),
|
||||
20520,
|
||||
2.53333333,
|
||||
(1150, 0, 1500, 200),
|
||||
),
|
||||
(
|
||||
"car",
|
||||
0.83203125,
|
||||
(1121, 55, 1214, 100),
|
||||
4185,
|
||||
2.066666666666667,
|
||||
(922, 0, 1242, 320),
|
||||
),
|
||||
(
|
||||
"car",
|
||||
0.85546875,
|
||||
(1414, 97, 1571, 186),
|
||||
13973,
|
||||
1.7640449438202248,
|
||||
(1248, 0, 1568, 320),
|
||||
),
|
||||
]
|
||||
frame_shape = (720, 2560)
|
||||
consolidated_detections = reduce_detections(frame_shape, detections)
|
||||
assert len(consolidated_detections) == len(detections)
|
||||
|
||||
def test_overlapping_different_size_objects_not_reduced(self):
|
||||
"""Test that overlapping objects that are significantly different in size are not reduced."""
|
||||
detections = [
|
||||
(
|
||||
"car",
|
||||
0.81,
|
||||
(164, 279, 816, 719),
|
||||
286880,
|
||||
1.48,
|
||||
(90, 0, 910, 820),
|
||||
),
|
||||
(
|
||||
"car",
|
||||
0.83203125,
|
||||
(248, 340, 328, 385),
|
||||
3600,
|
||||
1.777,
|
||||
(0, 0, 460, 460),
|
||||
),
|
||||
]
|
||||
frame_shape = (720, 2560)
|
||||
consolidated_detections = reduce_detections(frame_shape, detections)
|
||||
assert len(consolidated_detections) == len(detections)
|
||||
|
||||
|
||||
class TestRegionGrid(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
pass
|
||||
|
||||
def test_region_in_range(self):
|
||||
"""Test that region is kept at minimal size when within std dev."""
|
||||
frame_shape = (720, 1280)
|
||||
box = [450, 450, 550, 550]
|
||||
region_grid = [
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[{}, {}, {}, {}, {}, {"sizes": [0.25], "mean": 0.26, "std_dev": 0.01}],
|
||||
]
|
||||
|
||||
region = get_region_from_grid(frame_shape, box, 320, region_grid)
|
||||
assert region[2] - region[0] == 320
|
||||
|
||||
def test_region_out_of_range(self):
|
||||
"""Test that region is upsized when outside of std dev."""
|
||||
frame_shape = (720, 1280)
|
||||
box = [450, 450, 550, 550]
|
||||
region_grid = [
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[{}, {}, {}, {}, {}, {"sizes": [0.5], "mean": 0.5, "std_dev": 0.1}],
|
||||
]
|
||||
|
||||
region = get_region_from_grid(frame_shape, box, 320, region_grid)
|
||||
assert region[2] - region[0] > 320
|
||||
|
||||
@@ -74,6 +74,7 @@ class TimelineProcessor(threading.Thread):
|
||||
camera_config.detect.height,
|
||||
event_data["region"],
|
||||
),
|
||||
"attribute": "",
|
||||
},
|
||||
}
|
||||
if event_type == "start":
|
||||
@@ -84,6 +85,7 @@ class TimelineProcessor(threading.Thread):
|
||||
if (
|
||||
prev_event_data["current_zones"] != event_data["current_zones"]
|
||||
and len(event_data["current_zones"]) > 0
|
||||
and not event_data["stationary"]
|
||||
):
|
||||
timeline_entry[Timeline.class_type] = "entered_zone"
|
||||
timeline_entry[Timeline.data]["zones"] = event_data["current_zones"]
|
||||
@@ -93,6 +95,18 @@ class TimelineProcessor(threading.Thread):
|
||||
"stationary" if event_data["stationary"] else "active"
|
||||
)
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
elif prev_event_data["attributes"] == {} and event_data["attributes"] != {}:
|
||||
timeline_entry[Timeline.class_type] = "attribute"
|
||||
timeline_entry[Timeline.data]["attribute"] = list(
|
||||
event_data["attributes"].keys()
|
||||
)[0]
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
elif event_type == "end":
|
||||
timeline_entry[Timeline.class_type] = "gone"
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
if event_data["has_clip"] or event_data["has_snapshot"]:
|
||||
timeline_entry[Timeline.class_type] = "gone"
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
else:
|
||||
# if event was not saved then the timeline entries should be deleted
|
||||
Timeline.delete().where(
|
||||
Timeline.source_id == event_data["id"]
|
||||
).execute()
|
||||
|
||||
@@ -13,6 +13,7 @@ from frigate.util import intersection_over_union
|
||||
class CentroidTracker(ObjectTracker):
|
||||
def __init__(self, config: DetectConfig):
|
||||
self.tracked_objects = {}
|
||||
self.untracked_object_boxes = []
|
||||
self.disappeared = {}
|
||||
self.positions = {}
|
||||
self.max_disappeared = config.max_disappeared
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
|
||||
@@ -11,6 +12,8 @@ from frigate.track import ObjectTracker
|
||||
from frigate.types import PTZMetricsTypes
|
||||
from frigate.util.image import intersection_over_union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Normalizes distance from estimate relative to object size
|
||||
# Other ideas:
|
||||
@@ -62,9 +65,9 @@ class NorfairTracker(ObjectTracker):
|
||||
ptz_metrics: PTZMetricsTypes,
|
||||
):
|
||||
self.tracked_objects = {}
|
||||
self.untracked_object_boxes: list[list[int]] = []
|
||||
self.disappeared = {}
|
||||
self.positions = {}
|
||||
self.max_disappeared = config.detect.max_disappeared
|
||||
self.camera_config = config
|
||||
self.detect_config = config.detect
|
||||
self.ptz_metrics = ptz_metrics
|
||||
@@ -77,8 +80,8 @@ class NorfairTracker(ObjectTracker):
|
||||
self.tracker = Tracker(
|
||||
distance_function=frigate_distance,
|
||||
distance_threshold=2.5,
|
||||
initialization_delay=0,
|
||||
hit_counter_max=self.max_disappeared,
|
||||
initialization_delay=self.detect_config.min_initialized,
|
||||
hit_counter_max=self.detect_config.max_disappeared,
|
||||
)
|
||||
if self.ptz_autotracker_enabled.value:
|
||||
self.ptz_motion_estimator = PtzMotionEstimator(
|
||||
@@ -93,6 +96,12 @@ class NorfairTracker(ObjectTracker):
|
||||
obj["start_time"] = obj["frame_time"]
|
||||
obj["motionless_count"] = 0
|
||||
obj["position_changes"] = 0
|
||||
obj["score_history"] = [
|
||||
p.data["score"]
|
||||
for p in next(
|
||||
(o for o in self.tracker.tracked_objects if o.global_id == track_id)
|
||||
).past_detections
|
||||
]
|
||||
self.tracked_objects[id] = obj
|
||||
self.disappeared[id] = 0
|
||||
self.positions[id] = {
|
||||
@@ -106,11 +115,6 @@ class NorfairTracker(ObjectTracker):
|
||||
"ymax": self.detect_config.height,
|
||||
}
|
||||
|
||||
# start object with a hit count of `fps` to avoid quick detection -> loss
|
||||
next(
|
||||
(o for o in self.tracker.tracked_objects if o.global_id == track_id)
|
||||
).hit_counter = self.camera_config.detect.fps
|
||||
|
||||
def deregister(self, id, track_id):
|
||||
del self.tracked_objects[id]
|
||||
del self.disappeared[id]
|
||||
@@ -281,6 +285,7 @@ class NorfairTracker(ObjectTracker):
|
||||
obj = {
|
||||
**t.last_detection.data,
|
||||
"estimate": estimate,
|
||||
"estimate_velocity": t.estimate_velocity,
|
||||
}
|
||||
active_ids.append(t.global_id)
|
||||
if t.global_id not in self.track_id_map:
|
||||
@@ -302,6 +307,12 @@ class NorfairTracker(ObjectTracker):
|
||||
for e_id in expired_ids:
|
||||
self.deregister(self.track_id_map[e_id], e_id)
|
||||
|
||||
# update list of object boxes that don't have a tracked object yet
|
||||
tracked_object_boxes = [obj["box"] for obj in self.tracked_objects.values()]
|
||||
self.untracked_object_boxes = [
|
||||
o[2] for o in detections if o[2] not in tracked_object_boxes
|
||||
]
|
||||
|
||||
def debug_draw(self, frame, frame_time):
|
||||
active_detections = [
|
||||
Drawable(id=obj.id, points=obj.last_detection.points, label=obj.label)
|
||||
|
||||
@@ -23,14 +23,23 @@ class CameraMetricsTypes(TypedDict):
|
||||
process_fps: Synchronized
|
||||
read_start: Synchronized
|
||||
skipped_fps: Synchronized
|
||||
audio_rms: Synchronized
|
||||
audio_dBFS: Synchronized
|
||||
birdseye_enabled: Synchronized
|
||||
birdseye_mode: Synchronized
|
||||
|
||||
|
||||
class PTZMetricsTypes(TypedDict):
|
||||
ptz_autotracker_enabled: Synchronized
|
||||
ptz_stopped: Event
|
||||
ptz_tracking_active: Event
|
||||
ptz_motor_stopped: Event
|
||||
ptz_reset: Event
|
||||
ptz_start_time: Synchronized
|
||||
ptz_stop_time: Synchronized
|
||||
ptz_frame_time: Synchronized
|
||||
ptz_zoom_level: Synchronized
|
||||
ptz_max_zoom: Synchronized
|
||||
ptz_min_zoom: Synchronized
|
||||
|
||||
|
||||
class FeatureMetricsTypes(TypedDict):
|
||||
|
||||
@@ -8,12 +8,14 @@ import shlex
|
||||
import urllib.parse
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pytz
|
||||
import yaml
|
||||
from ruamel.yaml import YAML
|
||||
from tzlocal import get_localzone
|
||||
|
||||
from frigate.const import REGEX_HTTP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS
|
||||
|
||||
@@ -87,7 +89,8 @@ def load_config_with_no_duplicates(raw_config) -> dict:
|
||||
"""Get config ensuring duplicate keys are not allowed."""
|
||||
|
||||
# https://stackoverflow.com/a/71751051
|
||||
class PreserveDuplicatesLoader(yaml.loader.Loader):
|
||||
# important to use SafeLoader here to avoid RCE
|
||||
class PreserveDuplicatesLoader(yaml.loader.SafeLoader):
|
||||
pass
|
||||
|
||||
def map_constructor(loader, node, deep=False):
|
||||
@@ -112,10 +115,8 @@ def load_config_with_no_duplicates(raw_config) -> dict:
|
||||
|
||||
def clean_camera_user_pass(line: str) -> str:
|
||||
"""Removes user and password from line."""
|
||||
if "rtsp://" in line:
|
||||
return re.sub(REGEX_RTSP_CAMERA_USER_PASS, "://*:*@", line)
|
||||
else:
|
||||
return re.sub(REGEX_HTTP_CAMERA_USER_PASS, "user=*&password=*", line)
|
||||
rtsp_cleaned = re.sub(REGEX_RTSP_CAMERA_USER_PASS, "://*:*@", line)
|
||||
return re.sub(REGEX_HTTP_CAMERA_USER_PASS, "user=*&password=*", rtsp_cleaned)
|
||||
|
||||
|
||||
def escape_special_characters(path: str) -> str:
|
||||
@@ -156,7 +157,7 @@ def load_labels(path, encoding="utf-8", prefill=91):
|
||||
return labels
|
||||
|
||||
|
||||
def get_tz_modifiers(tz_name: str) -> Tuple[str, str]:
|
||||
def get_tz_modifiers(tz_name: str) -> Tuple[str, str, int]:
|
||||
seconds_offset = (
|
||||
datetime.datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds()
|
||||
)
|
||||
@@ -164,7 +165,7 @@ def get_tz_modifiers(tz_name: str) -> Tuple[str, str]:
|
||||
minutes_offset = int(seconds_offset / 60 - hours_offset * 60)
|
||||
hour_modifier = f"{hours_offset} hour"
|
||||
minute_modifier = f"{minutes_offset} minute"
|
||||
return hour_modifier, minute_modifier
|
||||
return hour_modifier, minute_modifier, seconds_offset
|
||||
|
||||
|
||||
def to_relative_box(
|
||||
@@ -249,3 +250,35 @@ def update_yaml(data, key_path, new_value):
|
||||
temp[last_key] = new_value
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def find_by_key(dictionary, target_key):
|
||||
if target_key in dictionary:
|
||||
return dictionary[target_key]
|
||||
else:
|
||||
for value in dictionary.values():
|
||||
if isinstance(value, dict):
|
||||
result = find_by_key(value, target_key)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def get_tomorrow_at_time(hour: int) -> datetime.datetime:
|
||||
"""Returns the datetime of the following day at 2am."""
|
||||
tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1)
|
||||
return tomorrow.replace(hour=hour, minute=0, second=0).astimezone(
|
||||
datetime.timezone.utc
|
||||
)
|
||||
|
||||
|
||||
def clear_and_unlink(file: Path, missing_ok: bool = True) -> None:
|
||||
"""clear file then unlink to avoid space retained by file descriptors."""
|
||||
if not missing_ok and not file.exists():
|
||||
raise FileNotFoundError()
|
||||
|
||||
# empty contents of file before unlinking https://github.com/blakeblackshear/frigate/issues/4769
|
||||
with open(file, "w"):
|
||||
pass
|
||||
|
||||
file.unlink(missing_ok=missing_ok)
|
||||
|
||||
548
frigate/util/object.py
Normal file
548
frigate/util/object.py
Normal file
@@ -0,0 +1,548 @@
|
||||
"""Utils for reading and writing object detection data."""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import math
|
||||
from collections import defaultdict
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from frigate.config import DetectConfig, ModelConfig
|
||||
from frigate.const import LABEL_CONSOLIDATION_DEFAULT, LABEL_CONSOLIDATION_MAP
|
||||
from frigate.detectors.detector_config import PixelFormatEnum
|
||||
from frigate.models import Event, Regions, Timeline
|
||||
from frigate.util.image import (
|
||||
area,
|
||||
calculate_region,
|
||||
clipped,
|
||||
intersection,
|
||||
intersection_over_union,
|
||||
yuv_region_2_bgr,
|
||||
yuv_region_2_rgb,
|
||||
yuv_region_2_yuv,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GRID_SIZE = 8
|
||||
|
||||
|
||||
def get_camera_regions_grid(
|
||||
name: str,
|
||||
detect: DetectConfig,
|
||||
min_region_size: int,
|
||||
) -> list[list[dict[str, any]]]:
|
||||
"""Build a grid of expected region sizes for a camera."""
|
||||
# get grid from db if available
|
||||
try:
|
||||
regions: Regions = Regions.select().where(Regions.camera == name).get()
|
||||
grid = regions.grid
|
||||
last_update = regions.last_update
|
||||
except DoesNotExist:
|
||||
grid = []
|
||||
for x in range(GRID_SIZE):
|
||||
row = []
|
||||
for y in range(GRID_SIZE):
|
||||
row.append({"sizes": []})
|
||||
grid.append(row)
|
||||
last_update = 0
|
||||
|
||||
# get events for timeline entries
|
||||
events = (
|
||||
Event.select(Event.id)
|
||||
.where(Event.camera == name)
|
||||
.where((Event.false_positive == None) | (Event.false_positive == False))
|
||||
.where(Event.start_time > last_update)
|
||||
)
|
||||
valid_event_ids = [e["id"] for e in events.dicts()]
|
||||
logger.debug(f"Found {len(valid_event_ids)} new events for {name}")
|
||||
|
||||
# no new events, return as is
|
||||
if not valid_event_ids:
|
||||
return grid
|
||||
|
||||
new_update = datetime.datetime.now().timestamp()
|
||||
timeline = (
|
||||
Timeline.select(
|
||||
*[
|
||||
Timeline.camera,
|
||||
Timeline.source,
|
||||
Timeline.data,
|
||||
]
|
||||
)
|
||||
.where(Timeline.source_id << valid_event_ids)
|
||||
.limit(10000)
|
||||
.dicts()
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(timeline)} new entries for {name}")
|
||||
|
||||
width = detect.width
|
||||
height = detect.height
|
||||
|
||||
for t in timeline:
|
||||
if t.get("source") != "tracked_object":
|
||||
continue
|
||||
|
||||
box = t["data"]["box"]
|
||||
|
||||
# calculate centroid position
|
||||
x = box[0] + (box[2] / 2)
|
||||
y = box[1] + (box[3] / 2)
|
||||
|
||||
x_pos = int(x * GRID_SIZE)
|
||||
y_pos = int(y * GRID_SIZE)
|
||||
|
||||
calculated_region = calculate_region(
|
||||
(height, width),
|
||||
box[0] * width,
|
||||
box[1] * height,
|
||||
(box[0] + box[2]) * width,
|
||||
(box[1] + box[3]) * height,
|
||||
min_region_size,
|
||||
1.35,
|
||||
)
|
||||
# save width of region to grid as relative
|
||||
grid[x_pos][y_pos]["sizes"].append(
|
||||
(calculated_region[2] - calculated_region[0]) / width
|
||||
)
|
||||
|
||||
for x in range(GRID_SIZE):
|
||||
for y in range(GRID_SIZE):
|
||||
cell = grid[x][y]
|
||||
|
||||
if len(cell["sizes"]) == 0:
|
||||
continue
|
||||
|
||||
std_dev = np.std(cell["sizes"])
|
||||
mean = np.mean(cell["sizes"])
|
||||
logger.debug(f"std dev: {std_dev} mean: {mean}")
|
||||
cell["x"] = x
|
||||
cell["y"] = y
|
||||
cell["std_dev"] = std_dev
|
||||
cell["mean"] = mean
|
||||
|
||||
# update db with new grid
|
||||
region = {
|
||||
Regions.camera: name,
|
||||
Regions.grid: grid,
|
||||
Regions.last_update: new_update,
|
||||
}
|
||||
(
|
||||
Regions.insert(region)
|
||||
.on_conflict(
|
||||
conflict_target=[Regions.camera],
|
||||
update=region,
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
|
||||
return grid
|
||||
|
||||
|
||||
def get_cluster_region_from_grid(frame_shape, min_region, cluster, boxes, region_grid):
|
||||
min_x = frame_shape[1]
|
||||
min_y = frame_shape[0]
|
||||
max_x = 0
|
||||
max_y = 0
|
||||
for b in cluster:
|
||||
min_x = min(boxes[b][0], min_x)
|
||||
min_y = min(boxes[b][1], min_y)
|
||||
max_x = max(boxes[b][2], max_x)
|
||||
max_y = max(boxes[b][3], max_y)
|
||||
return get_region_from_grid(
|
||||
frame_shape, [min_x, min_y, max_x, max_y], min_region, region_grid
|
||||
)
|
||||
|
||||
|
||||
def get_region_from_grid(
|
||||
frame_shape: tuple[int],
|
||||
cluster: list[int],
|
||||
min_region: int,
|
||||
region_grid: list[list[dict[str, any]]],
|
||||
) -> list[int]:
|
||||
"""Get a region for a box based on the region grid."""
|
||||
box = calculate_region(
|
||||
frame_shape, cluster[0], cluster[1], cluster[2], cluster[3], min_region
|
||||
)
|
||||
centroid = (
|
||||
box[0] + (min(frame_shape[1], box[2]) - box[0]) / 2,
|
||||
box[1] + (min(frame_shape[0], box[3]) - box[1]) / 2,
|
||||
)
|
||||
grid_x = int(centroid[0] / frame_shape[1] * GRID_SIZE)
|
||||
grid_y = int(centroid[1] / frame_shape[0] * GRID_SIZE)
|
||||
|
||||
cell = region_grid[grid_x][grid_y]
|
||||
|
||||
# if there is no known data, use original region calculation
|
||||
if not cell or not cell["sizes"]:
|
||||
return box
|
||||
|
||||
# convert the calculated region size to relative
|
||||
calc_size = (box[2] - box[0]) / frame_shape[1]
|
||||
|
||||
# if region is within expected size, don't resize
|
||||
if (
|
||||
(cell["mean"] - cell["std_dev"])
|
||||
<= calc_size
|
||||
<= (cell["mean"] + cell["std_dev"])
|
||||
):
|
||||
return box
|
||||
# TODO not sure how to handle case where cluster is larger than expected region
|
||||
elif calc_size > (cell["mean"] + cell["std_dev"]):
|
||||
return box
|
||||
|
||||
size = cell["mean"] * frame_shape[1]
|
||||
|
||||
# get region based on grid size
|
||||
return calculate_region(
|
||||
frame_shape,
|
||||
max(0, centroid[0] - size / 2),
|
||||
max(0, centroid[1] - size / 2),
|
||||
min(frame_shape[1], centroid[0] + size / 2),
|
||||
min(frame_shape[0], centroid[1] + size / 2),
|
||||
min_region,
|
||||
)
|
||||
|
||||
|
||||
def is_object_filtered(obj, objects_to_track, object_filters):
|
||||
object_name = obj[0]
|
||||
object_score = obj[1]
|
||||
object_box = obj[2]
|
||||
object_area = obj[3]
|
||||
object_ratio = obj[4]
|
||||
|
||||
if object_name not in objects_to_track:
|
||||
return True
|
||||
|
||||
if object_name in object_filters:
|
||||
obj_settings = object_filters[object_name]
|
||||
|
||||
# if the min area is larger than the
|
||||
# detected object, don't add it to detected objects
|
||||
if obj_settings.min_area > object_area:
|
||||
return True
|
||||
|
||||
# if the detected object is larger than the
|
||||
# max area, don't add it to detected objects
|
||||
if obj_settings.max_area < object_area:
|
||||
return True
|
||||
|
||||
# if the score is lower than the min_score, skip
|
||||
if obj_settings.min_score > object_score:
|
||||
return True
|
||||
|
||||
# if the object is not proportionally wide enough
|
||||
if obj_settings.min_ratio > object_ratio:
|
||||
return True
|
||||
|
||||
# if the object is proportionally too wide
|
||||
if obj_settings.max_ratio < object_ratio:
|
||||
return True
|
||||
|
||||
if obj_settings.mask is not None:
|
||||
# compute the coordinates of the object and make sure
|
||||
# the location isn't outside the bounds of the image (can happen from rounding)
|
||||
object_xmin = object_box[0]
|
||||
object_xmax = object_box[2]
|
||||
object_ymax = object_box[3]
|
||||
y_location = min(int(object_ymax), len(obj_settings.mask) - 1)
|
||||
x_location = min(
|
||||
int((object_xmax + object_xmin) / 2.0),
|
||||
len(obj_settings.mask[0]) - 1,
|
||||
)
|
||||
|
||||
# if the object is in a masked location, don't add it to detected objects
|
||||
if obj_settings.mask[y_location][x_location] == 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_min_region_size(model_config: ModelConfig) -> int:
|
||||
"""Get the min region size."""
|
||||
return max(model_config.height, model_config.width)
|
||||
|
||||
|
||||
def create_tensor_input(frame, model_config: ModelConfig, region):
|
||||
if model_config.input_pixel_format == PixelFormatEnum.rgb:
|
||||
cropped_frame = yuv_region_2_rgb(frame, region)
|
||||
elif model_config.input_pixel_format == PixelFormatEnum.bgr:
|
||||
cropped_frame = yuv_region_2_bgr(frame, region)
|
||||
else:
|
||||
cropped_frame = yuv_region_2_yuv(frame, region)
|
||||
|
||||
# Resize if needed
|
||||
if cropped_frame.shape != (model_config.height, model_config.width, 3):
|
||||
cropped_frame = cv2.resize(
|
||||
cropped_frame,
|
||||
dsize=(model_config.width, model_config.height),
|
||||
interpolation=cv2.INTER_LINEAR,
|
||||
)
|
||||
|
||||
# Expand dimensions since the model expects images to have shape: [1, height, width, 3]
|
||||
return np.expand_dims(cropped_frame, axis=0)
|
||||
|
||||
|
||||
def box_overlaps(b1, b2):
|
||||
if b1[2] < b2[0] or b1[0] > b2[2] or b1[1] > b2[3] or b1[3] < b2[1]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def box_inside(b1, b2):
|
||||
# check if b2 is inside b1
|
||||
if b2[0] >= b1[0] and b2[1] >= b1[1] and b2[2] <= b1[2] and b2[3] <= b1[3]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def reduce_boxes(boxes, iou_threshold=0.0):
|
||||
clusters = []
|
||||
|
||||
for box in boxes:
|
||||
matched = 0
|
||||
for cluster in clusters:
|
||||
if intersection_over_union(box, cluster) > iou_threshold:
|
||||
matched = 1
|
||||
cluster[0] = min(cluster[0], box[0])
|
||||
cluster[1] = min(cluster[1], box[1])
|
||||
cluster[2] = max(cluster[2], box[2])
|
||||
cluster[3] = max(cluster[3], box[3])
|
||||
|
||||
if not matched:
|
||||
clusters.append(list(box))
|
||||
|
||||
return [tuple(c) for c in clusters]
|
||||
|
||||
|
||||
def intersects_any(box_a, boxes):
|
||||
for box in boxes:
|
||||
if box_overlaps(box_a, box):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def inside_any(box_a, boxes):
|
||||
for box in boxes:
|
||||
# check if box_a is inside of box
|
||||
if box_inside(box, box_a):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_cluster_boundary(box, min_region):
|
||||
# compute the max region size for the current box (box is 10% of region)
|
||||
box_width = box[2] - box[0]
|
||||
box_height = box[3] - box[1]
|
||||
max_region_area = abs(box_width * box_height) / 0.1
|
||||
max_region_size = max(min_region, int(math.sqrt(max_region_area)))
|
||||
|
||||
centroid = (box_width / 2 + box[0], box_height / 2 + box[1])
|
||||
|
||||
max_x_dist = int(max_region_size - box_width / 2 * 1.1)
|
||||
max_y_dist = int(max_region_size - box_height / 2 * 1.1)
|
||||
|
||||
return [
|
||||
int(centroid[0] - max_x_dist),
|
||||
int(centroid[1] - max_y_dist),
|
||||
int(centroid[0] + max_x_dist),
|
||||
int(centroid[1] + max_y_dist),
|
||||
]
|
||||
|
||||
|
||||
def get_cluster_candidates(frame_shape, min_region, boxes):
|
||||
# and create a cluster of other boxes using it's max region size
|
||||
# only include boxes where the region is an appropriate(except the region could possibly be smaller?)
|
||||
# size in the cluster. in order to be in the cluster, the furthest corner needs to be within x,y offset
|
||||
# determined by the max_region size minus half the box + 20%
|
||||
# TODO: see if we can do this with numpy
|
||||
cluster_candidates = []
|
||||
used_boxes = []
|
||||
# loop over each box
|
||||
for current_index, b in enumerate(boxes):
|
||||
if current_index in used_boxes:
|
||||
continue
|
||||
cluster = [current_index]
|
||||
used_boxes.append(current_index)
|
||||
cluster_boundary = get_cluster_boundary(b, min_region)
|
||||
# find all other boxes that fit inside the boundary
|
||||
for compare_index, compare_box in enumerate(boxes):
|
||||
if compare_index in used_boxes:
|
||||
continue
|
||||
|
||||
# if the box is not inside the potential cluster area, cluster them
|
||||
if not box_inside(cluster_boundary, compare_box):
|
||||
continue
|
||||
|
||||
# get the region if you were to add this box to the cluster
|
||||
potential_cluster = cluster + [compare_index]
|
||||
cluster_region = get_cluster_region(
|
||||
frame_shape, min_region, potential_cluster, boxes
|
||||
)
|
||||
# if region could be smaller and either box would be too small
|
||||
# for the resulting region, dont cluster
|
||||
should_cluster = True
|
||||
if (cluster_region[2] - cluster_region[0]) > min_region:
|
||||
for b in potential_cluster:
|
||||
box = boxes[b]
|
||||
# boxes should be more than 5% of the area of the region
|
||||
if area(box) / area(cluster_region) < 0.05:
|
||||
should_cluster = False
|
||||
break
|
||||
|
||||
if should_cluster:
|
||||
cluster.append(compare_index)
|
||||
used_boxes.append(compare_index)
|
||||
cluster_candidates.append(cluster)
|
||||
|
||||
# return the unique clusters only
|
||||
unique = {tuple(sorted(c)) for c in cluster_candidates}
|
||||
return [list(tup) for tup in unique]
|
||||
|
||||
|
||||
def get_cluster_region(frame_shape, min_region, cluster, boxes):
|
||||
min_x = frame_shape[1]
|
||||
min_y = frame_shape[0]
|
||||
max_x = 0
|
||||
max_y = 0
|
||||
for b in cluster:
|
||||
min_x = min(boxes[b][0], min_x)
|
||||
min_y = min(boxes[b][1], min_y)
|
||||
max_x = max(boxes[b][2], max_x)
|
||||
max_y = max(boxes[b][3], max_y)
|
||||
return calculate_region(
|
||||
frame_shape, min_x, min_y, max_x, max_y, min_region, multiplier=1.2
|
||||
)
|
||||
|
||||
|
||||
def get_startup_regions(
|
||||
frame_shape: tuple[int],
|
||||
region_min_size: int,
|
||||
region_grid: list[list[dict[str, any]]],
|
||||
) -> list[list[int]]:
|
||||
"""Get a list of regions to run on startup."""
|
||||
# return 8 most popular regions for the camera
|
||||
all_cells = np.concatenate(region_grid).flat
|
||||
startup_cells = sorted(all_cells, key=lambda c: len(c["sizes"]), reverse=True)[0:8]
|
||||
regions = []
|
||||
|
||||
for cell in startup_cells:
|
||||
# rest of the cells are empty
|
||||
if not cell["sizes"]:
|
||||
break
|
||||
|
||||
x = frame_shape[1] / GRID_SIZE * (0.5 + cell["x"])
|
||||
y = frame_shape[0] / GRID_SIZE * (0.5 + cell["y"])
|
||||
size = cell["mean"] * frame_shape[1]
|
||||
regions.append(
|
||||
calculate_region(
|
||||
frame_shape,
|
||||
x - size / 2,
|
||||
y - size / 2,
|
||||
x + size / 2,
|
||||
y + size / 2,
|
||||
region_min_size,
|
||||
multiplier=1,
|
||||
)
|
||||
)
|
||||
|
||||
return regions
|
||||
|
||||
|
||||
def reduce_detections(
|
||||
frame_shape: tuple[int],
|
||||
all_detections: list[tuple[any]],
|
||||
) -> list[tuple[any]]:
|
||||
"""Take a list of detections and reduce overlaps to create a list of confident detections."""
|
||||
|
||||
def reduce_overlapping_detections(detections: list[tuple[any]]) -> list[tuple[any]]:
|
||||
"""apply non-maxima suppression to suppress weak, overlapping bounding boxes."""
|
||||
detected_object_groups = defaultdict(lambda: [])
|
||||
for detection in detections:
|
||||
detected_object_groups[detection[0]].append(detection)
|
||||
|
||||
selected_objects = []
|
||||
for group in detected_object_groups.values():
|
||||
# o[2] is the box of the object: xmin, ymin, xmax, ymax
|
||||
# apply max/min to ensure values do not exceed the known frame size
|
||||
boxes = [
|
||||
(
|
||||
o[2][0],
|
||||
o[2][1],
|
||||
o[2][2] - o[2][0],
|
||||
o[2][3] - o[2][1],
|
||||
)
|
||||
for o in group
|
||||
]
|
||||
|
||||
# reduce confidences for objects that are on edge of region
|
||||
# 0.6 should be used to ensure that the object is still considered and not dropped
|
||||
# due to min score requirement of NMSBoxes
|
||||
confidences = [0.6 if clipped(o, frame_shape) else o[1] for o in group]
|
||||
|
||||
idxs = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4)
|
||||
|
||||
# add objects
|
||||
for index in idxs:
|
||||
index = index if isinstance(index, np.int32) else index[0]
|
||||
obj = group[index]
|
||||
selected_objects.append(obj)
|
||||
|
||||
# set the detections list to only include top objects
|
||||
return selected_objects
|
||||
|
||||
def get_consolidated_object_detections(detections: list[tuple[any]]):
|
||||
"""Drop detections that overlap too much."""
|
||||
detected_object_groups = defaultdict(lambda: [])
|
||||
for detection in detections:
|
||||
detected_object_groups[detection[0]].append(detection)
|
||||
|
||||
consolidated_detections = []
|
||||
for group in detected_object_groups.values():
|
||||
# if the group only has 1 item, skip
|
||||
if len(group) == 1:
|
||||
consolidated_detections.append(group[0])
|
||||
continue
|
||||
|
||||
# sort smallest to largest by area
|
||||
sorted_by_area = sorted(group, key=lambda g: g[3])
|
||||
|
||||
for current_detection_idx in range(0, len(sorted_by_area)):
|
||||
current_detection = sorted_by_area[current_detection_idx]
|
||||
current_label = current_detection[0]
|
||||
current_box = current_detection[2]
|
||||
overlap = 0
|
||||
for to_check_idx in range(
|
||||
min(current_detection_idx + 1, len(sorted_by_area)),
|
||||
len(sorted_by_area),
|
||||
):
|
||||
to_check = sorted_by_area[to_check_idx][2]
|
||||
|
||||
# if area of current detection / area of check < 5% they should not be compared
|
||||
# this covers cases where a large car parked in a driveway doesn't block detections
|
||||
# of cars in the street behind it
|
||||
if area(current_box) / area(to_check) < 0.05:
|
||||
continue
|
||||
|
||||
intersect_box = intersection(current_box, to_check)
|
||||
# if % of smaller detection is inside of another detection, consolidate
|
||||
if intersect_box is not None and area(intersect_box) / area(
|
||||
current_box
|
||||
) > LABEL_CONSOLIDATION_MAP.get(
|
||||
current_label, LABEL_CONSOLIDATION_DEFAULT
|
||||
):
|
||||
overlap = 1
|
||||
break
|
||||
if overlap == 0:
|
||||
consolidated_detections.append(
|
||||
sorted_by_area[current_detection_idx]
|
||||
)
|
||||
|
||||
return consolidated_detections
|
||||
|
||||
return get_consolidated_object_detections(
|
||||
reduce_overlapping_detections(all_detections)
|
||||
)
|
||||
@@ -293,6 +293,8 @@ def get_nvidia_gpu_stats() -> dict[int, dict]:
|
||||
handle = nvml.nvmlDeviceGetHandleByIndex(i)
|
||||
meminfo = try_get_info(nvml.nvmlDeviceGetMemoryInfo, handle)
|
||||
util = try_get_info(nvml.nvmlDeviceGetUtilizationRates, handle)
|
||||
enc = try_get_info(nvml.nvmlDeviceGetEncoderUtilization, handle)
|
||||
dec = try_get_info(nvml.nvmlDeviceGetDecoderUtilization, handle)
|
||||
if util != "N/A":
|
||||
gpu_util = util.gpu
|
||||
else:
|
||||
@@ -303,10 +305,22 @@ def get_nvidia_gpu_stats() -> dict[int, dict]:
|
||||
else:
|
||||
gpu_mem_util = -1
|
||||
|
||||
if enc != "N/A":
|
||||
enc_util = enc[0]
|
||||
else:
|
||||
enc_util = -1
|
||||
|
||||
if dec != "N/A":
|
||||
dec_util = dec[0]
|
||||
else:
|
||||
dec_util = -1
|
||||
|
||||
results[i] = {
|
||||
"name": nvml.nvmlDeviceGetName(handle),
|
||||
"gpu": gpu_util,
|
||||
"mem": gpu_mem_util,
|
||||
"enc": enc_util,
|
||||
"dec": dec_util,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
485
frigate/video.py
485
frigate/video.py
@@ -1,6 +1,5 @@
|
||||
import datetime
|
||||
import logging
|
||||
import math
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import queue
|
||||
@@ -8,15 +7,18 @@ import signal
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from setproctitle import setproctitle
|
||||
|
||||
from frigate.config import CameraConfig, DetectConfig, ModelConfig
|
||||
from frigate.const import ALL_ATTRIBUTE_LABELS, ATTRIBUTE_LABEL_MAP, CACHE_DIR
|
||||
from frigate.detectors.detector_config import PixelFormatEnum
|
||||
from frigate.const import (
|
||||
ALL_ATTRIBUTE_LABELS,
|
||||
ATTRIBUTE_LABEL_MAP,
|
||||
CACHE_DIR,
|
||||
CACHE_SEGMENT_FORMAT,
|
||||
REQUEST_REGION_GRID,
|
||||
)
|
||||
from frigate.log import LogPipe
|
||||
from frigate.motion import MotionDetector
|
||||
from frigate.motion.improved_motion import ImprovedMotionDetector
|
||||
@@ -25,103 +27,30 @@ from frigate.ptz.autotrack import ptz_moving_at_frame_time
|
||||
from frigate.track import ObjectTracker
|
||||
from frigate.track.norfair_tracker import NorfairTracker
|
||||
from frigate.types import PTZMetricsTypes
|
||||
from frigate.util.builtin import EventsPerSecond
|
||||
from frigate.util.builtin import EventsPerSecond, get_tomorrow_at_time
|
||||
from frigate.util.image import (
|
||||
FrameManager,
|
||||
SharedMemoryFrameManager,
|
||||
area,
|
||||
calculate_region,
|
||||
draw_box_with_label,
|
||||
intersection,
|
||||
intersection_over_union,
|
||||
yuv_region_2_bgr,
|
||||
yuv_region_2_rgb,
|
||||
yuv_region_2_yuv,
|
||||
)
|
||||
from frigate.util.object import (
|
||||
box_inside,
|
||||
create_tensor_input,
|
||||
get_cluster_candidates,
|
||||
get_cluster_region,
|
||||
get_cluster_region_from_grid,
|
||||
get_min_region_size,
|
||||
get_startup_regions,
|
||||
inside_any,
|
||||
intersects_any,
|
||||
is_object_filtered,
|
||||
reduce_detections,
|
||||
)
|
||||
from frigate.util.services import listen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def filtered(obj, objects_to_track, object_filters):
|
||||
object_name = obj[0]
|
||||
object_score = obj[1]
|
||||
object_box = obj[2]
|
||||
object_area = obj[3]
|
||||
object_ratio = obj[4]
|
||||
|
||||
if object_name not in objects_to_track:
|
||||
return True
|
||||
|
||||
if object_name in object_filters:
|
||||
obj_settings = object_filters[object_name]
|
||||
|
||||
# if the min area is larger than the
|
||||
# detected object, don't add it to detected objects
|
||||
if obj_settings.min_area > object_area:
|
||||
return True
|
||||
|
||||
# if the detected object is larger than the
|
||||
# max area, don't add it to detected objects
|
||||
if obj_settings.max_area < object_area:
|
||||
return True
|
||||
|
||||
# if the score is lower than the min_score, skip
|
||||
if obj_settings.min_score > object_score:
|
||||
return True
|
||||
|
||||
# if the object is not proportionally wide enough
|
||||
if obj_settings.min_ratio > object_ratio:
|
||||
return True
|
||||
|
||||
# if the object is proportionally too wide
|
||||
if obj_settings.max_ratio < object_ratio:
|
||||
return True
|
||||
|
||||
if obj_settings.mask is not None:
|
||||
# compute the coordinates of the object and make sure
|
||||
# the location isn't outside the bounds of the image (can happen from rounding)
|
||||
object_xmin = object_box[0]
|
||||
object_xmax = object_box[2]
|
||||
object_ymax = object_box[3]
|
||||
y_location = min(int(object_ymax), len(obj_settings.mask) - 1)
|
||||
x_location = min(
|
||||
int((object_xmax + object_xmin) / 2.0),
|
||||
len(obj_settings.mask[0]) - 1,
|
||||
)
|
||||
|
||||
# if the object is in a masked location, don't add it to detected objects
|
||||
if obj_settings.mask[y_location][x_location] == 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_min_region_size(model_config: ModelConfig) -> int:
|
||||
"""Get the min region size."""
|
||||
return max(model_config.height, model_config.width)
|
||||
|
||||
|
||||
def create_tensor_input(frame, model_config: ModelConfig, region):
|
||||
if model_config.input_pixel_format == PixelFormatEnum.rgb:
|
||||
cropped_frame = yuv_region_2_rgb(frame, region)
|
||||
elif model_config.input_pixel_format == PixelFormatEnum.bgr:
|
||||
cropped_frame = yuv_region_2_bgr(frame, region)
|
||||
else:
|
||||
cropped_frame = yuv_region_2_yuv(frame, region)
|
||||
|
||||
# Resize if needed
|
||||
if cropped_frame.shape != (model_config.height, model_config.width, 3):
|
||||
cropped_frame = cv2.resize(
|
||||
cropped_frame,
|
||||
dsize=(model_config.width, model_config.height),
|
||||
interpolation=cv2.INTER_LINEAR,
|
||||
)
|
||||
|
||||
# Expand dimensions since the model expects images to have shape: [1, height, width, 3]
|
||||
return np.expand_dims(cropped_frame, axis=0)
|
||||
|
||||
|
||||
def stop_ffmpeg(ffmpeg_process, logger):
|
||||
logger.info("Terminating the existing ffmpeg process...")
|
||||
ffmpeg_process.terminate()
|
||||
@@ -305,14 +234,15 @@ class CameraWatchdog(threading.Thread):
|
||||
poll = p["process"].poll()
|
||||
|
||||
if self.config.record.enabled and "record" in p["roles"]:
|
||||
latest_segment_time = self.get_latest_segment_timestamp(
|
||||
latest_segment_time = self.get_latest_segment_datetime(
|
||||
p.get(
|
||||
"latest_segment_time", datetime.datetime.now().timestamp()
|
||||
"latest_segment_time",
|
||||
datetime.datetime.now().astimezone(datetime.timezone.utc),
|
||||
)
|
||||
)
|
||||
|
||||
if datetime.datetime.now().timestamp() > (
|
||||
latest_segment_time + 120
|
||||
if datetime.datetime.now().astimezone(datetime.timezone.utc) > (
|
||||
latest_segment_time + datetime.timedelta(seconds=120)
|
||||
):
|
||||
self.logger.error(
|
||||
f"No new recording segments were created for {self.camera_name} in the last 120s. restarting the ffmpeg record process..."
|
||||
@@ -360,7 +290,7 @@ class CameraWatchdog(threading.Thread):
|
||||
)
|
||||
self.capture_thread.start()
|
||||
|
||||
def get_latest_segment_timestamp(self, latest_timestamp) -> int:
|
||||
def get_latest_segment_datetime(self, latest_segment: datetime.datetime) -> int:
|
||||
"""Checks if ffmpeg is still writing recording segments to cache."""
|
||||
cache_files = sorted(
|
||||
[
|
||||
@@ -371,17 +301,19 @@ class CameraWatchdog(threading.Thread):
|
||||
and not d.startswith("clip_")
|
||||
]
|
||||
)
|
||||
newest_segment_timestamp = latest_timestamp
|
||||
newest_segment_time = latest_segment
|
||||
|
||||
for file in cache_files:
|
||||
if self.camera_name in file:
|
||||
basename = os.path.splitext(file)[0]
|
||||
_, date = basename.rsplit("-", maxsplit=1)
|
||||
ts = datetime.datetime.strptime(date, "%Y%m%d%H%M%S").timestamp()
|
||||
if ts > newest_segment_timestamp:
|
||||
newest_segment_timestamp = ts
|
||||
_, date = basename.rsplit("@", maxsplit=1)
|
||||
segment_time = datetime.datetime.strptime(
|
||||
date, CACHE_SEGMENT_FORMAT
|
||||
).astimezone(datetime.timezone.utc)
|
||||
if segment_time > newest_segment_time:
|
||||
newest_segment_time = segment_time
|
||||
|
||||
return newest_segment_timestamp
|
||||
return newest_segment_time
|
||||
|
||||
|
||||
class CameraCapture(threading.Thread):
|
||||
@@ -456,8 +388,10 @@ def track_camera(
|
||||
detection_queue,
|
||||
result_connection,
|
||||
detected_objects_queue,
|
||||
inter_process_queue,
|
||||
process_info,
|
||||
ptz_metrics,
|
||||
region_grid,
|
||||
):
|
||||
stop_event = mp.Event()
|
||||
|
||||
@@ -472,6 +406,7 @@ def track_camera(
|
||||
listen()
|
||||
|
||||
frame_queue = process_info["frame_queue"]
|
||||
region_grid_queue = process_info["region_grid_queue"]
|
||||
detection_enabled = process_info["detection_enabled"]
|
||||
motion_enabled = process_info["motion_enabled"]
|
||||
improve_contrast_enabled = process_info["improve_contrast_enabled"]
|
||||
@@ -500,7 +435,9 @@ def track_camera(
|
||||
|
||||
process_frames(
|
||||
name,
|
||||
inter_process_queue,
|
||||
frame_queue,
|
||||
region_grid_queue,
|
||||
frame_shape,
|
||||
model_config,
|
||||
config.detect,
|
||||
@@ -516,50 +453,12 @@ def track_camera(
|
||||
motion_enabled,
|
||||
stop_event,
|
||||
ptz_metrics,
|
||||
region_grid,
|
||||
)
|
||||
|
||||
logger.info(f"{name}: exiting subprocess")
|
||||
|
||||
|
||||
def box_overlaps(b1, b2):
|
||||
if b1[2] < b2[0] or b1[0] > b2[2] or b1[1] > b2[3] or b1[3] < b2[1]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def box_inside(b1, b2):
|
||||
# check if b2 is inside b1
|
||||
if b2[0] >= b1[0] and b2[1] >= b1[1] and b2[2] <= b1[2] and b2[3] <= b1[3]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def reduce_boxes(boxes, iou_threshold=0.0):
|
||||
clusters = []
|
||||
|
||||
for box in boxes:
|
||||
matched = 0
|
||||
for cluster in clusters:
|
||||
if intersection_over_union(box, cluster) > iou_threshold:
|
||||
matched = 1
|
||||
cluster[0] = min(cluster[0], box[0])
|
||||
cluster[1] = min(cluster[1], box[1])
|
||||
cluster[2] = max(cluster[2], box[2])
|
||||
cluster[3] = max(cluster[3], box[3])
|
||||
|
||||
if not matched:
|
||||
clusters.append(list(box))
|
||||
|
||||
return [tuple(c) for c in clusters]
|
||||
|
||||
|
||||
def intersects_any(box_a, boxes):
|
||||
for box in boxes:
|
||||
if box_overlaps(box_a, box):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def detect(
|
||||
detect_config: DetectConfig,
|
||||
object_detector,
|
||||
@@ -598,134 +497,17 @@ def detect(
|
||||
region,
|
||||
)
|
||||
# apply object filters
|
||||
if filtered(det, objects_to_track, object_filters):
|
||||
if is_object_filtered(det, objects_to_track, object_filters):
|
||||
continue
|
||||
detections.append(det)
|
||||
return detections
|
||||
|
||||
|
||||
def get_cluster_boundary(box, min_region):
|
||||
# compute the max region size for the current box (box is 10% of region)
|
||||
box_width = box[2] - box[0]
|
||||
box_height = box[3] - box[1]
|
||||
max_region_area = abs(box_width * box_height) / 0.1
|
||||
max_region_size = max(min_region, int(math.sqrt(max_region_area)))
|
||||
|
||||
centroid = (box_width / 2 + box[0], box_height / 2 + box[1])
|
||||
|
||||
max_x_dist = int(max_region_size - box_width / 2 * 1.1)
|
||||
max_y_dist = int(max_region_size - box_height / 2 * 1.1)
|
||||
|
||||
return [
|
||||
int(centroid[0] - max_x_dist),
|
||||
int(centroid[1] - max_y_dist),
|
||||
int(centroid[0] + max_x_dist),
|
||||
int(centroid[1] + max_y_dist),
|
||||
]
|
||||
|
||||
|
||||
def get_cluster_candidates(frame_shape, min_region, boxes):
|
||||
# and create a cluster of other boxes using it's max region size
|
||||
# only include boxes where the region is an appropriate(except the region could possibly be smaller?)
|
||||
# size in the cluster. in order to be in the cluster, the furthest corner needs to be within x,y offset
|
||||
# determined by the max_region size minus half the box + 20%
|
||||
# TODO: see if we can do this with numpy
|
||||
cluster_candidates = []
|
||||
used_boxes = []
|
||||
# loop over each box
|
||||
for current_index, b in enumerate(boxes):
|
||||
if current_index in used_boxes:
|
||||
continue
|
||||
cluster = [current_index]
|
||||
used_boxes.append(current_index)
|
||||
cluster_boundary = get_cluster_boundary(b, min_region)
|
||||
# find all other boxes that fit inside the boundary
|
||||
for compare_index, compare_box in enumerate(boxes):
|
||||
if compare_index in used_boxes:
|
||||
continue
|
||||
|
||||
# if the box is not inside the potential cluster area, cluster them
|
||||
if not box_inside(cluster_boundary, compare_box):
|
||||
continue
|
||||
|
||||
# get the region if you were to add this box to the cluster
|
||||
potential_cluster = cluster + [compare_index]
|
||||
cluster_region = get_cluster_region(
|
||||
frame_shape, min_region, potential_cluster, boxes
|
||||
)
|
||||
# if region could be smaller and either box would be too small
|
||||
# for the resulting region, dont cluster
|
||||
should_cluster = True
|
||||
if (cluster_region[2] - cluster_region[0]) > min_region:
|
||||
for b in potential_cluster:
|
||||
box = boxes[b]
|
||||
# boxes should be more than 5% of the area of the region
|
||||
if area(box) / area(cluster_region) < 0.05:
|
||||
should_cluster = False
|
||||
break
|
||||
|
||||
if should_cluster:
|
||||
cluster.append(compare_index)
|
||||
used_boxes.append(compare_index)
|
||||
cluster_candidates.append(cluster)
|
||||
|
||||
# return the unique clusters only
|
||||
unique = {tuple(sorted(c)) for c in cluster_candidates}
|
||||
return [list(tup) for tup in unique]
|
||||
|
||||
|
||||
def get_cluster_region(frame_shape, min_region, cluster, boxes):
|
||||
min_x = frame_shape[1]
|
||||
min_y = frame_shape[0]
|
||||
max_x = 0
|
||||
max_y = 0
|
||||
for b in cluster:
|
||||
min_x = min(boxes[b][0], min_x)
|
||||
min_y = min(boxes[b][1], min_y)
|
||||
max_x = max(boxes[b][2], max_x)
|
||||
max_y = max(boxes[b][3], max_y)
|
||||
return calculate_region(
|
||||
frame_shape, min_x, min_y, max_x, max_y, min_region, multiplier=1.2
|
||||
)
|
||||
|
||||
|
||||
def get_consolidated_object_detections(detected_object_groups):
|
||||
"""Drop detections that overlap too much"""
|
||||
consolidated_detections = []
|
||||
for group in detected_object_groups.values():
|
||||
# if the group only has 1 item, skip
|
||||
if len(group) == 1:
|
||||
consolidated_detections.append(group[0])
|
||||
continue
|
||||
|
||||
# sort smallest to largest by area
|
||||
sorted_by_area = sorted(group, key=lambda g: g[3])
|
||||
|
||||
for current_detection_idx in range(0, len(sorted_by_area)):
|
||||
current_detection = sorted_by_area[current_detection_idx][2]
|
||||
overlap = 0
|
||||
for to_check_idx in range(
|
||||
min(current_detection_idx + 1, len(sorted_by_area)),
|
||||
len(sorted_by_area),
|
||||
):
|
||||
to_check = sorted_by_area[to_check_idx][2]
|
||||
intersect_box = intersection(current_detection, to_check)
|
||||
# if 90% of smaller detection is inside of another detection, consolidate
|
||||
if (
|
||||
intersect_box is not None
|
||||
and area(intersect_box) / area(current_detection) > 0.9
|
||||
):
|
||||
overlap = 1
|
||||
break
|
||||
if overlap == 0:
|
||||
consolidated_detections.append(sorted_by_area[current_detection_idx])
|
||||
|
||||
return consolidated_detections
|
||||
|
||||
|
||||
def process_frames(
|
||||
camera_name: str,
|
||||
inter_process_queue: mp.Queue,
|
||||
frame_queue: mp.Queue,
|
||||
region_grid_queue: mp.Queue,
|
||||
frame_shape,
|
||||
model_config: ModelConfig,
|
||||
detect_config: DetectConfig,
|
||||
@@ -741,20 +523,36 @@ def process_frames(
|
||||
motion_enabled: mp.Value,
|
||||
stop_event,
|
||||
ptz_metrics: PTZMetricsTypes,
|
||||
region_grid,
|
||||
exit_on_empty: bool = False,
|
||||
):
|
||||
fps = process_info["process_fps"]
|
||||
detection_fps = process_info["detection_fps"]
|
||||
current_frame_time = process_info["detection_frame"]
|
||||
next_region_update = get_tomorrow_at_time(2)
|
||||
|
||||
fps_tracker = EventsPerSecond()
|
||||
fps_tracker.start()
|
||||
|
||||
startup_scan_counter = 0
|
||||
startup_scan = True
|
||||
stationary_frame_counter = 0
|
||||
|
||||
region_min_size = get_min_region_size(model_config)
|
||||
|
||||
while not stop_event.is_set():
|
||||
if (
|
||||
datetime.datetime.now().astimezone(datetime.timezone.utc)
|
||||
> next_region_update
|
||||
):
|
||||
inter_process_queue.put((REQUEST_REGION_GRID, camera_name))
|
||||
|
||||
try:
|
||||
region_grid = region_grid_queue.get(True, 10)
|
||||
except queue.Empty:
|
||||
logger.error(f"Unable to get updated region grid for {camera_name}")
|
||||
|
||||
next_region_update = get_tomorrow_at_time(2)
|
||||
|
||||
try:
|
||||
if exit_on_empty:
|
||||
frame_time = frame_queue.get(False)
|
||||
@@ -767,6 +565,7 @@ def process_frames(
|
||||
continue
|
||||
|
||||
current_frame_time.value = frame_time
|
||||
ptz_metrics["ptz_frame_time"].value = frame_time
|
||||
|
||||
frame = frame_manager.get(
|
||||
f"{camera_name}{frame_time}", (frame_shape[0] * 3 // 2, frame_shape[1])
|
||||
@@ -776,19 +575,8 @@ def process_frames(
|
||||
logger.info(f"{camera_name}: frame {frame_time} is not in memory store.")
|
||||
continue
|
||||
|
||||
# look for motion if enabled and ptz is not moving
|
||||
# ptz_moving_at_frame_time() always returns False for
|
||||
# non ptz/autotracking cameras
|
||||
motion_boxes = (
|
||||
motion_detector.detect(frame)
|
||||
if motion_enabled.value
|
||||
and not ptz_moving_at_frame_time(
|
||||
frame_time,
|
||||
ptz_metrics["ptz_start_time"].value,
|
||||
ptz_metrics["ptz_stop_time"].value,
|
||||
)
|
||||
else []
|
||||
)
|
||||
# look for motion if enabled
|
||||
motion_boxes = motion_detector.detect(frame) if motion_enabled.value else []
|
||||
|
||||
regions = []
|
||||
consolidated_detections = []
|
||||
@@ -801,60 +589,85 @@ def process_frames(
|
||||
# check every Nth frame for stationary objects
|
||||
# disappeared objects are not stationary
|
||||
# also check for overlapping motion boxes
|
||||
stationary_object_ids = [
|
||||
obj["id"]
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
# if it has exceeded the stationary threshold
|
||||
if obj["motionless_count"] >= detect_config.stationary.threshold
|
||||
# and it isn't due for a periodic check
|
||||
and (
|
||||
detect_config.stationary.interval == 0
|
||||
or obj["motionless_count"] % detect_config.stationary.interval != 0
|
||||
)
|
||||
# and it hasn't disappeared
|
||||
and object_tracker.disappeared[obj["id"]] == 0
|
||||
# and it doesn't overlap with any current motion boxes
|
||||
and not intersects_any(obj["box"], motion_boxes)
|
||||
]
|
||||
if stationary_frame_counter == detect_config.stationary.interval:
|
||||
stationary_frame_counter = 0
|
||||
stationary_object_ids = []
|
||||
else:
|
||||
stationary_frame_counter += 1
|
||||
stationary_object_ids = [
|
||||
obj["id"]
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
# if it has exceeded the stationary threshold
|
||||
if obj["motionless_count"] >= detect_config.stationary.threshold
|
||||
# and it hasn't disappeared
|
||||
and object_tracker.disappeared[obj["id"]] == 0
|
||||
# and it doesn't overlap with any current motion boxes when not calibrating
|
||||
and not intersects_any(
|
||||
obj["box"],
|
||||
[] if motion_detector.is_calibrating() else motion_boxes,
|
||||
)
|
||||
]
|
||||
|
||||
# get tracked object boxes that aren't stationary
|
||||
tracked_object_boxes = [
|
||||
obj["estimate"]
|
||||
(
|
||||
# use existing object box for stationary objects
|
||||
obj["estimate"]
|
||||
if obj["motionless_count"] < detect_config.stationary.threshold
|
||||
else obj["box"]
|
||||
)
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
if obj["id"] not in stationary_object_ids
|
||||
]
|
||||
object_boxes = tracked_object_boxes + object_tracker.untracked_object_boxes
|
||||
|
||||
combined_boxes = motion_boxes + tracked_object_boxes
|
||||
|
||||
cluster_candidates = get_cluster_candidates(
|
||||
frame_shape, region_min_size, combined_boxes
|
||||
)
|
||||
|
||||
# get consolidated regions for tracked objects
|
||||
regions = [
|
||||
get_cluster_region(
|
||||
frame_shape, region_min_size, candidate, combined_boxes
|
||||
frame_shape, region_min_size, candidate, object_boxes
|
||||
)
|
||||
for candidate in get_cluster_candidates(
|
||||
frame_shape, region_min_size, object_boxes
|
||||
)
|
||||
for candidate in cluster_candidates
|
||||
]
|
||||
|
||||
# if starting up, get the next startup scan region
|
||||
if startup_scan_counter < 9:
|
||||
ymin = int(frame_shape[0] / 3 * startup_scan_counter / 3)
|
||||
ymax = int(frame_shape[0] / 3 + ymin)
|
||||
xmin = int(frame_shape[1] / 3 * startup_scan_counter / 3)
|
||||
xmax = int(frame_shape[1] / 3 + xmin)
|
||||
regions.append(
|
||||
calculate_region(
|
||||
# only add in the motion boxes when not calibrating and a ptz is not moving via autotracking
|
||||
# ptz_moving_at_frame_time() always returns False for non-autotracking cameras
|
||||
if not motion_detector.is_calibrating() and not ptz_moving_at_frame_time(
|
||||
frame_time,
|
||||
ptz_metrics["ptz_start_time"].value,
|
||||
ptz_metrics["ptz_stop_time"].value,
|
||||
):
|
||||
# find motion boxes that are not inside tracked object regions
|
||||
standalone_motion_boxes = [
|
||||
b for b in motion_boxes if not inside_any(b, regions)
|
||||
]
|
||||
|
||||
if standalone_motion_boxes:
|
||||
motion_clusters = get_cluster_candidates(
|
||||
frame_shape,
|
||||
xmin,
|
||||
ymin,
|
||||
xmax,
|
||||
ymax,
|
||||
region_min_size,
|
||||
multiplier=1.2,
|
||||
standalone_motion_boxes,
|
||||
)
|
||||
)
|
||||
startup_scan_counter += 1
|
||||
motion_regions = [
|
||||
get_cluster_region_from_grid(
|
||||
frame_shape,
|
||||
region_min_size,
|
||||
candidate,
|
||||
standalone_motion_boxes,
|
||||
region_grid,
|
||||
)
|
||||
for candidate in motion_clusters
|
||||
]
|
||||
regions += motion_regions
|
||||
|
||||
# if starting up, get the next startup scan region
|
||||
if startup_scan:
|
||||
for region in get_startup_regions(
|
||||
frame_shape, region_min_size, region_grid
|
||||
):
|
||||
regions.append(region)
|
||||
startup_scan = False
|
||||
|
||||
# resize regions and detect
|
||||
# seed with stationary objects
|
||||
@@ -884,50 +697,10 @@ def process_frames(
|
||||
)
|
||||
)
|
||||
|
||||
#########
|
||||
# merge objects
|
||||
#########
|
||||
# group by name
|
||||
detected_object_groups = defaultdict(lambda: [])
|
||||
for detection in detections:
|
||||
detected_object_groups[detection[0]].append(detection)
|
||||
|
||||
selected_objects = []
|
||||
for group in detected_object_groups.values():
|
||||
# apply non-maxima suppression to suppress weak, overlapping bounding boxes
|
||||
# o[2] is the box of the object: xmin, ymin, xmax, ymax
|
||||
# apply max/min to ensure values do not exceed the known frame size
|
||||
boxes = [
|
||||
(
|
||||
o[2][0],
|
||||
o[2][1],
|
||||
o[2][2] - o[2][0],
|
||||
o[2][3] - o[2][1],
|
||||
)
|
||||
for o in group
|
||||
]
|
||||
confidences = [o[1] for o in group]
|
||||
idxs = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4)
|
||||
|
||||
# add objects
|
||||
for index in idxs:
|
||||
index = index if isinstance(index, np.int32) else index[0]
|
||||
obj = group[index]
|
||||
selected_objects.append(obj)
|
||||
|
||||
# set the detections list to only include top objects
|
||||
detections = selected_objects
|
||||
consolidated_detections = reduce_detections(frame_shape, detections)
|
||||
|
||||
# if detection was run on this frame, consolidate
|
||||
if len(regions) > 0:
|
||||
# group by name
|
||||
detected_object_groups = defaultdict(lambda: [])
|
||||
for detection in detections:
|
||||
detected_object_groups[detection[0]].append(detection)
|
||||
|
||||
consolidated_detections = get_consolidated_object_detections(
|
||||
detected_object_groups
|
||||
)
|
||||
tracked_detections = [
|
||||
d
|
||||
for d in consolidated_detections
|
||||
|
||||
35
migrations/019_create_regions_table.py
Normal file
35
migrations/019_create_regions_table.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Peewee migrations -- 019_create_regions_table.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['model_name'] # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.python(func, *args, **kwargs) # Run python code
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
|
||||
"""
|
||||
import peewee as pw
|
||||
|
||||
SQL = pw.SQL
|
||||
|
||||
|
||||
def migrate(migrator, database, fake=False, **kwargs):
|
||||
migrator.sql(
|
||||
'CREATE TABLE IF NOT EXISTS "regions" ("camera" VARCHAR(20) NOT NULL PRIMARY KEY, "last_update" DATETIME NOT NULL, "grid" JSON)'
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator, database, fake=False, **kwargs):
|
||||
pass
|
||||
40
migrations/020_update_index_recordings.py
Normal file
40
migrations/020_update_index_recordings.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Peewee migrations -- 020_update_index_recordings.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['model_name'] # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.python(func, *args, **kwargs) # Run python code
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
|
||||
"""
|
||||
import peewee as pw
|
||||
|
||||
SQL = pw.SQL
|
||||
|
||||
|
||||
def migrate(migrator, database, fake=False, **kwargs):
|
||||
migrator.sql("DROP INDEX recordings_end_time_start_time")
|
||||
migrator.sql(
|
||||
'CREATE INDEX "recordings_camera_start_time_end_time" ON "recordings" ("camera", "start_time" DESC, "end_time" DESC)'
|
||||
)
|
||||
migrator.sql(
|
||||
'CREATE INDEX "recordings_api_recordings_summary" ON "recordings" ("camera", "start_time" DESC, "duration", "motion", "objects")'
|
||||
)
|
||||
migrator.sql('CREATE INDEX "recordings_start_time" ON "recordings" ("start_time")')
|
||||
|
||||
|
||||
def rollback(migrator, database, fake=False, **kwargs):
|
||||
pass
|
||||
@@ -20,7 +20,6 @@
|
||||
},
|
||||
"ignorePatterns": ["*.d.ts"],
|
||||
"rules": {
|
||||
"indent": ["error", 2, { "SwitchCase": 1 }],
|
||||
"comma-dangle": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { rest } from 'msw';
|
||||
import { API_HOST } from '../src/env';
|
||||
// import { API_HOST } from '../src/env';
|
||||
|
||||
export const handlers = [
|
||||
rest.get(`${API_HOST}api/config`, (req, res, ctx) => {
|
||||
rest.get(`api/config`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
@@ -37,7 +37,7 @@ export const handlers = [
|
||||
})
|
||||
);
|
||||
}),
|
||||
rest.get(`${API_HOST}api/stats`, (req, res, ctx) => {
|
||||
rest.get(`api/stats`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
@@ -58,7 +58,7 @@ export const handlers = [
|
||||
})
|
||||
);
|
||||
}),
|
||||
rest.get(`${API_HOST}api/events`, (req, res, ctx) => {
|
||||
rest.get(`api/events`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
@@ -77,7 +77,7 @@ export const handlers = [
|
||||
)
|
||||
);
|
||||
}),
|
||||
rest.get(`${API_HOST}api/sub_labels`, (req, res, ctx) => {
|
||||
rest.get(`api/sub_labels`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json([
|
||||
@@ -86,4 +86,19 @@ export const handlers = [
|
||||
])
|
||||
);
|
||||
}),
|
||||
rest.get(`api/labels`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json([
|
||||
'person',
|
||||
'car',
|
||||
])
|
||||
);
|
||||
}),
|
||||
rest.get(`api/go2rtc`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({"config_path":"/dev/shm/go2rtc.yaml","host":"frigate.yourdomain.local","rtsp":{"listen":"0.0.0.0:8554","default_query":"mp4","PacketSize":0},"version":"1.7.1"})
|
||||
);
|
||||
}),
|
||||
];
|
||||
|
||||
2589
web/package-lock.json
generated
2589
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
||||
"preact-router": "^4.1.0",
|
||||
"react": "npm:@preact/compat@^17.1.2",
|
||||
"react-dom": "npm:@preact/compat@^17.1.2",
|
||||
"react-use-websocket": "^3.0.0",
|
||||
"strftime": "^0.10.1",
|
||||
"swr": "^1.3.0",
|
||||
"video.js": "^8.5.2",
|
||||
@@ -45,8 +46,10 @@
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-jest": "^27.2.3",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-vitest-globals": "^1.4.0",
|
||||
"fake-indexeddb": "^4.0.1",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"jsdom": "^22.0.0",
|
||||
"msw": "^1.2.1",
|
||||
"postcss": "^8.4.29",
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function AppBar() {
|
||||
setShowDialog(false);
|
||||
setShowDialogWait(true);
|
||||
sendRestart();
|
||||
}, [setShowDialog]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [setShowDialog, sendRestart]);
|
||||
|
||||
const handleDismissRestartDialog = useCallback(() => {
|
||||
setShowDialog(false);
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export const ENV = 'test';
|
||||
export const API_HOST = 'http://base-url.local:5000/';
|
||||
|
||||
@@ -18,6 +18,6 @@ describe('useApiHost', () => {
|
||||
<Test />
|
||||
</ApiProvider>
|
||||
);
|
||||
expect(screen.queryByText('http://base-url.local:5000/')).toBeInTheDocument();
|
||||
expect(screen.queryByText('http://localhost:3000/')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/* eslint-disable jest/no-disabled-tests */
|
||||
import { h } from 'preact';
|
||||
import { WS, WsProvider, useWs } from '../ws';
|
||||
import { WS as frigateWS, WsProvider, useWs } from '../ws';
|
||||
import { useCallback, useContext } from 'preact/hooks';
|
||||
import { fireEvent, render, screen } from 'testing-library';
|
||||
import { WS } from 'jest-websocket-mock';
|
||||
|
||||
function Test() {
|
||||
const { state } = useContext(WS);
|
||||
const { state } = useContext(frigateWS);
|
||||
return state.__connected ? (
|
||||
<div data-testid="data">
|
||||
{Object.keys(state).map((key) => (
|
||||
@@ -19,44 +21,32 @@ function Test() {
|
||||
const TEST_URL = 'ws://test-foo:1234/ws';
|
||||
|
||||
describe('WsProvider', () => {
|
||||
let createWebsocket, wsClient;
|
||||
beforeEach(() => {
|
||||
let wsClient, wsServer;
|
||||
beforeEach(async () => {
|
||||
wsClient = {
|
||||
close: vi.fn(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
createWebsocket = vi.fn((url) => {
|
||||
wsClient.args = [url];
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop, _receiver) {
|
||||
return wsClient[prop];
|
||||
},
|
||||
set(_target, prop, value) {
|
||||
wsClient[prop] = typeof value === 'function' ? vi.fn(value) : value;
|
||||
if (prop === 'onopen') {
|
||||
wsClient[prop]();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
wsServer = new WS(TEST_URL);
|
||||
});
|
||||
|
||||
test('connects to the ws server', async () => {
|
||||
afterEach(() => {
|
||||
WS.clean();
|
||||
});
|
||||
|
||||
test.skip('connects to the ws server', async () => {
|
||||
render(
|
||||
<WsProvider config={mockConfig} createWebsocket={createWebsocket} wsUrl={TEST_URL}>
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByTestId('data');
|
||||
expect(wsClient.args).toEqual([TEST_URL]);
|
||||
expect(screen.getByTestId('__connected')).toHaveTextContent('true');
|
||||
});
|
||||
|
||||
test('receives data through useWs', async () => {
|
||||
test.skip('receives data through useWs', async () => {
|
||||
function Test() {
|
||||
const {
|
||||
value: { payload, retain },
|
||||
@@ -71,16 +61,17 @@ describe('WsProvider', () => {
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<WsProvider config={mockConfig} createWebsocket={createWebsocket} wsUrl={TEST_URL}>
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByTestId('payload');
|
||||
wsClient.onmessage({
|
||||
data: JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }), retain: false }),
|
||||
});
|
||||
rerender(
|
||||
<WsProvider config={mockConfig} createWebsocket={createWebsocket} wsUrl={TEST_URL}>
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
@@ -88,7 +79,7 @@ describe('WsProvider', () => {
|
||||
expect(screen.getByTestId('retain')).toHaveTextContent('false');
|
||||
});
|
||||
|
||||
test('can send values through useWs', async () => {
|
||||
test.skip('can send values through useWs', async () => {
|
||||
function Test() {
|
||||
const { send, connected } = useWs('tacos');
|
||||
const handleClick = useCallback(() => {
|
||||
@@ -98,10 +89,11 @@ describe('WsProvider', () => {
|
||||
}
|
||||
|
||||
render(
|
||||
<WsProvider config={mockConfig} createWebsocket={createWebsocket} wsUrl={TEST_URL}>
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByRole('button');
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
await expect(wsClient.send).toHaveBeenCalledWith(
|
||||
@@ -109,19 +101,32 @@ describe('WsProvider', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('prefills the recordings/detect/snapshots state from config', async () => {
|
||||
test.skip('prefills the recordings/detect/snapshots state from config', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(123456);
|
||||
const config = {
|
||||
cameras: {
|
||||
front: { name: 'front', detect: { enabled: true }, record: { enabled: false }, snapshots: { enabled: true }, audio: { enabled: false } },
|
||||
side: { name: 'side', detect: { enabled: false }, record: { enabled: false }, snapshots: { enabled: false }, audio: { enabled: false } },
|
||||
front: {
|
||||
name: 'front',
|
||||
detect: { enabled: true },
|
||||
record: { enabled: false },
|
||||
snapshots: { enabled: true },
|
||||
audio: { enabled: false },
|
||||
},
|
||||
side: {
|
||||
name: 'side',
|
||||
detect: { enabled: false },
|
||||
record: { enabled: false },
|
||||
snapshots: { enabled: false },
|
||||
audio: { enabled: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
render(
|
||||
<WsProvider config={config} createWebsocket={createWebsocket} wsUrl={TEST_URL}>
|
||||
<WsProvider config={config} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByTestId('data');
|
||||
expect(screen.getByTestId('front/detect/state')).toHaveTextContent(
|
||||
'{"lastUpdate":123456,"payload":"ON","retain":false}'
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
import { API_HOST } from '../env';
|
||||
export const baseUrl = API_HOST || `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`;
|
||||
export const baseUrl = `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user