diff --git a/.cspell/frigate-dictionary.txt b/.cspell/frigate-dictionary.txt index 64fd7ca72..cc6adcc02 100644 --- a/.cspell/frigate-dictionary.txt +++ b/.cspell/frigate-dictionary.txt @@ -2,6 +2,7 @@ aarch absdiff airockchip Alloc +alpr Amcrest amdgpu analyzeduration @@ -61,6 +62,7 @@ dsize dtype ECONNRESET edgetpu +facenet fastapi faststart fflags @@ -114,6 +116,8 @@ itemsize Jellyfin jetson jetsons +jina +jinaai joserfc jsmpeg jsonify @@ -187,6 +191,7 @@ openai opencv openvino OWASP +paddleocr paho passwordless popleft @@ -308,4 +313,4 @@ yolo yolonas yolox zeep -zerolatency +zerolatency \ No newline at end of file diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 793ea7d42..724af45a5 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -33,9 +33,9 @@ runs: with: string: ${{ github.repository }} - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Log in to the Container registry uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 351911cb1..bf5763c00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ env: jobs: amd64_build: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 name: AMD64 Build steps: - name: Check out code @@ -42,7 +42,7 @@ jobs: tags: ${{ steps.setup.outputs.image-name }}-amd64 cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64 arm64_build: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 name: ARM Build steps: - name: Check out code @@ -66,8 +66,9 @@ jobs: ${{ steps.setup.outputs.image-name }}-standard-arm64 cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64 - name: Build and push RPi build - uses: docker/bake-action@v4 + uses: docker/bake-action@v6 with: + source: . push: true targets: rpi files: docker/rpi/rpi.hcl @@ -76,7 +77,8 @@ jobs: *.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64 *.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64,mode=max jetson_jp4_build: - runs-on: ubuntu-latest + if: false + runs-on: ubuntu-22.04 name: Jetson Jetpack 4 steps: - name: Check out code @@ -94,8 +96,9 @@ jobs: BASE_IMAGE: timongentzsch/l4t-ubuntu20-opencv:latest SLIM_BASE: timongentzsch/l4t-ubuntu20-opencv:latest TRT_BASE: timongentzsch/l4t-ubuntu20-opencv:latest - uses: docker/bake-action@v4 + uses: docker/bake-action@v6 with: + source: . push: true targets: tensorrt files: docker/tensorrt/trt.hcl @@ -104,7 +107,8 @@ jobs: *.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp4 *.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp4,mode=max jetson_jp5_build: - runs-on: ubuntu-latest + if: false + runs-on: ubuntu-22.04 name: Jetson Jetpack 5 steps: - name: Check out code @@ -122,8 +126,9 @@ jobs: BASE_IMAGE: nvcr.io/nvidia/l4t-tensorrt:r8.5.2-runtime SLIM_BASE: nvcr.io/nvidia/l4t-tensorrt:r8.5.2-runtime TRT_BASE: nvcr.io/nvidia/l4t-tensorrt:r8.5.2-runtime - uses: docker/bake-action@v4 + uses: docker/bake-action@v6 with: + source: . push: true targets: tensorrt files: docker/tensorrt/trt.hcl @@ -132,7 +137,7 @@ jobs: *.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp5 *.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp5,mode=max amd64_extra_builds: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 name: AMD64 Extra Build needs: - amd64_build @@ -149,8 +154,9 @@ jobs: - name: Build and push TensorRT (x86 GPU) env: COMPUTE_LEVEL: "50 60 70 80 90" - uses: docker/bake-action@v4 + uses: docker/bake-action@v6 with: + source: . push: true targets: tensorrt files: docker/tensorrt/trt.hcl @@ -158,8 +164,21 @@ jobs: tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt *.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64 *.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64,mode=max + - name: AMD/ROCm general build + env: + AMDGPU: gfx + HSA_OVERRIDE: 0 + uses: docker/bake-action@v6 + with: + source: . + push: true + targets: rocm + files: docker/rocm/rocm.hcl + set: | + rocm.tags=${{ steps.setup.outputs.image-name }}-rocm + *.cache-from=type=gha arm64_extra_builds: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 name: ARM Extra Build needs: - arm64_build @@ -174,8 +193,9 @@ jobs: with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Rockchip build - uses: docker/bake-action@v3 + uses: docker/bake-action@v6 with: + source: . push: true targets: rk files: docker/rockchip/rk.hcl @@ -183,7 +203,7 @@ jobs: rk.tags=${{ steps.setup.outputs.image-name }}-rk *.cache-from=type=gha combined_extra_builds: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 name: Combined Extra Builds needs: - amd64_build @@ -199,8 +219,9 @@ jobs: with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Hailo-8l build - uses: docker/bake-action@v4 + uses: docker/bake-action@v6 with: + source: . push: true targets: h8l files: docker/hailo8l/h8l.hcl @@ -208,22 +229,10 @@ jobs: h8l.tags=${{ steps.setup.outputs.image-name }}-h8l *.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-h8l *.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-h8l,mode=max - - name: AMD/ROCm general build - env: - AMDGPU: gfx - HSA_OVERRIDE: 0 - uses: docker/bake-action@v3 - with: - push: true - targets: rocm - files: docker/rocm/rocm.hcl - set: | - rocm.tags=${{ steps.setup.outputs.image-name }}-rocm - *.cache-from=type=gha # The majority of users running arm64 are rpi users, so the rpi # build should be the primary arm64 image assemble_default_build: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 name: Assemble and push default build needs: - amd64_build diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index cea238eab..6c773e5f9 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -6,7 +6,7 @@ on: - "docs/**" env: - DEFAULT_PYTHON: 3.9 + DEFAULT_PYTHON: 3.11 jobs: build_devcontainer: diff --git a/Makefile b/Makefile index b7c6ab821..5500174af 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ default_target: local COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1) -VERSION = 0.15.0 +VERSION = 0.16.0 IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) BOARDS= #Initialized empty diff --git a/benchmark.py b/benchmark.py index 5c0c68419..7db09a5d7 100755 --- a/benchmark.py +++ b/benchmark.py @@ -61,7 +61,7 @@ def start(id, num_detections, detection_queue, event): object_detector.cleanup() print(f"{id} - Processed for {duration:.2f} seconds.") print(f"{id} - FPS: {object_detector.fps.eps():.2f}") - print(f"{id} - Average frame processing time: {mean(frame_times)*1000:.2f}ms") + print(f"{id} - Average frame processing time: {mean(frame_times) * 1000:.2f}ms") ###### diff --git a/docker/hailo8l/Dockerfile b/docker/hailo8l/Dockerfile index 959e7692e..33b5b08f0 100644 --- a/docker/hailo8l/Dockerfile +++ b/docker/hailo8l/Dockerfile @@ -5,6 +5,7 @@ ARG DEBIAN_FRONTEND=noninteractive # Build Python wheels FROM wheels AS h8l-wheels +RUN python3 -m pip config set global.break-system-packages true COPY docker/main/requirements-wheels.txt /requirements-wheels.txt COPY docker/hailo8l/requirements-wheels-h8l.txt /requirements-wheels-h8l.txt @@ -30,6 +31,7 @@ COPY --from=hailort /hailo-wheels /deps/hailo-wheels COPY --from=hailort /rootfs/ / # Install the wheels +RUN python3 -m pip config set global.break-system-packages true RUN pip3 install -U /deps/h8l-wheels/*.whl RUN pip3 install -U /deps/hailo-wheels/*.whl diff --git a/docker/hailo8l/install_hailort.sh b/docker/hailo8l/install_hailort.sh index 62eba9611..c0198b34c 100755 --- a/docker/hailo8l/install_hailort.sh +++ b/docker/hailo8l/install_hailort.sh @@ -2,7 +2,7 @@ set -euxo pipefail -hailo_version="4.19.0" +hailo_version="4.20.0" if [[ "${TARGETARCH}" == "amd64" ]]; then arch="x86_64" @@ -15,5 +15,5 @@ wget -qO- "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_ver mkdir -p /hailo-wheels -wget -P /hailo-wheels/ "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp39-cp39-linux_${arch}.whl" +wget -P /hailo-wheels/ "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl" diff --git a/docker/hailo8l/user_installation.sh b/docker/hailo8l/user_installation.sh index 2cf44126f..f1d92b5d5 100644 --- a/docker/hailo8l/user_installation.sh +++ b/docker/hailo8l/user_installation.sh @@ -4,6 +4,7 @@ sudo apt-get update sudo apt-get install -y build-essential cmake git wget +hailo_version="4.20.0" arch=$(uname -m) if [[ $arch == "x86_64" ]]; then @@ -13,7 +14,7 @@ else fi # Clone the HailoRT driver repository -git clone --depth 1 --branch v4.19.0 https://github.com/hailo-ai/hailort-drivers.git +git clone --depth 1 --branch v${hailo_version} https://github.com/hailo-ai/hailort-drivers.git # Build and install the HailoRT driver cd hailort-drivers/linux/pcie diff --git a/docker/main/Dockerfile b/docker/main/Dockerfile index 9d2f442f0..e6dee8380 100644 --- a/docker/main/Dockerfile +++ b/docker/main/Dockerfile @@ -3,12 +3,12 @@ # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND=noninteractive -ARG BASE_IMAGE=debian:11 -ARG SLIM_BASE=debian:11-slim +ARG BASE_IMAGE=debian:12 +ARG SLIM_BASE=debian:12-slim FROM ${BASE_IMAGE} AS base -FROM --platform=${BUILDPLATFORM} debian:11 AS base_host +FROM --platform=${BUILDPLATFORM} debian:12 AS base_host FROM ${SLIM_BASE} AS slim-base @@ -66,8 +66,8 @@ COPY docker/main/requirements-ov.txt /requirements-ov.txt RUN apt-get -qq update \ && apt-get -qq install -y wget python3 python3-dev python3-distutils gcc pkg-config libhdf5-dev \ && wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ - && python3 get-pip.py "pip" \ - && pip install -r /requirements-ov.txt + && python3 get-pip.py "pip" --break-system-packages \ + && pip install --break-system-packages -r /requirements-ov.txt # Get OpenVino Model RUN --mount=type=bind,source=docker/main/build_ov_model.py,target=/build_ov_model.py \ @@ -139,24 +139,17 @@ ARG TARGETARCH # Use a separate container to build wheels to prevent build dependencies in final image RUN apt-get -qq update \ && apt-get -qq install -y \ - apt-transport-https \ - gnupg \ - wget \ - # 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-transport-https wget \ && apt-get -qq update \ && apt-get -qq install -y \ - python3.9 \ - python3.9-dev \ + python3 \ + python3-dev \ # opencv dependencies build-essential cmake git pkg-config libgtk-3-dev \ libavcodec-dev libavformat-dev libswscale-dev libv4l-dev \ libxvidcore-dev libx264-dev libjpeg-dev libpng-dev libtiff-dev \ gfortran openexr libatlas-base-dev libssl-dev\ - libtbb2 libtbb-dev libdc1394-22-dev libopenexr-dev \ + libtbbmalloc2 libtbb-dev libdc1394-dev libopenexr-dev \ libgstreamer-plugins-base1.0-dev libgstreamer1.0-dev \ # sqlite3 dependencies tclsh \ @@ -164,14 +157,11 @@ RUN apt-get -qq update \ gcc gfortran libopenblas-dev liblapack-dev && \ rm -rf /var/lib/apt/lists/* -# Ensure python3 defaults to python3.9 -RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.9 1 - RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ - && python3 get-pip.py "pip" + && python3 get-pip.py "pip" --break-system-packages COPY docker/main/requirements.txt /requirements.txt -RUN pip3 install -r /requirements.txt +RUN pip3 install -r /requirements.txt --break-system-packages # Build pysqlite3 from source COPY docker/main/build_pysqlite3.sh /build_pysqlite3.sh @@ -215,15 +205,14 @@ ENV TRANSFORMERS_NO_ADVISORY_WARNINGS=1 ENV OPENCV_FFMPEG_LOGLEVEL=8 ENV PATH="/usr/local/go2rtc/bin:/usr/local/tempio/bin:/usr/local/nginx/sbin:${PATH}" -ENV LIBAVFORMAT_VERSION_MAJOR=60 # Install dependencies RUN --mount=type=bind,source=docker/main/install_deps.sh,target=/deps/install_deps.sh \ /deps/install_deps.sh RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \ - python3 -m pip install --upgrade pip && \ - pip3 install -U /deps/wheels/*.whl + python3 -m pip install --upgrade pip --break-system-packages && \ + pip3 install -U /deps/wheels/*.whl --break-system-packages COPY --from=deps-rootfs / / @@ -270,7 +259,7 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* RUN --mount=type=bind,source=./docker/main/requirements-dev.txt,target=/workspace/frigate/requirements-dev.txt \ - pip3 install -r requirements-dev.txt + pip3 install -r requirements-dev.txt --break-system-packages HEALTHCHECK NONE diff --git a/docker/main/build_nginx.sh b/docker/main/build_nginx.sh index e97f6bbe0..2591810e3 100755 --- a/docker/main/build_nginx.sh +++ b/docker/main/build_nginx.sh @@ -8,10 +8,16 @@ SECURE_TOKEN_MODULE_VERSION="1.5" SET_MISC_MODULE_VERSION="v0.33" NGX_DEVEL_KIT_VERSION="v0.3.3" -cp /etc/apt/sources.list /etc/apt/sources.list.d/sources-src.list -sed -i 's|deb http|deb-src http|g' /etc/apt/sources.list.d/sources-src.list -apt-get update +source /etc/os-release +if [[ "$VERSION_ID" == "12" ]]; then + sed -i '/^Types:/s/deb/& deb-src/' /etc/apt/sources.list.d/debian.sources +else + cp /etc/apt/sources.list /etc/apt/sources.list.d/sources-src.list + sed -i 's|deb http|deb-src http|g' /etc/apt/sources.list.d/sources-src.list +fi + +apt-get update apt-get -yqq build-dep nginx apt-get -yqq install --no-install-recommends ca-certificates wget diff --git a/docker/main/build_ov_model.py b/docker/main/build_ov_model.py index 9e110ad9f..2888d87a8 100644 --- a/docker/main/build_ov_model.py +++ b/docker/main/build_ov_model.py @@ -4,7 +4,7 @@ from openvino.tools import mo ov_model = mo.convert_model( "/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb", compress_to_fp16=True, - transformations_config="/usr/local/lib/python3.9/dist-packages/openvino/tools/mo/front/tf/ssd_v2_support.json", + transformations_config="/usr/local/lib/python3.11/dist-packages/openvino/tools/mo/front/tf/ssd_v2_support.json", tensorflow_object_detection_api_pipeline_config="/models/ssdlite_mobilenet_v2_coco_2018_05_09/pipeline.config", reverse_input_channels=True, ) diff --git a/docker/main/build_sqlite_vec.sh b/docker/main/build_sqlite_vec.sh index 3dc28bcbf..b41f3383d 100755 --- a/docker/main/build_sqlite_vec.sh +++ b/docker/main/build_sqlite_vec.sh @@ -4,8 +4,15 @@ set -euxo pipefail SQLITE_VEC_VERSION="0.1.3" -cp /etc/apt/sources.list /etc/apt/sources.list.d/sources-src.list -sed -i 's|deb http|deb-src http|g' /etc/apt/sources.list.d/sources-src.list +source /etc/os-release + +if [[ "$VERSION_ID" == "12" ]]; then + sed -i '/^Types:/s/deb/& deb-src/' /etc/apt/sources.list.d/debian.sources +else + cp /etc/apt/sources.list /etc/apt/sources.list.d/sources-src.list + sed -i 's|deb http|deb-src http|g' /etc/apt/sources.list.d/sources-src.list +fi + apt-get update apt-get -yqq build-dep sqlite3 gettext git diff --git a/docker/main/install_deps.sh b/docker/main/install_deps.sh index 6c32ae168..f8f68398f 100755 --- a/docker/main/install_deps.sh +++ b/docker/main/install_deps.sh @@ -11,33 +11,34 @@ apt-get -qq install --no-install-recommends -y \ lbzip2 \ procps vainfo \ unzip locales tzdata libxml2 xz-utils \ - python3.9 \ + python3 \ python3-pip \ curl \ lsof \ jq \ - nethogs - -# ensure python3 defaults to python3.9 -update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.9 1 + nethogs \ + libgl1 \ + libglib2.0-0 \ + libusb-1.0.0 mkdir -p -m 600 /root/.gnupg -# add coral repo -curl -fsSLo - https://packages.cloud.google.com/apt/doc/apt-key.gpg | \ - gpg --dearmor -o /etc/apt/trusted.gpg.d/google-cloud-packages-archive-keyring.gpg -echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" | tee /etc/apt/sources.list.d/coral-edgetpu.list -echo "libedgetpu1-max libedgetpu/accepted-eula select true" | debconf-set-selections +# install coral runtime +wget -q -O /tmp/libedgetpu1-max.deb "https://github.com/feranick/libedgetpu/releases/download/16.0TF2.17.0-1/libedgetpu1-max_16.0tf2.17.0-1.bookworm_${TARGETARCH}.deb" +unset DEBIAN_FRONTEND +yes | dpkg -i /tmp/libedgetpu1-max.deb && export DEBIAN_FRONTEND=noninteractive +rm /tmp/libedgetpu1-max.deb -# enable non-free repo in Debian -if grep -q "Debian" /etc/issue; then - sed -i -e's/ main/ main contrib non-free/g' /etc/apt/sources.list +# install python3 & tflite runtime +if [[ "${TARGETARCH}" == "amd64" ]]; then + pip3 install --break-system-packages https://github.com/feranick/TFlite-builds/releases/download/v2.17.0/tflite_runtime-2.17.0-cp311-cp311-linux_x86_64.whl + pip3 install --break-system-packages https://github.com/feranick/pycoral/releases/download/2.0.2TF2.17.0/pycoral-2.0.2-cp311-cp311-linux_x86_64.whl fi -# coral drivers -apt-get -qq update -apt-get -qq install --no-install-recommends --no-install-suggests -y \ - libedgetpu1-max python3-tflite-runtime python3-pycoral +if [[ "${TARGETARCH}" == "arm64" ]]; then + pip3 install --break-system-packages https://github.com/feranick/TFlite-builds/releases/download/v2.17.0/tflite_runtime-2.17.0-cp311-cp311-linux_aarch64.whl + pip3 install --break-system-packages https://github.com/feranick/pycoral/releases/download/2.0.2TF2.17.0/pycoral-2.0.2-cp311-cp311-linux_aarch64.whl +fi # btbn-ffmpeg -> amd64 if [[ "${TARGETARCH}" == "amd64" ]]; then @@ -65,23 +66,15 @@ fi # arch specific packages if [[ "${TARGETARCH}" == "amd64" ]]; then - # use debian bookworm for amd / intel-i965 driver 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 + # install amd / intel-i965 driver packages apt-get -qq install --no-install-recommends --no-install-suggests -y \ i965-va-driver intel-gpu-tools onevpl-tools \ libva-drm2 \ mesa-va-drivers radeontop - # 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 - # intel packages use zst compression so we need to update dpkg apt-get install -y dpkg - rm -f /etc/apt/sources.list.d/debian-bookworm.list - # use intel apt intel packages wget -qO - https://repositories.intel.com/gpu/intel-graphics.key | gpg --yes --dearmor --output /usr/share/keyrings/intel-graphics.gpg echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/gpu/ubuntu jammy client" | tee /etc/apt/sources.list.d/intel-gpu-jammy.list diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 4db88ccd2..40a2f1d8b 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -1,3 +1,4 @@ +aiofiles == 24.1.* click == 8.1.* # FastAPI aiohttp == 3.11.2 @@ -10,10 +11,10 @@ imutils == 0.5.* joserfc == 1.0.* pathvalidate == 3.2.* markupsafe == 2.1.* +python-multipart == 0.0.12 +# General mypy == 1.6.1 -numpy == 1.26.* -onvif_zeep == 0.2.12 -opencv-python-headless == 4.9.0.* +onvif-zeep-async == 3.1.* paho-mqtt == 2.1.* pandas == 2.2.* peewee == 3.17.* @@ -27,15 +28,19 @@ ruamel.yaml == 0.18.* tzlocal == 5.2 requests == 2.32.* types-requests == 2.32.* -scipy == 1.13.* norfair == 2.2.* setproctitle == 1.3.* ws4py == 0.5.* unidecode == 1.3.* +# Image Manipulation +numpy == 1.26.* +opencv-python-headless == 4.10.0.* +opencv-contrib-python == 4.9.0.* +scipy == 1.14.* # OpenVino & ONNX -openvino == 2024.3.* -onnxruntime-openvino == 1.19.* ; platform_machine == 'x86_64' -onnxruntime == 1.19.* ; platform_machine == 'aarch64' +openvino == 2024.4.* +onnxruntime-openvino == 1.20.* ; platform_machine == 'x86_64' +onnxruntime == 1.20.* ; platform_machine == 'aarch64' # Embeddings transformers == 4.45.* # Generative AI @@ -45,3 +50,7 @@ openai == 1.51.* # push notifications py-vapid == 1.9.* pywebpush == 2.0.* +# alpr +pyclipper == 1.3.* +shapely == 2.0.* +prometheus-client == 0.21.* diff --git a/docker/main/requirements.txt b/docker/main/requirements.txt index 90780e2b4..3ae420d07 100644 --- a/docker/main/requirements.txt +++ b/docker/main/requirements.txt @@ -1,2 +1,2 @@ -scikit-build == 0.17.* +scikit-build == 0.18.* nvidia-pyindex diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate/run index eacce294f..e4a1b20e5 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate/run @@ -42,8 +42,14 @@ function migrate_db_path() { fi } +function set_libva_version() { + local ffmpeg_path=$(python3 /usr/local/ffmpeg/get_ffmpeg_path.py) + export LIBAVFORMAT_VERSION_MAJOR=$($ffmpeg_path -version | grep -Po "libavformat\W+\K\d+") +} + echo "[INFO] Preparing Frigate..." migrate_db_path +set_libva_version echo "[INFO] Starting Frigate..." cd /opt/frigate || echo "[ERROR] Failed to change working directory to /opt/frigate" diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc/run index 9c4922d81..90c26ceff 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc/run @@ -43,6 +43,11 @@ function get_ip_and_port_from_supervisor() { export FRIGATE_GO2RTC_WEBRTC_CANDIDATE_INTERNAL="${ip_address}:${webrtc_port}" } +function set_libva_version() { + local ffmpeg_path=$(python3 /usr/local/ffmpeg/get_ffmpeg_path.py) + export LIBAVFORMAT_VERSION_MAJOR=$($ffmpeg_path -version | grep -Po "libavformat\W+\K\d+") +} + if [[ -f "/dev/shm/go2rtc.yaml" ]]; then echo "[INFO] Removing stale config from last run..." rm /dev/shm/go2rtc.yaml @@ -61,6 +66,8 @@ else echo "[WARNING] Unable to remove existing go2rtc config. Changes made to your frigate config file may not be recognized. Please remove the /dev/shm/go2rtc.yaml from your docker host manually." fi +set_libva_version + readonly config_path="/config" if [[ -x "${config_path}/go2rtc" ]]; then diff --git a/docker/main/rootfs/usr/local/ffmpeg/get_ffmpeg_path.py b/docker/main/rootfs/usr/local/ffmpeg/get_ffmpeg_path.py new file mode 100644 index 000000000..27034bff9 --- /dev/null +++ b/docker/main/rootfs/usr/local/ffmpeg/get_ffmpeg_path.py @@ -0,0 +1,45 @@ +import json +import os +import shutil +import sys + +from ruamel.yaml import YAML + +sys.path.insert(0, "/opt/frigate") +from frigate.const import ( + DEFAULT_FFMPEG_VERSION, + INCLUDED_FFMPEG_VERSIONS, +) + +sys.path.remove("/opt/frigate") + +yaml = YAML() + +config_file = os.environ.get("CONFIG_FILE", "/config/config.yml") + +# Check if we can use .yaml instead of .yml +config_file_yaml = config_file.replace(".yml", ".yaml") +if os.path.isfile(config_file_yaml): + config_file = config_file_yaml + +try: + with open(config_file) as f: + raw_config = f.read() + + if config_file.endswith((".yaml", ".yml")): + config: dict[str, any] = yaml.load(raw_config) + elif config_file.endswith(".json"): + config: dict[str, any] = json.loads(raw_config) +except FileNotFoundError: + config: dict[str, any] = {} + +path = config.get("ffmpeg", {}).get("path", "default") +if path == "default": + if shutil.which("ffmpeg") is None: + print(f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg") + else: + print("ffmpeg") +elif path in INCLUDED_FFMPEG_VERSIONS: + print(f"/usr/lib/ffmpeg/{path}/bin/ffmpeg") +else: + print(f"{path}/bin/ffmpeg") diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 75527bf53..61b598859 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -81,6 +81,9 @@ http { open_file_cache_errors on; aio on; + # file upload size + client_max_body_size 10M; + # https://github.com/kaltura/nginx-vod-module#vod_open_file_thread_pool vod_open_file_thread_pool default; diff --git a/docker/rockchip/COCO/coco_subset_20.txt b/docker/rockchip/COCO/coco_subset_20.txt new file mode 100644 index 000000000..aa372fe7a --- /dev/null +++ b/docker/rockchip/COCO/coco_subset_20.txt @@ -0,0 +1,20 @@ +./subset/000000005001.jpg +./subset/000000038829.jpg +./subset/000000052891.jpg +./subset/000000075612.jpg +./subset/000000098261.jpg +./subset/000000181542.jpg +./subset/000000215245.jpg +./subset/000000277005.jpg +./subset/000000288685.jpg +./subset/000000301421.jpg +./subset/000000334371.jpg +./subset/000000348481.jpg +./subset/000000373353.jpg +./subset/000000397681.jpg +./subset/000000414673.jpg +./subset/000000419312.jpg +./subset/000000465822.jpg +./subset/000000475732.jpg +./subset/000000559707.jpg +./subset/000000574315.jpg \ No newline at end of file diff --git a/docker/rockchip/COCO/subset/000000005001.jpg b/docker/rockchip/COCO/subset/000000005001.jpg new file mode 100644 index 000000000..a7d4437ec Binary files /dev/null and b/docker/rockchip/COCO/subset/000000005001.jpg differ diff --git a/docker/rockchip/COCO/subset/000000038829.jpg b/docker/rockchip/COCO/subset/000000038829.jpg new file mode 100644 index 000000000..f275500e8 Binary files /dev/null and b/docker/rockchip/COCO/subset/000000038829.jpg differ diff --git a/docker/rockchip/COCO/subset/000000052891.jpg b/docker/rockchip/COCO/subset/000000052891.jpg new file mode 100644 index 000000000..57344ef00 Binary files /dev/null and b/docker/rockchip/COCO/subset/000000052891.jpg differ diff --git a/docker/rockchip/COCO/subset/000000075612.jpg b/docker/rockchip/COCO/subset/000000075612.jpg new file mode 100644 index 000000000..16555e4b6 Binary files /dev/null and b/docker/rockchip/COCO/subset/000000075612.jpg differ diff --git a/docker/rockchip/COCO/subset/000000098261.jpg b/docker/rockchip/COCO/subset/000000098261.jpg new file mode 100644 index 000000000..57412f7f3 Binary files /dev/null and b/docker/rockchip/COCO/subset/000000098261.jpg differ diff --git a/docker/rockchip/COCO/subset/000000181542.jpg b/docker/rockchip/COCO/subset/000000181542.jpg new file mode 100644 index 000000000..e3676d39d Binary files /dev/null and b/docker/rockchip/COCO/subset/000000181542.jpg differ diff --git a/docker/rockchip/COCO/subset/000000215245.jpg b/docker/rockchip/COCO/subset/000000215245.jpg new file mode 100644 index 000000000..624e4f1d0 Binary files /dev/null and b/docker/rockchip/COCO/subset/000000215245.jpg differ diff --git a/docker/rockchip/COCO/subset/000000277005.jpg b/docker/rockchip/COCO/subset/000000277005.jpg new file mode 100644 index 000000000..629cb6e61 Binary files /dev/null and b/docker/rockchip/COCO/subset/000000277005.jpg differ diff --git a/docker/rockchip/COCO/subset/000000288685.jpg b/docker/rockchip/COCO/subset/000000288685.jpg new file mode 100644 index 000000000..4dc759dad Binary files /dev/null and b/docker/rockchip/COCO/subset/000000288685.jpg differ diff --git a/docker/rockchip/COCO/subset/000000301421.jpg b/docker/rockchip/COCO/subset/000000301421.jpg new file mode 100644 index 000000000..2cbfa4e65 Binary files /dev/null and b/docker/rockchip/COCO/subset/000000301421.jpg differ diff --git a/docker/rockchip/COCO/subset/000000334371.jpg b/docker/rockchip/COCO/subset/000000334371.jpg new file mode 100644 index 000000000..b47ac6d2c Binary files /dev/null and b/docker/rockchip/COCO/subset/000000334371.jpg differ diff --git a/docker/rockchip/COCO/subset/000000348481.jpg b/docker/rockchip/COCO/subset/000000348481.jpg new file mode 100644 index 000000000..a2cb75cc0 Binary files /dev/null and b/docker/rockchip/COCO/subset/000000348481.jpg differ diff --git a/docker/rockchip/COCO/subset/000000373353.jpg b/docker/rockchip/COCO/subset/000000373353.jpg new file mode 100644 index 000000000..c09251120 Binary files /dev/null and b/docker/rockchip/COCO/subset/000000373353.jpg differ diff --git a/docker/rockchip/COCO/subset/000000397681.jpg b/docker/rockchip/COCO/subset/000000397681.jpg new file mode 100644 index 000000000..5b9425914 Binary files /dev/null and b/docker/rockchip/COCO/subset/000000397681.jpg differ diff --git a/docker/rockchip/COCO/subset/000000414673.jpg b/docker/rockchip/COCO/subset/000000414673.jpg new file mode 100644 index 000000000..587c370a1 Binary files /dev/null and b/docker/rockchip/COCO/subset/000000414673.jpg differ diff --git a/docker/rockchip/COCO/subset/000000419312.jpg b/docker/rockchip/COCO/subset/000000419312.jpg new file mode 100644 index 000000000..274ea879a Binary files /dev/null and b/docker/rockchip/COCO/subset/000000419312.jpg differ diff --git a/docker/rockchip/COCO/subset/000000465822.jpg b/docker/rockchip/COCO/subset/000000465822.jpg new file mode 100644 index 000000000..3510d113a Binary files /dev/null and b/docker/rockchip/COCO/subset/000000465822.jpg differ diff --git a/docker/rockchip/COCO/subset/000000475732.jpg b/docker/rockchip/COCO/subset/000000475732.jpg new file mode 100644 index 000000000..51d96851b Binary files /dev/null and b/docker/rockchip/COCO/subset/000000475732.jpg differ diff --git a/docker/rockchip/COCO/subset/000000559707.jpg b/docker/rockchip/COCO/subset/000000559707.jpg new file mode 100644 index 000000000..4811ef1c2 Binary files /dev/null and b/docker/rockchip/COCO/subset/000000559707.jpg differ diff --git a/docker/rockchip/COCO/subset/000000574315.jpg b/docker/rockchip/COCO/subset/000000574315.jpg new file mode 100644 index 000000000..ad06b6dba Binary files /dev/null and b/docker/rockchip/COCO/subset/000000574315.jpg differ diff --git a/docker/rockchip/Dockerfile b/docker/rockchip/Dockerfile index e1b43c255..e9c9602a8 100644 --- a/docker/rockchip/Dockerfile +++ b/docker/rockchip/Dockerfile @@ -7,21 +7,26 @@ 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 sed -i "/onnxruntime/d" /requirements-wheels.txt +RUN python3 -m pip config set global.break-system-packages true RUN pip3 wheel --wheel-dir=/rk-wheels -c /requirements-wheels.txt -r /requirements-wheels-rk.txt +RUN rm -rf /rk-wheels/opencv_python-* FROM deps AS rk-frigate ARG TARGETARCH RUN --mount=type=bind,from=rk-wheels,source=/rk-wheels,target=/deps/rk-wheels \ - pip3 install -U /deps/rk-wheels/*.whl + pip3 install --no-deps -U /deps/rk-wheels/*.whl --break-system-packages WORKDIR /opt/frigate/ COPY --from=rootfs / / +COPY docker/rockchip/COCO /COCO +COPY docker/rockchip/conv2rknn.py /opt/conv2rknn.py -ADD https://github.com/MarcA711/rknn-toolkit2/releases/download/v2.0.0/librknnrt.so /usr/lib/ +ADD https://github.com/MarcA711/rknn-toolkit2/releases/download/v2.3.0/librknnrt.so /usr/lib/ RUN rm -rf /usr/lib/btbn-ffmpeg/bin/ffmpeg RUN rm -rf /usr/lib/btbn-ffmpeg/bin/ffprobe -ADD --chmod=111 https://github.com/MarcA711/Rockchip-FFmpeg-Builds/releases/download/6.1-5/ffmpeg /usr/lib/ffmpeg/6.0/bin/ -ADD --chmod=111 https://github.com/MarcA711/Rockchip-FFmpeg-Builds/releases/download/6.1-5/ffprobe /usr/lib/ffmpeg/6.0/bin/ +ADD --chmod=111 https://github.com/MarcA711/Rockchip-FFmpeg-Builds/releases/download/6.1-7/ffmpeg /usr/lib/ffmpeg/6.0/bin/ +ADD --chmod=111 https://github.com/MarcA711/Rockchip-FFmpeg-Builds/releases/download/6.1-7/ffprobe /usr/lib/ffmpeg/6.0/bin/ ENV PATH="/usr/lib/ffmpeg/6.0/bin/:${PATH}" diff --git a/docker/rockchip/conv2rknn.py b/docker/rockchip/conv2rknn.py new file mode 100644 index 000000000..4f4a315e1 --- /dev/null +++ b/docker/rockchip/conv2rknn.py @@ -0,0 +1,82 @@ +import os + +import rknn +import yaml +from rknn.api import RKNN + +try: + with open(rknn.__path__[0] + "/VERSION") as file: + tk_version = file.read().strip() +except FileNotFoundError: + pass + +try: + with open("/config/conv2rknn.yaml", "r") as config_file: + configuration = yaml.safe_load(config_file) +except FileNotFoundError: + raise Exception("Please place a config.yaml file in /config/conv2rknn.yaml") + +if configuration["config"] != None: + rknn_config = configuration["config"] +else: + rknn_config = {} + +if not os.path.isdir("/config/model_cache/rknn_cache/onnx"): + raise Exception( + "Place the onnx models you want to convert to rknn format in /config/model_cache/rknn_cache/onnx" + ) + +if "soc" not in configuration: + try: + with open("/proc/device-tree/compatible") as file: + soc = file.read().split(",")[-1].strip("\x00") + except FileNotFoundError: + raise Exception("Make sure to run docker in privileged mode.") + + configuration["soc"] = [ + soc, + ] + +if "quantization" not in configuration: + configuration["quantization"] = False + +if "output_name" not in configuration: + configuration["output_name"] = "{{input_basename}}" + +for input_filename in os.listdir("/config/model_cache/rknn_cache/onnx"): + for soc in configuration["soc"]: + quant = "i8" if configuration["quantization"] else "fp16" + + input_path = "/config/model_cache/rknn_cache/onnx/" + input_filename + input_basename = input_filename[: input_filename.rfind(".")] + + output_filename = ( + configuration["output_name"].format( + quant=quant, + input_basename=input_basename, + soc=soc, + tk_version=tk_version, + ) + + ".rknn" + ) + output_path = "/config/model_cache/rknn_cache/" + output_filename + + rknn_config["target_platform"] = soc + + rknn = RKNN(verbose=True) + rknn.config(**rknn_config) + + if rknn.load_onnx(model=input_path) != 0: + raise Exception("Error loading model.") + + if ( + rknn.build( + do_quantization=configuration["quantization"], + dataset="/COCO/coco_subset_20.txt", + ) + != 0 + ): + raise Exception("Error building model.") + + if rknn.export_rknn(output_path) != 0: + raise Exception("Error exporting rknn model.") diff --git a/docker/rockchip/requirements-wheels-rk.txt b/docker/rockchip/requirements-wheels-rk.txt index c56b69b66..8d5b5efe0 100644 --- a/docker/rockchip/requirements-wheels-rk.txt +++ b/docker/rockchip/requirements-wheels-rk.txt @@ -1 +1,2 @@ -rknn-toolkit-lite2 @ https://github.com/MarcA711/rknn-toolkit2/releases/download/v2.0.0/rknn_toolkit_lite2-2.0.0b0-cp39-cp39-linux_aarch64.whl \ No newline at end of file +rknn-toolkit2 == 2.3.0 +rknn-toolkit-lite2 == 2.3.0 \ No newline at end of file diff --git a/docker/rocm/Dockerfile b/docker/rocm/Dockerfile index eebe04878..34c7efffb 100644 --- a/docker/rocm/Dockerfile +++ b/docker/rocm/Dockerfile @@ -34,7 +34,7 @@ RUN mkdir -p /opt/rocm-dist/etc/ld.so.conf.d/ RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf ####################################################################### -FROM --platform=linux/amd64 debian:11 as debian-base +FROM --platform=linux/amd64 debian:12 as debian-base RUN apt-get update && apt-get -y upgrade RUN apt-get -y install --no-install-recommends libelf1 libdrm2 libdrm-amdgpu1 libnuma1 kmod @@ -51,7 +51,7 @@ COPY --from=rocm /opt/rocm-$ROCM /opt/rocm-$ROCM RUN ln -s /opt/rocm-$ROCM /opt/rocm RUN apt-get -y install g++ cmake -RUN apt-get -y install python3-pybind11 python3.9-distutils python3-dev +RUN apt-get -y install python3-pybind11 python3-distutils python3-dev WORKDIR /opt/build @@ -70,10 +70,11 @@ RUN apt-get -y install libnuma1 WORKDIR /opt/frigate/ COPY --from=rootfs / / -COPY docker/rocm/requirements-wheels-rocm.txt /requirements.txt -RUN python3 -m pip install --upgrade pip \ - && pip3 uninstall -y onnxruntime-openvino \ - && pip3 install -r /requirements.txt +# Temporarily disabled to see if a new wheel can be built to support py3.11 +#COPY docker/rocm/requirements-wheels-rocm.txt /requirements.txt +#RUN python3 -m pip install --upgrade pip \ +# && pip3 uninstall -y onnxruntime-openvino \ +# && pip3 install -r /requirements.txt ####################################################################### FROM scratch AS rocm-dist @@ -86,12 +87,12 @@ COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*$AMDGPU* /opt/rocm-$ROCM/share COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx908* /opt/rocm-$ROCM/share/miopen/db/ COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*$AMDGPU* /opt/rocm-$ROCM/lib/rocblas/library/ COPY --from=rocm /opt/rocm-dist/ / -COPY --from=debian-build /opt/rocm/lib/migraphx.cpython-39-x86_64-linux-gnu.so /opt/rocm-$ROCM/lib/ +COPY --from=debian-build /opt/rocm/lib/migraphx.cpython-311-x86_64-linux-gnu.so /opt/rocm-$ROCM/lib/ ####################################################################### FROM deps-prelim AS rocm-prelim-hsa-override0 - -ENV HSA_ENABLE_SDMA=0 +\ + ENV HSA_ENABLE_SDMA=0 COPY --from=rocm-dist / / diff --git a/docker/rpi/Dockerfile b/docker/rpi/Dockerfile index 9860e65ec..581ca7ff8 100644 --- a/docker/rpi/Dockerfile +++ b/docker/rpi/Dockerfile @@ -12,7 +12,5 @@ RUN rm -rf /usr/lib/btbn-ffmpeg/ RUN --mount=type=bind,source=docker/rpi/install_deps.sh,target=/deps/install_deps.sh \ /deps/install_deps.sh -ENV LIBAVFORMAT_VERSION_MAJOR=58 - WORKDIR /opt/frigate/ COPY --from=rootfs / / diff --git a/docker/rpi/install_deps.sh b/docker/rpi/install_deps.sh index 9716623ca..ed34389e5 100755 --- a/docker/rpi/install_deps.sh +++ b/docker/rpi/install_deps.sh @@ -18,13 +18,14 @@ apt-get -qq install --no-install-recommends -y \ mkdir -p -m 600 /root/.gnupg # enable non-free repo -sed -i -e's/ main/ main contrib non-free/g' /etc/apt/sources.list +echo "deb http://deb.debian.org/debian bookworm main contrib non-free non-free-firmware" | tee -a /etc/apt/sources.list +apt update # ffmpeg -> arm64 if [[ "${TARGETARCH}" == "arm64" ]]; then # add raspberry pi repo gpg --no-default-keyring --keyring /usr/share/keyrings/raspbian.gpg --keyserver keyserver.ubuntu.com --recv-keys 82B129927FA3303E - echo "deb [signed-by=/usr/share/keyrings/raspbian.gpg] https://archive.raspberrypi.org/debian/ bullseye main" | tee /etc/apt/sources.list.d/raspi.list + echo "deb [signed-by=/usr/share/keyrings/raspbian.gpg] https://archive.raspberrypi.org/debian/ bookworm main" | tee /etc/apt/sources.list.d/raspi.list apt-get -qq update apt-get -qq install --no-install-recommends --no-install-suggests -y ffmpeg fi diff --git a/docker/tensorrt/Dockerfile.amd64 b/docker/tensorrt/Dockerfile.amd64 index 745d0d350..276094ed2 100644 --- a/docker/tensorrt/Dockerfile.amd64 +++ b/docker/tensorrt/Dockerfile.amd64 @@ -7,18 +7,19 @@ ARG DEBIAN_FRONTEND=noninteractive FROM wheels as trt-wheels ARG DEBIAN_FRONTEND ARG TARGETARCH +RUN python3 -m pip config set global.break-system-packages true # Add TensorRT wheels to another folder COPY docker/tensorrt/requirements-amd64.txt /requirements-tensorrt.txt RUN mkdir -p /trt-wheels && pip3 wheel --wheel-dir=/trt-wheels -r /requirements-tensorrt.txt FROM tensorrt-base AS frigate-tensorrt -ENV TRT_VER=8.5.3 +ENV TRT_VER=8.6.1 +RUN python3 -m pip config set global.break-system-packages true RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \ - pip3 install -U /deps/trt-wheels/*.whl && \ + pip3 install -U /deps/trt-wheels/*.whl --break-system-packages && \ ldconfig -ENV LD_LIBRARY_PATH=/usr/local/lib/python3.9/dist-packages/tensorrt:/usr/local/cuda/lib64:/usr/local/lib/python3.9/dist-packages/nvidia/cufft/lib WORKDIR /opt/frigate/ COPY --from=rootfs / / @@ -31,4 +32,4 @@ COPY --from=trt-deps /usr/local/cuda-12.1 /usr/local/cuda COPY docker/tensorrt/detector/rootfs/ / COPY --from=trt-deps /usr/local/lib/libyolo_layer.so /usr/local/lib/libyolo_layer.so RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \ - pip3 install -U /deps/trt-wheels/*.whl + pip3 install -U /deps/trt-wheels/*.whl --break-system-packages diff --git a/docker/tensorrt/Dockerfile.arm64 b/docker/tensorrt/Dockerfile.arm64 index 23a2459ac..ba2638fcb 100644 --- a/docker/tensorrt/Dockerfile.arm64 +++ b/docker/tensorrt/Dockerfile.arm64 @@ -41,11 +41,11 @@ RUN --mount=type=bind,source=docker/tensorrt/detector/build_python_tensorrt.sh,t && TENSORRT_VER=$(cat /etc/TENSORRT_VER) /deps/build_python_tensorrt.sh COPY docker/tensorrt/requirements-arm64.txt /requirements-tensorrt.txt -ADD https://nvidia.box.com/shared/static/9aemm4grzbbkfaesg5l7fplgjtmswhj8.whl /tmp/onnxruntime_gpu-1.15.1-cp39-cp39-linux_aarch64.whl +ADD https://nvidia.box.com/shared/static/psl23iw3bh7hlgku0mjo1xekxpego3e3.whl /tmp/onnxruntime_gpu-1.15.1-cp311-cp311-linux_aarch64.whl RUN pip3 uninstall -y onnxruntime-openvino \ && pip3 wheel --wheel-dir=/trt-wheels -r /requirements-tensorrt.txt \ - && pip3 install --no-deps /tmp/onnxruntime_gpu-1.15.1-cp39-cp39-linux_aarch64.whl + && pip3 install --no-deps /tmp/onnxruntime_gpu-1.15.1-cp311-cp311-linux_aarch64.whl FROM build-wheels AS trt-model-wheels ARG DEBIAN_FRONTEND diff --git a/docker/tensorrt/Dockerfile.base b/docker/tensorrt/Dockerfile.base index 6fdf9db3f..f9cdde587 100644 --- a/docker/tensorrt/Dockerfile.base +++ b/docker/tensorrt/Dockerfile.base @@ -3,7 +3,7 @@ # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND=noninteractive -ARG TRT_BASE=nvcr.io/nvidia/tensorrt:23.03-py3 +ARG TRT_BASE=nvcr.io/nvidia/tensorrt:23.12-py3 # Build TensorRT-specific library FROM ${TRT_BASE} AS trt-deps diff --git a/docker/tensorrt/detector/rootfs/etc/ld.so.conf.d/cuda_tensorrt.conf b/docker/tensorrt/detector/rootfs/etc/ld.so.conf.d/cuda_tensorrt.conf index fe16ed9c5..561b7bcd4 100644 --- a/docker/tensorrt/detector/rootfs/etc/ld.so.conf.d/cuda_tensorrt.conf +++ b/docker/tensorrt/detector/rootfs/etc/ld.so.conf.d/cuda_tensorrt.conf @@ -1,6 +1,8 @@ /usr/local/lib -/usr/local/lib/python3.9/dist-packages/nvidia/cudnn/lib -/usr/local/lib/python3.9/dist-packages/nvidia/cuda_runtime/lib -/usr/local/lib/python3.9/dist-packages/nvidia/cublas/lib -/usr/local/lib/python3.9/dist-packages/nvidia/cuda_nvrtc/lib -/usr/local/lib/python3.9/dist-packages/tensorrt \ No newline at end of file +/usr/local/cuda/lib64 +/usr/local/lib/python3.11/dist-packages/nvidia/cudnn/lib +/usr/local/lib/python3.11/dist-packages/nvidia/cuda_runtime/lib +/usr/local/lib/python3.11/dist-packages/nvidia/cublas/lib +/usr/local/lib/python3.11/dist-packages/nvidia/cuda_nvrtc/lib +/usr/local/lib/python3.11/dist-packages/tensorrt +/usr/local/lib/python3.11/dist-packages/nvidia/cufft/lib \ No newline at end of file diff --git a/docker/tensorrt/requirements-amd64.txt b/docker/tensorrt/requirements-amd64.txt index df276a613..c81851506 100644 --- a/docker/tensorrt/requirements-amd64.txt +++ b/docker/tensorrt/requirements-amd64.txt @@ -1,14 +1,14 @@ # NVidia TensorRT Support (amd64 only) --extra-index-url 'https://pypi.nvidia.com' numpy < 1.24; platform_machine == 'x86_64' -tensorrt == 8.5.3.*; platform_machine == 'x86_64' -cuda-python == 11.8; platform_machine == 'x86_64' -cython == 0.29.*; platform_machine == 'x86_64' +tensorrt == 8.6.1.*; platform_machine == 'x86_64' +cuda-python == 11.8.*; platform_machine == 'x86_64' +cython == 3.0.*; platform_machine == 'x86_64' nvidia-cuda-runtime-cu12 == 12.1.*; platform_machine == 'x86_64' nvidia-cuda-runtime-cu11 == 11.8.*; platform_machine == 'x86_64' nvidia-cublas-cu11 == 11.11.3.6; platform_machine == 'x86_64' -nvidia-cudnn-cu11 == 8.6.0.*; platform_machine == 'x86_64' +nvidia-cudnn-cu12 == 9.5.0.*; platform_machine == 'x86_64' nvidia-cufft-cu11==10.*; platform_machine == 'x86_64' onnx==1.16.*; platform_machine == 'x86_64' -onnxruntime-gpu==1.18.*; platform_machine == 'x86_64' +onnxruntime-gpu==1.20.*; platform_machine == 'x86_64' protobuf==3.20.3; platform_machine == 'x86_64' diff --git a/docs/docs/configuration/advanced.md b/docs/docs/configuration/advanced.md index 4007154b1..3068ec8f8 100644 --- a/docs/docs/configuration/advanced.md +++ b/docs/docs/configuration/advanced.md @@ -4,7 +4,9 @@ title: Advanced Options sidebar_label: Advanced Options --- -### `logger` +### Logging + +#### Frigate `logger` Change the default log level for troubleshooting purposes. @@ -28,6 +30,18 @@ Examples of available modules are: - `watchdog.` - `ffmpeg..` NOTE: All FFmpeg logs are sent as `error` level. +#### Go2RTC Logging + +See [the go2rtc docs](for logging configuration) + +```yaml +go2rtc: + streams: + ... + log: + exec: trace +``` + ### `environment_vars` This section can be used to set environment variables for those unable to modify the environment of the container (ie. within HassOS) @@ -189,16 +203,16 @@ When frigate starts up, it checks whether your config file is valid, and if it i ### 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. +Frigate can accept a new configuration file as JSON at the `/api/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 +curl -X POST http://frigate_host:5000/api/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 @- +yq r -j config.yml | curl -X POST http://frigate_host:5000/api/config/save -d @- ``` ### Via Command Line diff --git a/docs/docs/configuration/authentication.md b/docs/docs/configuration/authentication.md index a48b03b48..dba9360ac 100644 --- a/docs/docs/configuration/authentication.md +++ b/docs/docs/configuration/authentication.md @@ -24,6 +24,11 @@ On startup, an admin user and password are generated and printed in the logs. It In the event that you are locked out of your instance, you can tell Frigate to reset the admin password and print it in the logs on next startup using the `reset_admin_password` setting in your config file. +```yaml +auth: + reset_admin_password: true +``` + ## Login failure rate limiting In order to limit the risk of brute force attacks, rate limiting is available for login failures. This is implemented with SlowApi, and the string notation for valid values is available in [the documentation](https://limits.readthedocs.io/en/stable/quickstart.html#examples). diff --git a/docs/docs/configuration/autotracking.md b/docs/docs/configuration/autotracking.md index 9545fa7d3..c053ef369 100644 --- a/docs/docs/configuration/autotracking.md +++ b/docs/docs/configuration/autotracking.md @@ -167,3 +167,7 @@ To maintain object tracking during PTZ moves, Frigate tracks the motion of your ### 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. + +### Frigate reports an error saying that calibration has failed. Why? + +Calibration measures the amount of time it takes for Frigate to make a series of movements with your PTZ. This error message is recorded in the log if these values are too high for Frigate to support calibrated autotracking. This is often the case when your camera's motor or network connection is too slow or your camera's firmware doesn't report the motor status in a timely manner. You can try running without calibration (just remove the `movement_weights` line from your config and restart), but if calibration fails, this often means that autotracking will behave unpredictably. diff --git a/docs/docs/configuration/camera_specific.md b/docs/docs/configuration/camera_specific.md index 072c20bb5..ab5e605d2 100644 --- a/docs/docs/configuration/camera_specific.md +++ b/docs/docs/configuration/camera_specific.md @@ -65,19 +65,32 @@ ffmpeg: ## Model/vendor specific setup +### Amcrest & Dahua + +Amcrest & Dahua cameras should be connected to via RTSP using the following format: + +``` +rtsp://USERNAME:PASSWORD@CAMERA-IP/cam/realmonitor?channel=1&subtype=0 # this is the main stream +rtsp://USERNAME:PASSWORD@CAMERA-IP/cam/realmonitor?channel=1&subtype=1 # this is the sub stream, typically supporting low resolutions only +rtsp://USERNAME:PASSWORD@CAMERA-IP/cam/realmonitor?channel=1&subtype=2 # higher end cameras support a third stream with a mid resolution (1280x720, 1920x1080) +rtsp://USERNAME:PASSWORD@CAMERA-IP/cam/realmonitor?channel=1&subtype=3 # new higher end cameras support a fourth stream with another mid resolution (1280x720, 1920x1080) + +``` + ### Annke C800 -This camera is H.265 only. To be able to play clips on some devices (like MacOs or iPhone) the H.265 stream has to be repackaged and the audio stream has to be converted to aac. Unfortunately direct playback of in the browser is not working (yet), but the downloaded clip can be played locally. +This camera is H.265 only. To be able to play clips on some devices (like MacOs or iPhone) the H.265 stream has to be adjusted using the `apple_compatibility` config. ```yaml cameras: annkec800: # <------ Name the camera ffmpeg: + apple_compatibility: true # <- Adds compatibility with MacOS and iPhone output_args: - record: -f segment -segment_time 10 -segment_format mp4 -reset_timestamps 1 -strftime 1 -c:v copy -tag:v hvc1 -bsf:v hevc_mp4toannexb -c:a aac + record: preset-record-generic-audio-aac inputs: - - path: rtsp://user:password@camera-ip:554/H264/ch1/main/av_stream # <----- Update for your camera + - path: rtsp://USERNAME:PASSWORD@CAMERA-IP/H264/ch1/main/av_stream # <----- Update for your camera roles: - detect - record @@ -95,6 +108,29 @@ ffmpeg: input_args: preset-rtsp-blue-iris ``` +### Hikvision Cameras + +Hikvision cameras should be connected to via RTSP using the following format: + +``` +rtsp://USERNAME:PASSWORD@CAMERA-IP/streaming/channels/101 # this is the main stream +rtsp://USERNAME:PASSWORD@CAMERA-IP/streaming/channels/102 # this is the sub stream, typically supporting low resolutions only +rtsp://USERNAME:PASSWORD@CAMERA-IP/streaming/channels/103 # higher end cameras support a third stream with a mid resolution (1280x720, 1920x1080) +``` + +:::note + +[Some users have reported](https://www.reddit.com/r/frigate_nvr/comments/1hg4ze7/hikvision_security_settings) that newer Hikvision cameras require adjustments to the security settings: + +``` +RTSP Authentication - digest/basic +RTSP Digest Algorithm - MD5 +WEB Authentication - digest/basic +WEB Digest Algorithm - MD5 +``` + +::: + ### Reolink Cameras Reolink has older cameras (ex: 410 & 520) as well as newer camera (ex: 520a & 511wa) which support different subsets of options. In both cases using the http stream is recommended. @@ -156,7 +192,9 @@ cameras: #### Reolink Doorbell -The reolink doorbell supports 2-way audio via go2rtc and other applications. It is important that the http-flv stream is still used for stability, a secondary rtsp stream can be added that will be using for the two way audio only. +The reolink doorbell supports two way audio via go2rtc and other applications. It is important that the http-flv stream is still used for stability, a secondary rtsp stream can be added that will be using for the two way audio only. + +Ensure HTTP is enabled in the camera's advanced network settings. To use two way talk with Frigate, see the [Live view documentation](/configuration/live#two-way-talk). ```yaml go2rtc: diff --git a/docs/docs/configuration/face_recognition.md b/docs/docs/configuration/face_recognition.md new file mode 100644 index 000000000..22968762a --- /dev/null +++ b/docs/docs/configuration/face_recognition.md @@ -0,0 +1,59 @@ +--- +id: face_recognition +title: Face Recognition +--- + +Face recognition allows people to be assigned names and when their face is recognized Frigate will assign the person's name as a sub label. This information is included in the UI, filters, as well as in notifications. + +Frigate has support for FaceNet to create face embeddings, which runs locally. Embeddings are then saved to Frigate's database. + +## Minimum System Requirements + +Face recognition works by running a large AI model locally on your system. Systems without a GPU will not run Face Recognition reliably or at all. + +## Configuration + +Face recognition is disabled by default and requires semantic search to be enabled, face recognition must be enabled in your config file before it can be used. Semantic Search and face recognition are global configuration settings. + +```yaml +face_recognition: + enabled: true +``` + +## Dataset + +The number of images needed for a sufficient training set for face recognition varies depending on several factors: + +- Diversity of the dataset: A dataset with diverse images, including variations in lighting, pose, and facial expressions, will require fewer images per person than a less diverse dataset. +- Desired accuracy: The higher the desired accuracy, the more images are typically needed. + +However, here are some general guidelines: + +- Minimum: For basic face recognition tasks, a minimum of 10-20 images per person is often recommended. +- Recommended: For more robust and accurate systems, 30-50 images per person is a good starting point. +- Ideal: For optimal performance, especially in challenging conditions, 100 or more images per person can be beneficial. + +## Creating a Robust Training Set + +The accuracy of face recognition is heavily dependent on the quality of data given to it for training. It is recommended to build the face training library in phases. + +:::tip + +When choosing images to include in the face training set it is recommended to always follow these recommendations: +- If it is difficult to make out details in a persons face it will not be helpful in training. +- Avoid images with under/over-exposure. +- Avoid blurry / pixelated images. +- Be careful when uploading images of people when they are wearing clothing that covers a lot of their face as this may confuse the training. +- Do not upload too many images at the same time, it is recommended to train 4-6 images for each person each day so it is easier to know if the previously added images helped or hurt performance. + +::: + +### Step 1 - Building a Strong Foundation + +When first enabling face recognition it is important to build a foundation of strong images. It is recommended to start by uploading 1-2 photos taken by a smartphone for each person. It is important that the person's face in the photo is straight-on and not turned which will ensure a good starting point. + +Then it is recommended to use the `Face Library` tab in Frigate to select and train images for each person as they are detected. When building a strong foundation it is strongly recommended to only train on images that are straight-on. Ignore images from cameras that recognize faces from an angle. Once a person starts to be consistently recognized correctly on images that are straight-on, it is time to move on to the next step. + +### Step 2 - Expanding The Dataset + +Once straight-on images are performing well, start choosing slightly off-angle images to include for training. It is important to still choose images where enough face detail is visible to recognize someone. \ No newline at end of file diff --git a/docs/docs/configuration/genai.md b/docs/docs/configuration/genai.md index 9d5f62b8c..23f1c06be 100644 --- a/docs/docs/configuration/genai.md +++ b/docs/docs/configuration/genai.md @@ -15,9 +15,9 @@ Semantic Search must be enabled to use Generative AI. ## Configuration -Generative AI can be enabled for all cameras or only for specific cameras. There are currently 3 providers available to integrate with Frigate. +Generative AI can be enabled for all cameras or only for specific cameras. There are currently 3 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI section below. -If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`. +To use Generative AI, you must define a single provider at the global level of your Frigate configuration. If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`. ```yaml genai: @@ -116,6 +116,12 @@ genai: model: gpt-4o ``` +:::note + +To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` environment variable to your provider's API URL. + +::: + ## Azure OpenAI Microsoft offers several vision models through Azure OpenAI. A subscription is required. diff --git a/docs/docs/configuration/hardware_acceleration.md b/docs/docs/configuration/hardware_acceleration.md index e70e57497..393350e62 100644 --- a/docs/docs/configuration/hardware_acceleration.md +++ b/docs/docs/configuration/hardware_acceleration.md @@ -175,6 +175,16 @@ For more information on the various values across different distributions, see h Depending on your OS and kernel configuration, you may need to change the `/proc/sys/kernel/perf_event_paranoid` kernel tunable. You can test the change by running `sudo sh -c 'echo 2 >/proc/sys/kernel/perf_event_paranoid'` which will persist until a reboot. Make it permanent by running `sudo sh -c 'echo kernel.perf_event_paranoid=2 >> /etc/sysctl.d/local.conf'` +#### Stats for SR-IOV devices + +When using virtualized GPUs via SR-IOV, additional args are needed for GPU stats to function. This can be enabled with the following config: + +```yaml +telemetry: + stats: + sriov: True +``` + ## AMD/ATI GPUs (Radeon HD 2000 and newer GPUs) via libva-mesa-driver VAAPI supports automatic profile selection so it will work automatically with both H.264 and H.265 streams. diff --git a/docs/docs/configuration/license_plate_recognition.md b/docs/docs/configuration/license_plate_recognition.md new file mode 100644 index 000000000..a2b976726 --- /dev/null +++ b/docs/docs/configuration/license_plate_recognition.md @@ -0,0 +1,45 @@ +--- +id: license_plate_recognition +title: License Plate Recognition (LPR) +--- + +Frigate can recognize license plates on vehicles and automatically add the detected characters as a `sub_label` to objects that are of type `car`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street with a dedicated LPR camera. + +Users running a Frigate+ model should ensure that `license_plate` is added to the [list of objects to track](https://docs.frigate.video/plus/#available-label-types) either globally or for a specific camera. This will improve the accuracy and performance of the LPR model. + +LPR is most effective when the vehicle’s license plate is fully visible to the camera. For moving vehicles, Frigate will attempt to read the plate continuously, refining its detection and keeping the most confident result. LPR will not run on stationary vehicles. + +## Minimum System Requirements + +License plate recognition works by running AI models locally on your system. The models are relatively lightweight and run on your CPU. At least 4GB of RAM is required. + +## Configuration + +License plate recognition is disabled by default. Enable it in your config file: + +```yaml +lpr: + enabled: true +``` + +## Advanced Configuration + +Several options are available to fine-tune the LPR feature. For example, you can adjust the `min_area` setting, which defines the minimum size in pixels a license plate must be before LPR runs. The default is 500 pixels. + +Additionally, you can define `known_plates` as strings or regular expressions, allowing Frigate to label tracked vehicles with custom sub_labels when a recognized plate is detected. This information is then accessible in the UI, filters, and notifications. + +```yaml +lpr: + enabled: true + min_area: 500 + known_plates: + Wife's Car: + - "ABC-1234" + - "ABC-I234" + Johnny: + - "J*N-*234" # Using wildcards for H/M and 1/I + Sally: + - "[S5]LL-1234" # Matches SLL-1234 and 5LL-1234 +``` + +In this example, "Wife's Car" will appear as the label for any vehicle matching the plate "ABC-1234." The model might occasionally interpret the digit 1 as a capital I (e.g., "ABC-I234"), so both variations are listed. Similarly, multiple possible variations are specified for Johnny and Sally. diff --git a/docs/docs/configuration/live.md b/docs/docs/configuration/live.md index 6d63744cc..bc19d3caa 100644 --- a/docs/docs/configuration/live.md +++ b/docs/docs/configuration/live.md @@ -3,9 +3,9 @@ id: live title: Live View --- -Frigate intelligently displays your camera streams on the Live view dashboard. Your camera images update once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any motion is detected, cameras seamlessly switch to a live stream. +Frigate intelligently displays your camera streams on the Live view dashboard. By default, Frigate employs "smart streaming" where camera images update once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any motion or active objects are detected, cameras seamlessly switch to a live stream. -## Live View technologies +### Live View technologies Frigate intelligently uses three different streaming technologies to display your camera streams on the dashboard and the single camera view, switching between available modes based on network bandwidth, player errors, or required features like two-way talk. The highest quality and fluency of the Live view requires the bundled `go2rtc` to be configured as shown in the [step by step guide](/guides/configuring_go2rtc). @@ -51,19 +51,32 @@ go2rtc: - ffmpeg:rtsp://192.168.1.5:554/live0#video=copy ``` -### Setting Stream For Live UI +### Setting Streams For Live UI -There may be some cameras that you would prefer to use the sub stream for live view, but the main stream for recording. This can be done via `live -> stream_name`. +You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the `live -> streams` list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage. + +Additionally, when creating and editing camera groups in the UI, you can choose the stream you want to use for your camera group's Live dashboard. + +::: note + +Frigate's default dashboard ("All Cameras") will always use the first entry you've defined in `streams:` when playing live streams from your cameras. + +::: + +Configure the `streams` option with a "friendly name" for your stream followed by the go2rtc stream name. + +Using Frigate's internal version of go2rtc is required to use this feature. You cannot specify paths in the `streams` configuration, only go2rtc stream names. ```yaml go2rtc: streams: test_cam: - - rtsp://192.168.1.5:554/live0 # <- stream which supports video & aac audio. + - rtsp://192.168.1.5:554/live_main # <- stream which supports video & aac audio. - "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:test_cam_sub#audio=opus" # <- copy of the stream which transcodes audio to opus for webrtc + - rtsp://192.168.1.5:554/live_sub # <- stream which supports video & aac audio. + test_cam_another_sub: + - rtsp://192.168.1.5:554/live_alt # <- stream which supports video & aac audio. cameras: test_cam: @@ -80,7 +93,10 @@ cameras: roles: - detect live: - stream_name: test_cam_sub + streams: # <--- Multiple streams for Frigate 0.16 and later + Main Stream: test_cam # <--- Specify a "friendly name" followed by the go2rtc stream name + Sub Stream: test_cam_sub + Special Stream: test_cam_another_sub ``` ### WebRTC extra configuration: @@ -101,6 +117,7 @@ WebRTC works by creating a TCP or UDP connection on port `8555`. However, it req ``` - For access through Tailscale, the Frigate system's Tailscale IP must be added as a WebRTC candidate. Tailscale IPs all start with `100.`, and are reserved within the `100.64.0.0/10` CIDR block. +- Note that WebRTC does not support H.265. :::tip @@ -138,3 +155,60 @@ services: ::: See [go2rtc WebRTC docs](https://github.com/AlexxIT/go2rtc/tree/v1.8.3#module-webrtc) for more information about this. + +### Two way talk + +For devices that support two way talk, Frigate can be configured to use the feature from the camera's Live view in the Web UI. You should: + +- Set up go2rtc with [WebRTC](#webrtc-extra-configuration). +- Ensure you access Frigate via https (may require [opening port 8971](/frigate/installation/#ports)). +- For the Home Assistant Frigate card, [follow the docs](https://github.com/dermotduffy/frigate-hass-card?tab=readme-ov-file#using-2-way-audio) for the correct source. + +To use the Reolink Doorbell with two way talk, you should use the [recommended Reolink configuration](/configuration/camera_specific#reolink-doorbell) + +### Streaming options on camera group dashboards + +Frigate provides a dialog in the Camera Group Edit pane with several options for streaming on a camera group's dashboard. These settings are _per device_ and are saved in your device's local storage. + +- Stream selection using the `live -> streams` configuration option (see _Setting Streams For Live UI_ above) +- Streaming type: + - _No streaming_: Camera images will only update once per minute and no live streaming will occur. + - _Smart Streaming_ (default, recommended setting): Smart streaming will update your camera image once per minute when no detectable activity is occurring to conserve bandwidth and resources, since a static picture is the same as a streaming image with no motion or objects. When motion or objects are detected, the image seamlessly switches to a live stream. + - _Continuous Streaming_: Camera image will always be a live stream when visible on the dashboard, even if no activity is being detected. Continuous streaming may cause high bandwidth usage and performance issues. **Use with caution.** +- _Compatibility mode_: Enable this option only if your camera's live stream is displaying color artifacts and has a diagonal line on the right side of the image. Before enabling this, try setting your camera's `detect` width and height to a standard aspect ratio (for example: 640x352 becomes 640x360, and 800x443 becomes 800x450, 2688x1520 becomes 2688x1512, etc). Depending on your browser and device, more than a few cameras in compatibility mode may not be supported, so only use this option if changing your config fails to resolve the color artifacts and diagonal line. + +:::note + +The default dashboard ("All Cameras") will always use Smart Streaming and the first entry set in your `streams` configuration, if defined. Use a camera group if you want to change any of these settings from the defaults. + +::: + +## Live view FAQ + +1. Why don't I have audio in my Live view? + You must use go2rtc to hear audio in your live streams. If you have go2rtc already configured, you need to ensure your camera is sending PCMA/PCMU or AAC audio. If you can't change your camera's audio codec, you need to [transcode the audio](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg) using go2rtc. + + Note that the low bandwidth mode player is a video-only stream. You should not expect to hear audio when in low bandwidth mode, even if you've set up go2rtc. + +2. Frigate shows that my live stream is in "low bandwidth mode". What does this mean? + Frigate intelligently selects the live streaming technology based on a number of factors (user-selected modes like two-way talk, camera settings, browser capabilities, available bandwidth) and prioritizes showing an actual up-to-date live view of your camera's stream as quickly as possible. + + When you have go2rtc configured, Live view initially attempts to load and play back your stream with a clearer, fluent stream technology (MSE). An initial timeout, a low bandwidth condition that would cause buffering of the stream, or decoding errors in the stream will cause Frigate to switch to the stream defined by the `detect` role, using the jsmpeg format. This is what the UI labels as "low bandwidth mode". On Live dashboards, the mode will automatically reset when smart streaming is configured and activity stops. You can also try using the _Reset_ button to force a reload of your stream. + + If you are still experiencing Frigate falling back to low bandwidth mode, you may need to adjust your camera's settings per the recommendations above or ensure you have enough bandwidth available. + +3. It doesn't seem like my cameras are streaming on the Live dashboard. Why? + On the default Live dashboard ("All Cameras"), your camera images will update once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity is detected, cameras seamlessly switch to a full-resolution live stream. If you want to customize this behavior, use a camera group. + +4. I see a strange diagonal line on my live view, but my recordings look fine. How can I fix it? + This is caused by incorrect dimensions set in your detect width or height (or incorrectly auto-detected), causing the jsmpeg player's rendering engine to display a slightly distorted image. You should enlarge the width and height of your `detect` resolution up to a standard aspect ratio (example: 640x352 becomes 640x360, and 800x443 becomes 800x450, 2688x1520 becomes 2688x1512, etc). If changing the resolution to match a standard (4:3, 16:9, or 32:9, etc) aspect ratio does not solve the issue, you can enable "compatibility mode" in your camera group dashboard's stream settings. Depending on your browser and device, more than a few cameras in compatibility mode may not be supported, so only use this option if changing your `detect` width and height fails to resolve the color artifacts and diagonal line. + +5. How does "smart streaming" work? + Because a static image of a scene looks exactly the same as a live stream with no motion or activity, smart streaming updates your camera images once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity (motion or object/audio detection) occurs, cameras seamlessly switch to a live stream. + + This static image is pulled from the stream defined in your config with the `detect` role. When activity is detected, images from the `detect` stream immediately begin updating at ~5 frames per second so you can see the activity until the live player is loaded and begins playing. This usually only takes a second or two. If the live player times out, buffers, or has streaming errors, the jsmpeg player is loaded and plays a video-only stream from the `detect` role. When activity ends, the players are destroyed and a static image is displayed until activity is detected again, and the process repeats. + + This is Frigate's default and recommended setting because it results in a significant bandwidth savings, especially for high resolution cameras. + +6. I have unmuted some cameras on my dashboard, but I do not hear sound. Why? + If your camera is streaming (as indicated by a red dot in the upper right, or if it has been set to continuous streaming mode), your browser may be blocking audio until you interact with the page. This is an intentional browser limitation. See [this article](https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_availability). Many browsers have a whitelist feature to change this behavior. diff --git a/docs/docs/configuration/metrics.md b/docs/docs/configuration/metrics.md new file mode 100644 index 000000000..662404205 --- /dev/null +++ b/docs/docs/configuration/metrics.md @@ -0,0 +1,99 @@ +--- +id: metrics +title: Metrics +--- + +# Metrics + +Frigate exposes Prometheus metrics at the `/api/metrics` endpoint that can be used to monitor the performance and health of your Frigate instance. + +## Available Metrics + +### System Metrics +- `frigate_cpu_usage_percent{pid="", name="", process="", type="", cmdline=""}` - Process CPU usage percentage +- `frigate_mem_usage_percent{pid="", name="", process="", type="", cmdline=""}` - Process memory usage percentage +- `frigate_gpu_usage_percent{gpu_name=""}` - GPU utilization percentage +- `frigate_gpu_mem_usage_percent{gpu_name=""}` - GPU memory usage percentage + +### Camera Metrics +- `frigate_camera_fps{camera_name=""}` - Frames per second being consumed from your camera +- `frigate_detection_fps{camera_name=""}` - Number of times detection is run per second +- `frigate_process_fps{camera_name=""}` - Frames per second being processed +- `frigate_skipped_fps{camera_name=""}` - Frames per second skipped for processing +- `frigate_detection_enabled{camera_name=""}` - Detection enabled status for camera +- `frigate_audio_dBFS{camera_name=""}` - Audio dBFS for camera +- `frigate_audio_rms{camera_name=""}` - Audio RMS for camera + +### Detector Metrics +- `frigate_detector_inference_speed_seconds{name=""}` - Time spent running object detection in seconds +- `frigate_detection_start{name=""}` - Detector start time (unix timestamp) + +### Storage Metrics +- `frigate_storage_free_bytes{storage=""}` - Storage free bytes +- `frigate_storage_total_bytes{storage=""}` - Storage total bytes +- `frigate_storage_used_bytes{storage=""}` - Storage used bytes +- `frigate_storage_mount_type{mount_type="", storage=""}` - Storage mount type info + +### Service Metrics +- `frigate_service_uptime_seconds` - Uptime in seconds +- `frigate_service_last_updated_timestamp` - Stats recorded time (unix timestamp) +- `frigate_device_temperature{device=""}` - Device Temperature + +### Event Metrics +- `frigate_camera_events{camera="", label=""}` - Count of camera events since exporter started + +## Configuring Prometheus + +To scrape metrics from Frigate, add the following to your Prometheus configuration: + +```yaml +scrape_configs: + - job_name: 'frigate' + metrics_path: '/api/metrics' + static_configs: + - targets: ['frigate:5000'] + scrape_interval: 15s +``` + +## Example Queries + +Here are some example PromQL queries that might be useful: + +```promql +# Average CPU usage across all processes +avg(frigate_cpu_usage_percent) + +# Total GPU memory usage +sum(frigate_gpu_mem_usage_percent) + +# Detection FPS by camera +rate(frigate_detection_fps{camera_name="front_door"}[5m]) + +# Storage usage percentage +(frigate_storage_used_bytes / frigate_storage_total_bytes) * 100 + +# Event count by camera in last hour +increase(frigate_camera_events[1h]) +``` + +## Grafana Dashboard + +You can use these metrics to create Grafana dashboards to monitor your Frigate instance. Here's an example of metrics you might want to track: + +- CPU, Memory and GPU usage over time +- Camera FPS and detection rates +- Storage usage and trends +- Event counts by camera +- System temperatures + +A sample Grafana dashboard JSON will be provided in a future update. + +## Metric Types + +The metrics exposed by Frigate use the following Prometheus metric types: + +- **Counter**: Cumulative values that only increase (e.g., `frigate_camera_events`) +- **Gauge**: Values that can go up and down (e.g., `frigate_cpu_usage_percent`) +- **Info**: Key-value pairs for metadata (e.g., `frigate_storage_mount_type`) + +For more information about Prometheus metric types, see the [Prometheus documentation](https://prometheus.io/docs/concepts/metric_types/). diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 3e03daab3..c04b92474 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -33,6 +33,14 @@ Frigate supports multiple different detectors that work on different types of ha ::: +:::note + +Multiple detectors can not be mixed for object detection (ex: OpenVINO and Coral EdgeTPU can not be used for object detection at the same time). + +This does not affect using hardware for accelerating other tasks such as [semantic search](./semantic_search.md) + +::: + # Officially Supported Detectors Frigate provides the following builtin detector types: `cpu`, `edgetpu`, `hailo8l`, `onnx`, `openvino`, `rknn`, `rocm`, 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. @@ -116,6 +124,30 @@ detectors: device: pci ``` +## Hailo-8l + +This detector is available for use with Hailo-8 AI Acceleration Module. + +See the [installation docs](../frigate/installation.md#hailo-8l) for information on configuring the hailo8. + +### Configuration + +```yaml +detectors: + hailo8l: + type: hailo8l + device: PCIe + +model: + width: 300 + height: 300 + input_tensor: nhwc + input_pixel_format: bgr + model_type: ssd + path: /config/model_cache/h8l_cache/ssd_mobilenet_v1.hef +``` + + ## OpenVINO Detector The OpenVINO detector type runs an OpenVINO IR model on AMD and Intel CPUs, Intel GPUs and Intel VPU hardware. To configure an OpenVINO detector, set the `"type"` attribute to `"openvino"`. @@ -418,7 +450,7 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl ## ONNX -ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, and TensorRT. On startup Frigate will automatically try to use a GPU if one is available. +ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, and TensorRT. On startup Frigate will automatically try to use a GPU if one is available. :::info @@ -485,6 +517,33 @@ model: labelmap_path: /labelmap/coco-80.txt ``` +#### YOLOv9 + +[YOLOv9](https://github.com/MultimediaTechLab/YOLO) models are supported, but not included by default. + +:::tip + +The YOLOv9 detector has been designed to support YOLOv9 models, but may support other YOLO model architectures as well. + +::: + +After placing the downloaded onnx model in your config folder, you can use the following configuration: + +```yaml +detectors: + onnx: + type: onnx + +model: + model_type: yolov9 + width: 640 # <--- should match the imgsize set during model export + height: 640 # <--- should match the imgsize set during model export + input_tensor: nchw + input_dtype: float + path: /config/model_cache/yolov9-t.onnx + labelmap_path: /labelmap/coco-80.txt +``` + Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. ## CPU Detector (not recommended) @@ -550,7 +609,7 @@ Hardware accelerated object detection is supported on the following SoCs: - RK3576 - RK3588 -This implementation uses the [Rockchip's RKNN-Toolkit2](https://github.com/airockchip/rknn-toolkit2/), version v2.0.0.beta0. Currently, only [Yolo-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) is supported as object detection model. +This implementation uses the [Rockchip's RKNN-Toolkit2](https://github.com/airockchip/rknn-toolkit2/), version v2.3.0. Currently, only [Yolo-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) is supported as object detection model. ### Prerequisites @@ -625,25 +684,36 @@ $ cat /sys/kernel/debug/rknpu/load - All models are automatically downloaded and stored in the folder `config/model_cache/rknn_cache`. After upgrading Frigate, you should remove older models to free up space. - You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models. -## Hailo-8l +### Converting your own onnx model to rknn format -This detector is available for use with Hailo-8 AI Acceleration Module. +To convert a onnx model to the rknn format using the [rknn-toolkit2](https://github.com/airockchip/rknn-toolkit2/) you have to: -See the [installation docs](../frigate/installation.md#hailo-8l) for information on configuring the hailo8. +- Place one ore more models in onnx format in the directory `config/model_cache/rknn_cache/onnx` on your docker host (this might require `sudo` privileges). +- Save the configuration file under `config/conv2rknn.yaml` (see below for details). +- Run `docker exec python3 /opt/conv2rknn.py`. If the conversion was successful, the rknn models will be placed in `config/model_cache/rknn_cache`. -### Configuration +This is an example configuration file that you need to adjust to your specific onnx model: ```yaml -detectors: - hailo8l: - type: hailo8l - device: PCIe +soc: ["rk3562","rk3566", "rk3568", "rk3576", "rk3588"] +quantization: false -model: - width: 300 - height: 300 - input_tensor: nhwc - input_pixel_format: bgr - model_type: ssd - path: /config/model_cache/h8l_cache/ssd_mobilenet_v1.hef +output_name: "{input_basename}" + +config: + mean_values: [[0, 0, 0]] + std_values: [[255, 255, 255]] + quant_img_rgb2bgr: true ``` + +Explanation of the paramters: + +- `soc`: A list of all SoCs you want to build the rknn model for. If you don't specify this parameter, the script tries to find out your SoC and builds the rknn model for this one. +- `quantization`: true: 8 bit integer (i8) quantization, false: 16 bit float (fp16). Default: false. +- `output_name`: The output name of the model. The following variables are available: + - `quant`: "i8" or "fp16" depending on the config + - `input_basename`: the basename of the input model (e.g. "my_model" if the input model is calles "my_model.onnx") + - `soc`: the SoC this model was build for (e.g. "rk3588") + - `tk_version`: Version of `rknn-toolkit2` (e.g. "2.3.0") + - **example**: Specifying `output_name = "frigate-{quant}-{input_basename}-{soc}-v{tk_version}"` could result in a model called `frigate-i8-my_model-rk3588-v2.3.0.rknn`. +- `config`: Configuration passed to `rknn-toolkit2` for model conversion. For an explanation of all available parameters have a look at section "2.2. Model configuration" of [this manual](https://github.com/MarcA711/rknn-toolkit2/releases/download/v2.3.0/03_Rockchip_RKNPU_API_Reference_RKNN_Toolkit2_V2.3.0_EN.pdf). \ No newline at end of file diff --git a/docs/docs/configuration/object_filters.md b/docs/docs/configuration/object_filters.md index ca7260094..3f36086c0 100644 --- a/docs/docs/configuration/object_filters.md +++ b/docs/docs/configuration/object_filters.md @@ -34,7 +34,7 @@ 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. +`min_area` and `max_area` filter on the area of an objects bounding box 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. These values can either be in pixels or as a percentage of the frame (for example, 0.12 represents 12% of the frame). ### Object Proportions diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 0c2dbdd12..65a0f4825 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -46,6 +46,11 @@ mqtt: tls_insecure: false # Optional: interval in seconds for publishing stats (default: shown below) stats_interval: 60 + # Optional: QoS level for subscriptions and publishing (default: shown below) + # 0 = at most once + # 1 = at least once + # 2 = exactly once + qos: 0 # Optional: Detectors configuration. Defaults to a single CPU detector detectors: @@ -244,6 +249,8 @@ ffmpeg: # If set too high, then if a ffmpeg crash or camera stream timeout occurs, you could potentially lose up to a maximum of retry_interval second(s) of footage # NOTE: this can be a useful setting for Wireless / Battery cameras to reduce how much footage is potentially lost during a connection timeout. retry_interval: 10 + # Optional: Set tag on HEVC (H.265) recording stream to improve compatibility with Apple players. (default: shown below) + apple_compatibility: false # Optional: Detect configuration # NOTE: Can be overridden at the camera level @@ -310,9 +317,11 @@ objects: # Optional: filters to reduce false positives for specific object types filters: person: - # Optional: minimum width*height of the bounding box for the detected object (default: 0) + # Optional: minimum size of the bounding box for the detected object (default: 0). + # Can be specified as an integer for width*height in pixels or as a decimal representing the percentage of the frame (0.000001 to 0.99). min_area: 5000 - # Optional: maximum width*height of the bounding box for the detected object (default: 24000000) + # Optional: maximum size of the bounding box for the detected object (default: 24000000). + # Can be specified as an integer for width*height in pixels or as a decimal representing the percentage of the frame (0.000001 to 0.99). max_area: 100000 # Optional: minimum width/height of the bounding box for the detected object (default: 0) min_ratio: 0.5 @@ -400,6 +409,7 @@ motion: mqtt_off_delay: 30 # Optional: Notification Configuration +# NOTE: Can be overridden at the camera level (except email) notifications: # Optional: Enable notification service (default: shown below) enabled: False @@ -524,6 +534,14 @@ semantic_search: # NOTE: small model runs on CPU and large model runs on GPU model_size: "small" +# Optional: Configuration for face recognition capability +face_recognition: + # Optional: Enable semantic search (default: shown below) + enabled: False + # Optional: Set the model size used for embeddings. (default: shown below) + # NOTE: small model runs on CPU and large model runs on GPU + model_size: "small" + # Optional: Configuration for AI generated tracked object descriptions # NOTE: Semantic Search must be enabled for this to do anything. # WARNING: Depending on the provider, this will send thumbnails over the internet @@ -548,15 +566,19 @@ genai: # Optional: Restream configuration # Uses https://github.com/AlexxIT/go2rtc (v1.9.2) +# NOTE: The default go2rtc API port (1984) must be used, +# changing this port for the integrated go2rtc instance is not supported. go2rtc: # Optional: Live stream configuration for WebUI. # NOTE: Can be overridden at the camera level live: - # Optional: Set the name of the stream configured in go2rtc + # Optional: Set the streams configured in go2rtc # that should be used for live view in frigate WebUI. (default: name of camera) # NOTE: In most cases this should be set at the camera level only. - stream_name: camera_name + streams: + main_stream: main_stream_name + sub_stream: sub_stream_name # Optional: Set the height of the jsmpeg stream. (default: 720) # This must be less than or equal to the height of the detect stream. Lower resolutions # reduce bandwidth required for viewing the jsmpeg stream. Width is computed to match known aspect ratio. @@ -641,7 +663,10 @@ cameras: front_steps: # 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: 0.284,0.997,0.389,0.869,0.410,0.745 + coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428 + # Optional: The real-world distances of a 4-sided zone used for zones with speed estimation enabled (default: none) + # List distances in order of the zone points coordinates and use the unit system defined in the ui config + distances: 10,15,12,11 # Optional: Number of consecutive frames required for object to be considered present in the zone (default: shown below). inertia: 3 # Optional: Number of seconds that an object must loiter to be considered in the zone (default: shown below) @@ -792,6 +817,9 @@ ui: # https://www.gnu.org/software/libc/manual/html_node/Formatting-Calendar-Time.html # possible values are shown above (default: not set) strftime_fmt: "%Y/%m/%d %H:%M" + # Optional: Set the unit system to either "imperial" or "metric" (default: metric) + # Used in the UI and in MQTT topics + unit_system: metric # Optional: Telemetry configuration telemetry: @@ -805,11 +833,13 @@ telemetry: - lo # Optional: Configure system stats stats: - # Enable AMD GPU stats (default: shown below) + # Optional: Enable AMD GPU stats (default: shown below) amd_gpu_stats: True - # Enable Intel GPU stats (default: shown below) + # Optional: Enable Intel GPU stats (default: shown below) intel_gpu_stats: True - # Enable network bandwidth stats monitoring for camera ffmpeg processes, go2rtc, and object detectors. (default: shown below) + # Optional: Treat GPU as SR-IOV to fix GPU stats (default: shown below) + sriov: False + # Optional: Enable network bandwidth stats monitoring for camera ffmpeg processes, go2rtc, and object detectors. (default: shown below) # NOTE: The container must either be privileged or have cap_net_admin, cap_net_raw capabilities enabled. network_bandwidth: False # Optional: Enable the latest version outbound check (default: shown below) diff --git a/docs/docs/configuration/semantic_search.md b/docs/docs/configuration/semantic_search.md index ab3937c53..bd3d79cae 100644 --- a/docs/docs/configuration/semantic_search.md +++ b/docs/docs/configuration/semantic_search.md @@ -1,6 +1,6 @@ --- id: semantic_search -title: Using Semantic Search +title: Semantic Search --- Semantic Search in Frigate allows you to find tracked objects within your review items using either the image itself, a user-defined text description, or an automatically generated one. This feature works by creating _embeddings_ — numerical vector representations — for both the images and text descriptions of your tracked objects. By comparing these embeddings, Frigate assesses their similarities to deliver relevant search results. diff --git a/docs/docs/configuration/zones.md b/docs/docs/configuration/zones.md index aef6b0a5b..1d1e66c27 100644 --- a/docs/docs/configuration/zones.md +++ b/docs/docs/configuration/zones.md @@ -122,16 +122,59 @@ cameras: - car ``` -### Loitering Time +### Speed Estimation -Zones support a `loitering_time` configuration which can be used to only consider an object as part of a zone if they loiter in the zone for the specified number of seconds. This can be used, for example, to create alerts for cars that stop on the street but not cars that just drive past your camera. +Frigate can be configured to estimate the speed of objects moving through a zone. This works by combining data from Frigate's object tracker and "real world" distance measurements of the edges of the zone. The recommended use case for this feature is to track the speed of vehicles on a road as they move through the zone. + +Your zone must be defined with exactly 4 points and should be aligned to the ground where objects are moving. + +![Ground plane 4-point zone](/img/ground-plane.jpg) + +Speed estimation requires a minimum number of frames for your object to be tracked before a valid estimate can be calculated, so create your zone away from places where objects enter and exit for the best results. _Your zone should not take up the full frame._ An object's speed is tracked while it is in the zone and then saved to Frigate's database. + +Accurate real-world distance measurements are required to estimate speeds. These distances can be specified in your zone config through the `distances` field. ```yaml cameras: name_of_your_camera: zones: - front_yard: - loitering_time: 5 # unit is in seconds - objects: - - person + street: + coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428 + distances: 10,12,11,13.5 +``` + +Each number in the `distance` field represents the real-world distance between the points in the `coordinates` list. So in the example above, the distance between the first two points ([0.033,0.306] and [0.324,0.138]) is 10. The distance between the second and third set of points ([0.324,0.138] and [0.439,0.185]) is 12, and so on. The fastest and most accurate way to configure this is through the Zone Editor in the Frigate UI. + +The `distance` values are measured in meters or feet, depending on how `unit_system` is configured in your `ui` config: + +```yaml +ui: + # can be "metric" or "imperial", default is metric + unit_system: metric +``` + +The average speed of your object as it moved through your zone is saved in Frigate's database and can be seen in the UI in the Tracked Object Details pane in Explore. Current estimated speed can also be seen on the debug view as the third value in the object label (see the caveats below). Current estimated speed, average estimated speed, and velocity angle (the angle of the direction the object is moving relative to the frame) of tracked objects is also sent through the `events` MQTT topic. See the [MQTT docs](../integrations/mqtt.md#frigateevents). These speed values are output as a number in miles per hour (mph) or kilometers per hour (kph), depending on how `unit_system` is configured in your `ui` config. + +#### Best practices and caveats + +- Speed estimation works best with a straight road or path when your object travels in a straight line across that path. If your object makes turns, speed estimation may not be accurate. +- Create a zone where the bottom center of your object's bounding box travels directly through it and does not become obscured at any time. +- Depending on the size and location of your zone, you may want to decrease the zone's `inertia` value from the default of 3. +- The more accurate your real-world dimensions can be measured, the more accurate speed estimation will be. However, due to the way Frigate's tracking algorithm works, you may need to tweak the real-world distance values so that estimated speeds better match real-world speeds. +- Once an object leaves the zone, speed accuracy will likely decrease due to perspective distortion and misalignment with the calibrated area. Therefore, speed values will show as a zero through MQTT and will not be visible on the debug view when an object is outside of a speed tracking zone. +- The speeds are only an _estimation_ and are highly dependent on camera position, zone points, and real-world measurements. This feature should not be used for law enforcement. + +### Speed Threshold + +Zones can be configured with a minimum speed requirement, meaning an object must be moving at or above this speed to be considered inside the zone. Zone `distances` must be defined as described above. + +```yaml +cameras: + name_of_your_camera: + zones: + sidewalk: + coordinates: ... + distances: ... + inertia: 1 + speed_threshold: 20 # unit is in kph or mph, depending on how unit_system is set (see above) ``` diff --git a/docs/docs/frigate/hardware.md b/docs/docs/frigate/hardware.md index 5e8ab23e7..dcfc91072 100644 --- a/docs/docs/frigate/hardware.md +++ b/docs/docs/frigate/hardware.md @@ -13,20 +13,19 @@ Many users have reported various issues with Reolink cameras, so I do not recomm Here are some of the camera's I recommend: -- Loryta(Dahua) T5442TM-AS-LED (affiliate link) -- Loryta(Dahua) IPC-T5442TM-AS (affiliate link) -- Amcrest IP5M-T1179EW-28MM (affiliate link) +- Loryta(Dahua) IPC-T549M-ALED-S3 (affiliate link) +- Loryta(Dahua) IPC-T54IR-AS (affiliate link) +- Amcrest IP5M-T1179EW-AI-V3 (affiliate link) I may earn a small commission for my endorsement, recommendation, testimonial, or link to any products or services from this website. ## Server -My current favorite is the Beelink EQ12 because of the efficient N100 CPU and dual NICs that allow you to setup a dedicated private network for your cameras where they can be blocked from accessing the internet. There are many used workstation options on eBay that work very well. Anything with an Intel CPU and capable of running Debian should work fine. As a bonus, you may want to look for devices with a M.2 or PCIe express slot that is compatible with the Google Coral. I may earn a small commission for my endorsement, recommendation, testimonial, or link to any products or services from this website. +My current favorite is the Beelink EQ13 because of the efficient N100 CPU and dual NICs that allow you to setup a dedicated private network for your cameras where they can be blocked from accessing the internet. There are many used workstation options on eBay that work very well. Anything with an Intel CPU and capable of running Debian should work fine. As a bonus, you may want to look for devices with a M.2 or PCIe express slot that is compatible with the Google Coral. I may earn a small commission for my endorsement, recommendation, testimonial, or link to any products or services from this website. -| Name | Coral Inference Speed | Coral Compatibility | Notes | -| ------------------------------------------------------------------------------------------------------------- | --------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| Beelink EQ12 (Amazon) | 5-10ms | USB | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. | -| Intel NUC (Amazon) | 5-10ms | USB | Overkill for most, but great performance. Can handle many cameras at 5fps depending on typical amounts of motion. Requires extra parts. | +| Name | Coral Inference Speed | Coral Compatibility | Notes | +| ------------------------------------------------------------------------------------------------------------- | --------------------- | ------------------- | ----------------------------------------------------------------------------------------- | +| Beelink EQ13 (Amazon) | 5-10ms | USB | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. | ## Detectors @@ -52,24 +51,25 @@ The OpenVINO detector type is able to run on: More information is available [in the detector docs](/configuration/object_detectors#openvino-detector) -Inference speeds vary greatly depending on the CPU, GPU, or VPU used, some known examples are below: +Inference speeds vary greatly depending on the CPU or GPU used, some known examples of GPU inference times are below: -| Name | Inference Speed | Notes | -| -------------------- | --------------- | --------------------------------------------------------------------- | -| Intel NCS2 VPU | 60 - 65 ms | May vary based on host device | -| Intel Celeron J4105 | ~ 25 ms | Inference speeds on CPU were 150 - 200 ms | -| Intel Celeron N3060 | 130 - 150 ms | Inference speeds on CPU were ~ 550 ms | -| Intel Celeron N3205U | ~ 120 ms | Inference speeds on CPU were ~ 380 ms | -| Intel Celeron N4020 | 50 - 200 ms | Inference speeds on CPU were ~ 800 ms, greatly depends on other loads | -| Intel i3 6100T | 15 - 35 ms | Inference speeds on CPU were 60 - 120 ms | -| Intel i3 8100 | ~ 15 ms | Inference speeds on CPU were ~ 65 ms | -| Intel i5 4590 | ~ 20 ms | Inference speeds on CPU were ~ 230 ms | -| Intel i5 6500 | ~ 15 ms | Inference speeds on CPU were ~ 150 ms | -| Intel i5 7200u | 15 - 25 ms | Inference speeds on CPU were ~ 150 ms | -| Intel i5 7500 | ~ 15 ms | Inference speeds on CPU were ~ 260 ms | -| Intel i5 1135G7 | 10 - 15 ms | | -| Intel i5 12600K | ~ 15 ms | Inference speeds on CPU were ~ 35 ms | -| Intel Arc A750 | ~ 4 ms | | +| Name | MobileNetV2 Inference Time | YOLO-NAS Inference Time | Notes | +| -------------------- | -------------------------- | ------------------------- | -------------------------------------- | +| Intel Celeron J4105 | ~ 25 ms | | Can only run one detector instance | +| Intel Celeron N3060 | 130 - 150 ms | | Can only run one detector instance | +| Intel Celeron N3205U | ~ 120 ms | | Can only run one detector instance | +| Intel Celeron N4020 | 50 - 200 ms | | Inference speed depends on other loads | +| Intel i3 6100T | 15 - 35 ms | | Can only run one detector instance | +| Intel i3 8100 | ~ 15 ms | | | +| Intel i5 4590 | ~ 20 ms | | | +| Intel i5 6500 | ~ 15 ms | | | +| Intel i5 7200u | 15 - 25 ms | | | +| Intel i5 7500 | ~ 15 ms | | | +| Intel i5 1135G7 | 10 - 15 ms | | | +| Intel i3 12000 | | 320: ~ 19 ms 640: ~ 54 ms | | +| Intel i5 12600K | ~ 15 ms | 320: ~ 20 ms 640: ~ 46 ms | | +| Intel Arc A380 | ~ 6 ms | 320: ~ 10 ms | | +| Intel Arc A750 | ~ 4 ms | 320: ~ 8 ms | | ### TensorRT - Nvidia GPU @@ -78,29 +78,35 @@ The TensortRT detector is able to run on x86 hosts that have an Nvidia GPU which Inference speeds will vary greatly depending on the GPU and the model used. `tiny` variants are faster than the equivalent non-tiny model, some known examples are below: -| Name | Inference Speed | -| --------------- | --------------- | -| GTX 1060 6GB | ~ 7 ms | -| GTX 1070 | ~ 6 ms | -| GTX 1660 SUPER | ~ 4 ms | -| RTX 3050 | 5 - 7 ms | -| RTX 3070 Mobile | ~ 5 ms | -| Quadro P400 2GB | 20 - 25 ms | -| Quadro P2000 | ~ 12 ms | +| Name | YoloV7 Inference Time | YOLO-NAS Inference Time | +| --------------- | --------------------- | ------------------------- | +| GTX 1060 6GB | ~ 7 ms | | +| GTX 1070 | ~ 6 ms | | +| GTX 1660 SUPER | ~ 4 ms | | +| RTX 3050 | 5 - 7 ms | 320: ~ 10 ms 640: ~ 16 ms | +| RTX 3070 Mobile | ~ 5 ms | | +| Quadro P400 2GB | 20 - 25 ms | | +| Quadro P2000 | ~ 12 ms | | -#### AMD GPUs +### AMD GPUs -With the [rocm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many AMD GPUs. +With the [rocm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many discrete AMD GPUs. -### Community Supported: +### Hailo-8l PCIe -#### Nvidia Jetson +Frigate supports the Hailo-8l M.2 card on any hardware but currently it is only tested on the Raspberry Pi5 PCIe hat from the AI kit. + +The inference time for the Hailo-8L chip at time of writing is around 17-21 ms for the SSD MobileNet Version 1 model. + +## Community Supported Detectors + +### Nvidia Jetson Frigate supports all Jetson boards, from the inexpensive Jetson Nano to the powerful Jetson Orin AGX. It will [make use of the Jetson's hardware media engine](/configuration/hardware_acceleration#nvidia-jetson-orin-agx-orin-nx-orin-nano-xavier-agx-xavier-nx-tx2-tx1-nano) when configured with the [appropriate presets](/configuration/ffmpeg_presets#hwaccel-presets), and will make use of the Jetson's GPU and DLA for object detection when configured with the [TensorRT detector](/configuration/object_detectors#nvidia-tensorrt-detector). 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 platform +### Rockchip platform Frigate supports hardware video processing on all Rockchip boards. However, hardware object detection is only supported on these boards: @@ -112,12 +118,6 @@ Frigate supports hardware video processing on all Rockchip boards. However, hard The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms for yolo-nas s. -#### Hailo-8l PCIe - -Frigate supports the Hailo-8l M.2 card on any hardware but currently it is only tested on the Raspberry Pi5 PCIe hat from the AI kit. - -The inference time for the Hailo-8L chip at time of writing is around 17-21 ms for the SSD MobileNet Version 1 model. - ## 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. diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index 4a28623a5..2c139d2bd 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -111,7 +111,7 @@ For Raspberry Pi 5 users with the AI Kit, installation is straightforward. Simpl For other installations, follow these steps for installation: 1. Install the driver from the [Hailo GitHub repository](https://github.com/hailo-ai/hailort-drivers). A convenient script for Linux is available to clone the repository, build the driver, and install it. -2. Copy or download [this script](https://github.com/blakeblackshear/frigate/blob/41c9b13d2fffce508b32dfc971fa529b49295fbd/docker/hailo8l/user_installation.sh). +2. Copy or download [this script](https://github.com/blakeblackshear/frigate/blob/dev/docker/hailo8l/user_installation.sh). 3. Ensure it has execution permissions with `sudo chmod +x user_installation.sh` 4. Run the script with `./user_installation.sh` diff --git a/docs/docs/guides/configuring_go2rtc.md b/docs/docs/guides/configuring_go2rtc.md index 8667ead77..1a61fd0c5 100644 --- a/docs/docs/guides/configuring_go2rtc.md +++ b/docs/docs/guides/configuring_go2rtc.md @@ -7,7 +7,7 @@ title: Configuring go2rtc Use of the bundled go2rtc is optional. You can still configure FFmpeg to connect directly to your cameras. However, adding go2rtc to your configuration is required for the following features: -- WebRTC or MSE for live viewing with higher resolutions and frame rates than the jsmpeg stream which is limited to the detect stream +- WebRTC or MSE for live viewing with audio, higher resolutions and frame rates than the jsmpeg stream which is limited to the detect stream and does not support audio - Live stream support for cameras in Home Assistant Integration - RTSP relay for use with other consumers to reduce the number of connections to your camera streams diff --git a/docs/docs/integrations/home-assistant.md b/docs/docs/integrations/home-assistant.md index 3841b0587..19330b6b8 100644 --- a/docs/docs/integrations/home-assistant.md +++ b/docs/docs/integrations/home-assistant.md @@ -47,7 +47,7 @@ that card. ## Configuration -When configuring the integration, you will be asked for the `URL` of your Frigate instance which needs to be pointed at the internal unauthenticated port (`5000`) for your instance. This may look like `http://:5000/`. +When configuring the integration, you will be asked for the `URL` of your Frigate instance which can be pointed at the internal unauthenticated port (`5000`) or the authenticated port (`8971`) for your instance. This may look like `http://:5000/`. ### Docker Compose Examples @@ -55,7 +55,7 @@ If you are running Home Assistant Core and Frigate with Docker Compose on the sa #### Home Assistant running with host networking -It is not recommended to run Frigate in host networking mode. In this example, you would use `http://172.17.0.1:5000` when configuring the integration. +It is not recommended to run Frigate in host networking mode. In this example, you would use `http://172.17.0.1:5000` or `http://172.17.0.1:8971` when configuring the integration. ```yaml services: @@ -75,7 +75,7 @@ services: #### Home Assistant _not_ running with host networking or in a separate compose file -In this example, you would use `http://frigate:5000` when configuring the integration. There is no need to map the port for the Frigate container. +In this example, it is recommended to connect to the authenticated port, for example, `http://frigate:8971` when configuring the integration. There is no need to map the port for the Frigate container. ```yaml services: @@ -97,20 +97,21 @@ services: If you are using HassOS with the addon, the URL should be one of the following depending on which addon version you are using. Note that if you are using the Proxy Addon, you do NOT point the integration at the proxy URL. Just enter the URL used to access Frigate directly from your network. -| Addon Version | URL | -| ------------------------------ | -------------------------------------- | -| Frigate NVR | `http://ccab4aaf-frigate:5000` | -| Frigate NVR (Full Access) | `http://ccab4aaf-frigate-fa:5000` | -| Frigate NVR Beta | `http://ccab4aaf-frigate-beta:5000` | -| Frigate NVR Beta (Full Access) | `http://ccab4aaf-frigate-fa-beta:5000` | +| Addon Version | URL | +| ------------------------------ | ----------------------------------------- | +| Frigate NVR | `http://ccab4aaf-frigate:5000` | +| Frigate NVR (Full Access) | `http://ccab4aaf-frigate-fa:5000` | +| Frigate NVR Beta | `http://ccab4aaf-frigate-beta:5000` | +| Frigate NVR Beta (Full Access) | `http://ccab4aaf-frigate-fa-beta:5000` | +| Frigate NVR HailoRT Beta | `http://ccab4aaf-frigate-hailo-beta:5000` | ### Frigate running on a separate machine -If you run Frigate on a separate device within your local network, Home Assistant will need access to port 5000. +If you run Frigate on a separate device within your local network, Home Assistant will need access to port 8971. #### Local network -Use `http://:5000` as the URL for the integration. If you want to protect access to port 5000, you can use firewall rules to limit access to the device running Home Assistant. +Use `http://:8971` as the URL for the integration so that authentication is required. ```yaml services: @@ -118,7 +119,7 @@ services: image: ghcr.io/blakeblackshear/frigate:stable ... ports: - - "5000:5000" + - "8971:8971" ... ``` @@ -195,12 +196,30 @@ To load a snapshot for a tracked object: https://HA_URL/api/frigate/notifications//snapshot.jpg ``` -To load a video clip of a tracked object: +To load a video clip of a tracked object using an Android device: ``` https://HA_URL/api/frigate/notifications//clip.mp4 ``` +To load a video clip of a tracked object using an iOS device: + +``` +https://HA_URL/api/frigate/notifications//master.m3u8 +``` + +To load a preview gif of a tracked object: + +``` +https://HA_URL/api/frigate/notifications//event_preview.gif +``` + +To load a preview gif of a review item: + +``` +https://HA_URL/api/frigate/notifications//review_preview.gif +``` + ## RTSP stream @@ -282,3 +301,7 @@ which server they are referring to. #### If I am detecting multiple objects, how do I assign the correct `binary_sensor` to the camera in HomeKit? The [HomeKit integration](https://www.home-assistant.io/integrations/homekit/) randomly links one of the binary sensors (motion sensor entities) grouped with the camera device in Home Assistant. You can specify a `linked_motion_sensor` in the Home Assistant [HomeKit configuration](https://www.home-assistant.io/integrations/homekit/#linked_motion_sensor) for each camera. + +#### I have set up automations based on the occupancy sensors. Sometimes the automation runs because the sensors are turned on, but then I look at Frigate I can't find the object that triggered the sensor. Is this a bug? + +No. The occupancy sensors have fewer checks in place because they are often used for things like turning the lights on where latency needs to be as low as possible. So false positives can sometimes trigger these sensors. If you want false positive filtering, you should use an mqtt sensor on the `frigate/events` or `frigate/reviews` topic. diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index 194821cbd..c344a5aaa 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -52,7 +52,9 @@ Message published for each changed tracked object. The first message is publishe "attributes": { "face": 0.64 }, // attributes with top score that have been identified on the object at any point - "current_attributes": [] // detailed data about the current attributes in this frame + "current_attributes": [], // detailed data about the current attributes in this frame + "current_estimated_speed": 0.71, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled + "velocity_angle": 180 // direction of travel relative to the frame for objects moving through zones with speed estimation enabled }, "after": { "id": "1607123955.475377-mxklsc", @@ -89,7 +91,9 @@ Message published for each changed tracked object. The first message is publishe "box": [442, 506, 534, 524], "score": 0.86 } - ] + ], + "current_estimated_speed": 0.77, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled + "velocity_angle": 180 // direction of travel relative to the frame for objects moving through zones with speed estimation enabled } } ``` @@ -337,3 +341,19 @@ the camera to be removed from the view._ ### `frigate//birdseye_mode/state` Topic with current state of the Birdseye mode for a camera. Published values are `CONTINUOUS`, `MOTION`, `OBJECTS`. + +### `frigate//notifications/set` + +Topic to turn notifications on and off. Expected values are `ON` and `OFF`. + +### `frigate//notifications/state` + +Topic with current state of notifications. Published values are `ON` and `OFF`. + +### `frigate//notifications/suspend` + +Topic to suspend notifications for a certain number of minutes. Expected value is an integer. + +### `frigate//notifications/suspended` + +Topic with timestamp that notifications are suspended until. Published value is a UNIX timestamp, or 0 if notifications are not suspended. diff --git a/docs/docs/integrations/plus.md b/docs/docs/integrations/plus.md index 9e4af74eb..0a2b7f2d0 100644 --- a/docs/docs/integrations/plus.md +++ b/docs/docs/integrations/plus.md @@ -29,7 +29,9 @@ You cannot use the `environment_vars` section of your Frigate configuration file ## Submit examples -Once your API key is configured, you can submit examples directly from the Explore page in Frigate using the `Frigate+` button. +Once your API key is configured, you can submit examples directly from the Explore page in Frigate. From the More Filters menu, select "Has a Snapshot - Yes" and "Submitted to Frigate+ - No", and press Apply at the bottom of the pane. Then, click on a thumbnail and select the Snapshot tab. + +You can use your keyboard's left and right arrow keys to quickly navigate between the tracked object snapshots. :::note @@ -37,8 +39,6 @@ Snapshots must be enabled to be able to submit examples to Frigate+ ::: -![Send To Plus](/img/plus/send-to-plus.jpg) - ![Submit To Plus](/img/plus/submit-to-plus.jpg) ### Annotate and verify diff --git a/docs/docs/integrations/third_party_extensions.md b/docs/docs/integrations/third_party_extensions.md index a9677e721..e1f9a1053 100644 --- a/docs/docs/integrations/third_party_extensions.md +++ b/docs/docs/integrations/third_party_extensions.md @@ -19,6 +19,10 @@ Please use your own knowledge to assess and vet them before you install anything It supports automatically setting the sub labels in Frigate for person objects that are detected and recognized. This is a fork (with fixed errors and new features) of [original Double Take](https://github.com/jakowenko/double-take) project which, unfortunately, isn't being maintained by author. +## [Frigate Notify](https://github.com/0x2142/frigate-notify) + +[Frigate Notify](https://github.com/0x2142/frigate-notify) is a simple app designed to send notifications from Frigate NVR to your favorite platforms. Intended to be used with standalone Frigate installations - Home Assistant not required, MQTT is optional but recommended. + ## [Frigate telegram](https://github.com/OldTyT/frigate-telegram) [Frigate telegram](https://github.com/OldTyT/frigate-telegram) makes it possible to send events from Frigate to Telegram. Events are sent as a message with a text description, video, and thumbnail. diff --git a/docs/docs/plus/first_model.md b/docs/docs/plus/first_model.md index 6978bb491..e68fd388d 100644 --- a/docs/docs/plus/first_model.md +++ b/docs/docs/plus/first_model.md @@ -5,7 +5,7 @@ title: Requesting your first model ## Step 1: Upload and annotate your images -Before requesting your first model, you will need to upload and verify at least 1 image to Frigate+. The more images you upload, annotate, and verify the better your results will be. Most users start to see very good results once they have 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. Refer to the [integration docs](../integrations/plus.md#generate-an-api-key) for instructions on how to easily submit images to Frigate+ directly from Frigate. +Before requesting your first model, you will need to upload and verify at least 10 images to Frigate+. The more images you upload, annotate, and verify the better your results will be. Most users start to see very good results once they have 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. Refer to the [integration docs](../integrations/plus.md#generate-an-api-key) for instructions on how to easily submit images to Frigate+ directly from Frigate. It is recommended to submit **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. diff --git a/docs/docs/plus/improving_model.md b/docs/docs/plus/improving_model.md index 37a765994..578f4512c 100644 --- a/docs/docs/plus/improving_model.md +++ b/docs/docs/plus/improving_model.md @@ -13,7 +13,7 @@ You may find that Frigate+ models result in more false positives initially, but 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. +**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. You can exclude labels that you don't want detected on any of your cameras. **Make tight bounding boxes**: Tighter bounding boxes improve the recognition and ensure that accurate bounding boxes are predicted at runtime. @@ -21,7 +21,7 @@ For the best results, follow the following guidelines. **Label objects hard to identify as difficult**: When objects are truly difficult to make out, such as a car barely visible through a bush, or a dog that is hard to distinguish from the background at night, flag it as 'difficult'. This is not used in the model training as of now, but will in the future. -**`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. +**Delivery logos such as `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. ![Fedex Logo](/img/plus/fedex-logo.jpg) diff --git a/docs/docs/plus/index.md b/docs/docs/plus/index.md index b7d3d7eec..37798badb 100644 --- a/docs/docs/plus/index.md +++ b/docs/docs/plus/index.md @@ -17,7 +17,7 @@ Information on how to integrate Frigate+ with Frigate can be found in the [integ ## Available model types -There are two model types offered in Frigate+: `mobiledet` and `yolonas`. Both of these models are object detection models and are trained to detect the same set of labels [listed below](#available-label-types). +There are two model types offered in Frigate+, `mobiledet` and `yolonas`. Both of these models are object detection models and are trained to detect the same set of labels [listed below](#available-label-types). Not all model types are supported by all detectors, so it's important to choose a model type to match your detector as shown in the table under [supported detector types](#supported-detector-types). @@ -32,7 +32,7 @@ Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVi :::warning -Using Frigate+ models with `onnx` and `rocm` is only available with Frigate 0.15, which is still under development. +Using Frigate+ models with `onnx` and `rocm` is only available with Frigate 0.15 and later. ::: @@ -48,11 +48,19 @@ _\* Requires Frigate 0.15_ ## Available label types -Frigate+ models support a more relevant set of objects for security cameras. Currently, only the following objects are supported: `person`, `face`, `car`, `license_plate`, `amazon`, `ups`, `fedex`, `package`, `dog`, `cat`, `deer`. Other object types available in the default Frigate model are not available. Additional object types will be added in future releases. +Frigate+ models support a more relevant set of objects for security cameras. Currently, the following objects are supported: + +- **People**: `person`, `face` +- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `license_plate` +- **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd` +- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit` +- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella` + +Other object types available in the default Frigate model are not available. Additional object types will be added in future releases. ### Label attributes -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 review items directly. In addition, the `threshold` filter will have no effect on these labels. You should adjust the `min_score` and other filter values as needed. +Frigate has special handling for some labels when using Frigate+ models. `face`, `license_plate`, and delivery logos such as `amazon`, `ups`, and `fedex` are considered attribute labels which are not tracked like regular objects and do not generate review items directly. 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: @@ -75,6 +83,6 @@ When using Frigate+ models, Frigate will choose the snapshot of a person object ![Face Attribute](/img/plus/attribute-example-face.jpg) -`amazon`, `ups`, and `fedex` labels are used to automatically assign a sub label to car objects. +Delivery logos such as `amazon`, `ups`, and `fedex` labels are used to automatically assign a sub label to car objects. ![Fedex Attribute](/img/plus/attribute-example-fedex.jpg) diff --git a/docs/docs/troubleshooting/edgetpu.md b/docs/docs/troubleshooting/edgetpu.md index 33e00f11a..8f3cb0db7 100644 --- a/docs/docs/troubleshooting/edgetpu.md +++ b/docs/docs/troubleshooting/edgetpu.md @@ -54,6 +54,17 @@ The most common reason for the PCIe Coral not being detected is that the driver - In most cases [the Coral docs](https://coral.ai/docs/m2/get-started/#2-install-the-pcie-driver-and-edge-tpu-runtime) show how to install the driver for the PCIe based Coral. - For Ubuntu 22.04+ https://github.com/jnicolson/gasket-builder can be used to build and install the latest version of the driver. +### Not detected on Raspberry Pi5 + +A kernel update to the RPi5 means an upate to config.txt is required, see [the raspberry pi forum for more info](https://forums.raspberrypi.com/viewtopic.php?t=363682&sid=cb59b026a412f0dc041595951273a9ca&start=25) + +Specifically, add the following to config.txt + +``` +dtoverlay=pciex1-compat-pi5,no-mip +dtoverlay=pcie-32bit-dma-pi5 +``` + ## Only One PCIe Coral Is Detected With Coral Dual EdgeTPU Coral Dual EdgeTPU is one card with two identical TPU cores. Each core has it's own PCIe interface and motherboard needs to have two PCIe busses on the m.2 slot to make them both work. diff --git a/docs/docs/troubleshooting/faqs.md b/docs/docs/troubleshooting/faqs.md index 889db3370..1af1508e4 100644 --- a/docs/docs/troubleshooting/faqs.md +++ b/docs/docs/troubleshooting/faqs.md @@ -17,6 +17,10 @@ ffmpeg: record: preset-record-generic-audio-aac ``` +### How can I get sound in live view? + +Audio is only supported for live view when go2rtc is configured, see [the live docs](../configuration/live.md) for more information. + ### I can't view recordings in the Web UI. Ensure your cameras send h264 encoded video, or [transcode them](/configuration/restream.md). diff --git a/docs/docs/troubleshooting/recordings.md b/docs/docs/troubleshooting/recordings.md index 611ba45e2..667ea1e8f 100644 --- a/docs/docs/troubleshooting/recordings.md +++ b/docs/docs/troubleshooting/recordings.md @@ -3,7 +3,15 @@ id: recordings title: Troubleshooting Recordings --- -### WARNING : Unable to keep up with recording segments in cache for camera. Keeping the 5 most recent segments out of 6 and discarding the rest... +## I have Frigate configured for motion recording only, but it still seems to be recording even with no motion. Why? + +You'll want to: + +- Make sure your camera's timestamp is masked out with a motion mask. Even if there is no motion occurring in your scene, your motion settings may be sensitive enough to count your timestamp as motion. +- If you have audio detection enabled, keep in mind that audio that is heard above `min_volume` is considered motion. +- [Tune your motion detection settings](/configuration/motion_detection) either by editing your config file or by using the UI's Motion Tuner. + +## I see the message: WARNING : Unable to keep up with recording segments in cache for camera. Keeping the 5 most recent segments out of 6 and discarding the rest... This error can be caused by a number of different issues. The first step in troubleshooting is to enable debug logging for recording. This will enable logging showing how long it takes for recordings to be moved from RAM cache to the disk. @@ -40,6 +48,7 @@ On linux, some helpful tools/commands in diagnosing would be: On modern linux kernels, the system will utilize some swap if enabled. Setting vm.swappiness=1 no longer means that the kernel will only swap in order to avoid OOM. To prevent any swapping inside a container, set allocations memory and memory+swap to be the same and disable swapping by setting the following docker/podman run parameters: **Compose example** + ```yaml version: "3.9" services: @@ -54,6 +63,7 @@ services: ``` **Run command example** + ``` --memory= --memory-swap= --memory-swappiness=0 ``` diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 4ed41d2ad..0c25e4eb7 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -33,9 +33,11 @@ const sidebars: SidebarsConfig = { 'configuration/object_detectors', 'configuration/audio_detectors', ], - 'Semantic Search': [ + Classifiers: [ 'configuration/semantic_search', 'configuration/genai', + 'configuration/face_recognition', + 'configuration/license_plate_recognition', ], Cameras: [ 'configuration/cameras', @@ -82,6 +84,7 @@ const sidebars: SidebarsConfig = { items: frigateHttpApiSidebar, }, 'integrations/mqtt', + 'configuration/metrics', 'integrations/third_party_extensions', ], 'Frigate+': [ diff --git a/docs/static/img/ground-plane.jpg b/docs/static/img/ground-plane.jpg new file mode 100644 index 000000000..f7ea4db2a Binary files /dev/null and b/docs/static/img/ground-plane.jpg differ diff --git a/frigate/__main__.py b/frigate/__main__.py index b086d33b3..4143f7ae6 100644 --- a/frigate/__main__.py +++ b/frigate/__main__.py @@ -3,12 +3,15 @@ import faulthandler import signal import sys import threading +from typing import Union +import ruamel.yaml from pydantic import ValidationError from frigate.app import FrigateApp from frigate.config import FrigateConfig from frigate.log import setup_logging +from frigate.util.config import find_config_file def main() -> None: @@ -42,10 +45,51 @@ def main() -> None: print("*************************************************************") print("*************************************************************") print("*** Config Validation Errors ***") - print("*************************************************************") + print("*************************************************************\n") + # Attempt to get the original config file for line number tracking + config_path = find_config_file() + with open(config_path, "r") as f: + yaml_config = ruamel.yaml.YAML() + yaml_config.preserve_quotes = True + full_config = yaml_config.load(f) + for error in e.errors(): - location = ".".join(str(item) for item in error["loc"]) - print(f"{location}: {error['msg']}") + error_path = error["loc"] + + current = full_config + line_number = "Unknown" + last_line_number = "Unknown" + + try: + for i, part in enumerate(error_path): + key: Union[int, str] = ( + int(part) if isinstance(part, str) and part.isdigit() else part + ) + + if isinstance(current, ruamel.yaml.comments.CommentedMap): + current = current[key] + elif isinstance(current, list): + if isinstance(key, int): + current = current[key] + + if hasattr(current, "lc"): + last_line_number = current.lc.line + + if i == len(error_path) - 1: + if hasattr(current, "lc"): + line_number = current.lc.line + else: + line_number = last_line_number + + except Exception as traverse_error: + print(f"Could not determine exact line number: {traverse_error}") + + if current != full_config: + print(f"Line # : {line_number}") + print(f"Key : {' -> '.join(map(str, error_path))}") + print(f"Value : {error.get('input', '-')}") + print(f"Message : {error.get('msg', error.get('type', 'Unknown'))}\n") + print("*************************************************************") print("*** End Config Validation Errors ***") print("*************************************************************") diff --git a/frigate/api/app.py b/frigate/api/app.py index 7ab54ad86..52e686af1 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -1,5 +1,6 @@ """Main api runner.""" +import asyncio import copy import json import logging @@ -7,15 +8,20 @@ import os import traceback from datetime import datetime, timedelta from functools import reduce +from io import StringIO from typing import Any, Optional +import aiofiles import requests +import ruamel.yaml from fastapi import APIRouter, Body, Path, Request, Response from fastapi.encoders import jsonable_encoder from fastapi.params import Depends -from fastapi.responses import JSONResponse, PlainTextResponse +from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse from markupsafe import escape from peewee import operator +from prometheus_client import CONTENT_TYPE_LATEST, generate_latest +from pydantic import ValidationError from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters from frigate.api.defs.request.app_body import AppConfigSetBody @@ -31,6 +37,7 @@ from frigate.util.config import find_config_file from frigate.util.services import ( ffprobe_stream, get_nvidia_driver_info, + process_logs, restart_frigate, vainfo_hwaccel, ) @@ -105,6 +112,12 @@ def stats_history(request: Request, keys: str = None): return JSONResponse(content=request.app.stats_emitter.get_stats_history(keys)) +@router.get("/metrics") +def metrics(): + """Expose Prometheus metrics endpoint""" + return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST) + + @router.get("/config") def config(request: Request): config_obj: FrigateConfig = request.app.frigate_config @@ -153,6 +166,8 @@ def config(request: Request): config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()} config["model"]["colormap"] = config_obj.model.colormap + config["model"]["all_attributes"] = config_obj.model.all_attributes + config["model"]["non_logo_attributes"] = config_obj.model.non_logo_attributes # use merged labelamp for detector_config in config["detectors"].values(): @@ -185,7 +200,6 @@ def config_raw(): @router.post("/config/save") def config_save(save_option: str, body: Any = Body(media_type="text/plain")): new_config = body.decode() - if not new_config: return JSONResponse( content=( @@ -196,13 +210,64 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")): # Validate the config schema try: + # Use ruamel to parse and preserve line numbers + yaml_config = ruamel.yaml.YAML() + yaml_config.preserve_quotes = True + full_config = yaml_config.load(StringIO(new_config)) + FrigateConfig.parse_yaml(new_config) + + except ValidationError as e: + error_message = [] + + for error in e.errors(): + error_path = error["loc"] + current = full_config + line_number = "Unknown" + last_line_number = "Unknown" + + try: + for i, part in enumerate(error_path): + key = int(part) if part.isdigit() else part + + if isinstance(current, ruamel.yaml.comments.CommentedMap): + current = current[key] + elif isinstance(current, list): + current = current[key] + + if hasattr(current, "lc"): + last_line_number = current.lc.line + + if i == len(error_path) - 1: + if hasattr(current, "lc"): + line_number = current.lc.line + else: + line_number = last_line_number + + except Exception: + line_number = "Unable to determine" + + error_message.append( + f"Line {line_number}: {' -> '.join(map(str, error_path))} - {error.get('msg', error.get('type', 'Unknown'))}" + ) + + return JSONResponse( + content=( + { + "success": False, + "message": "Your configuration is invalid.\nSee the official documentation at docs.frigate.video.\n\n" + + "\n".join(error_message), + } + ), + status_code=400, + ) + except Exception: return JSONResponse( content=( { "success": False, - "message": f"\nConfig Error:\n\n{escape(str(traceback.format_exc()))}", + "message": f"\nYour configuration is invalid.\nSee the official documentation at docs.frigate.video.\n\n{escape(str(traceback.format_exc()))}", } ), status_code=400, @@ -393,9 +458,10 @@ def nvinfo(): @router.get("/logs/{service}", tags=[Tags.logs]) -def logs( +async def logs( service: str = Path(enum=["frigate", "nginx", "go2rtc"]), download: Optional[str] = None, + stream: Optional[bool] = False, start: Optional[int] = 0, end: Optional[int] = None, ): @@ -414,6 +480,27 @@ def logs( status_code=500, ) + async def stream_logs(file_path: str): + """Asynchronously stream log lines.""" + buffer = "" + try: + async with aiofiles.open(file_path, "r") as file: + await file.seek(0, 2) + while True: + line = await file.readline() + if line: + buffer += line + # Process logs only when there are enough lines in the buffer + if "\n" in buffer: + _, processed_lines = process_logs(buffer, service) + buffer = "" + for processed_line in processed_lines: + yield f"{processed_line}\n" + else: + await asyncio.sleep(0.1) + except FileNotFoundError: + yield "Log file not found.\n" + log_locations = { "frigate": "/dev/shm/logs/frigate/current", "go2rtc": "/dev/shm/logs/go2rtc/current", @@ -430,48 +517,17 @@ def logs( if download: return download_logs(service_location) + if stream: + return StreamingResponse(stream_logs(service_location), media_type="text/plain") + + # For full logs initially try: - file = open(service_location, "r") - contents = file.read() - file.close() - - # use the start timestamp to group logs together`` - logLines = [] - keyLength = 0 - dateEnd = 0 - currentKey = "" - currentLine = "" - - for rawLine in contents.splitlines(): - cleanLine = rawLine.strip() - - if len(cleanLine) < 10: - continue - - # handle cases where S6 does not include date in log line - if " " not in cleanLine: - cleanLine = f"{datetime.now()} {cleanLine}" - - if dateEnd == 0: - dateEnd = cleanLine.index(" ") - keyLength = dateEnd - (6 if service_location == "frigate" else 0) - - newKey = cleanLine[0:keyLength] - - if newKey == currentKey: - currentLine += f"\n{cleanLine[dateEnd:].strip()}" - continue - else: - if len(currentLine) > 0: - logLines.append(currentLine) - - currentKey = newKey - currentLine = cleanLine - - logLines.append(currentLine) + async with aiofiles.open(service_location, "r") as file: + contents = await file.read() + total_lines, log_lines = process_logs(contents, service, start, end) return JSONResponse( - content={"totalLines": len(logLines), "lines": logLines[start:end]}, + content={"totalLines": total_lines, "lines": log_lines}, status_code=200, ) except FileNotFoundError as e: diff --git a/frigate/api/classification.py b/frigate/api/classification.py new file mode 100644 index 000000000..7cd127d07 --- /dev/null +++ b/frigate/api/classification.py @@ -0,0 +1,178 @@ +"""Object classification APIs.""" + +import logging +import os +import random +import shutil +import string + +from fastapi import APIRouter, Request, UploadFile +from fastapi.responses import JSONResponse +from pathvalidate import sanitize_filename + +from frigate.api.defs.tags import Tags +from frigate.const import FACE_DIR +from frigate.embeddings import EmbeddingsContext + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=[Tags.events]) + + +@router.get("/faces") +def get_faces(): + face_dict: dict[str, list[str]] = {} + + for name in os.listdir(FACE_DIR): + face_dir = os.path.join(FACE_DIR, name) + + if not os.path.isdir(face_dir): + continue + + face_dict[name] = [] + + for file in sorted( + os.listdir(face_dir), + key=lambda f: os.path.getctime(os.path.join(face_dir, f)), + reverse=True, + ): + face_dict[name].append(file) + + return JSONResponse(status_code=200, content=face_dict) + + +@router.post("/faces/reprocess") +def reclassify_face(request: Request, body: dict = None): + if not request.app.frigate_config.face_recognition.enabled: + return JSONResponse( + status_code=400, + content={"message": "Face recognition is not enabled.", "success": False}, + ) + + json: dict[str, any] = body or {} + training_file = os.path.join( + FACE_DIR, f"train/{sanitize_filename(json.get('training_file', ''))}" + ) + + if not training_file or not os.path.isfile(training_file): + return JSONResponse( + content=( + { + "success": False, + "message": f"Invalid filename or no file exists: {training_file}", + } + ), + status_code=404, + ) + + context: EmbeddingsContext = request.app.embeddings + response = context.reprocess_face(training_file) + + return JSONResponse( + content=response, + status_code=200, + ) + + +@router.post("/faces/train/{name}/classify") +def train_face(request: Request, name: str, body: dict = None): + if not request.app.frigate_config.face_recognition.enabled: + return JSONResponse( + status_code=400, + content={"message": "Face recognition is not enabled.", "success": False}, + ) + + json: dict[str, any] = body or {} + training_file = os.path.join( + FACE_DIR, f"train/{sanitize_filename(json.get('training_file', ''))}" + ) + + if not training_file or not os.path.isfile(training_file): + return JSONResponse( + content=( + { + "success": False, + "message": f"Invalid filename or no file exists: {training_file}", + } + ), + status_code=404, + ) + + sanitized_name = sanitize_filename(name) + rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) + new_name = f"{sanitized_name}-{rand_id}.webp" + new_file = os.path.join(FACE_DIR, f"{sanitized_name}/{new_name}") + shutil.move(training_file, new_file) + + context: EmbeddingsContext = request.app.embeddings + context.clear_face_classifier() + + return JSONResponse( + content=( + { + "success": True, + "message": f"Successfully saved {training_file} as {new_name}.", + } + ), + status_code=200, + ) + + +@router.post("/faces/{name}/create") +async def create_face(request: Request, name: str): + if not request.app.frigate_config.face_recognition.enabled: + return JSONResponse( + status_code=400, + content={"message": "Face recognition is not enabled.", "success": False}, + ) + + os.makedirs( + os.path.join(FACE_DIR, sanitize_filename(name.replace(" ", "_"))), exist_ok=True + ) + return JSONResponse( + status_code=200, + content={"success": False, "message": "Successfully created face folder."}, + ) + + +@router.post("/faces/{name}/register") +async def register_face(request: Request, name: str, file: UploadFile): + if not request.app.frigate_config.face_recognition.enabled: + return JSONResponse( + status_code=400, + content={"message": "Face recognition is not enabled.", "success": False}, + ) + + context: EmbeddingsContext = request.app.embeddings + result = context.register_face(name, await file.read()) + return JSONResponse( + status_code=200 if result.get("success", True) else 400, + content=result, + ) + + +@router.post("/faces/{name}/delete") +def deregister_faces(request: Request, name: str, body: dict = None): + if not request.app.frigate_config.face_recognition.enabled: + return JSONResponse( + status_code=400, + content={"message": "Face recognition is not enabled.", "success": False}, + ) + + json: dict[str, any] = body or {} + list_of_ids = json.get("ids", "") + + if not list_of_ids or len(list_of_ids) == 0: + return JSONResponse( + content=({"success": False, "message": "Not a valid list of ids"}), + status_code=404, + ) + + context: EmbeddingsContext = request.app.embeddings + context.delete_face_ids( + name, map(lambda file: sanitize_filename(file), list_of_ids) + ) + return JSONResponse( + content=({"success": True, "message": "Successfully deleted faces."}), + status_code=200, + ) diff --git a/frigate/api/defs/query/events_query_parameters.py b/frigate/api/defs/query/events_query_parameters.py index 5a2b61d43..01c79abb0 100644 --- a/frigate/api/defs/query/events_query_parameters.py +++ b/frigate/api/defs/query/events_query_parameters.py @@ -25,6 +25,8 @@ class EventsQueryParams(BaseModel): favorites: Optional[int] = None min_score: Optional[float] = None max_score: Optional[float] = None + min_speed: Optional[float] = None + max_speed: Optional[float] = None is_submitted: Optional[int] = None min_length: Optional[float] = None max_length: Optional[float] = None @@ -51,6 +53,8 @@ class EventsSearchQueryParams(BaseModel): timezone: Optional[str] = "utc" min_score: Optional[float] = None max_score: Optional[float] = None + min_speed: Optional[float] = None + max_speed: Optional[float] = None sort: Optional[str] = None diff --git a/frigate/api/defs/query/media_query_parameters.py b/frigate/api/defs/query/media_query_parameters.py index b7df85d30..4750d3277 100644 --- a/frigate/api/defs/query/media_query_parameters.py +++ b/frigate/api/defs/query/media_query_parameters.py @@ -20,6 +20,7 @@ class MediaLatestFrameQueryParams(BaseModel): regions: Optional[int] = None quality: Optional[int] = 70 height: Optional[int] = None + store: Optional[int] = None class MediaEventsSnapshotQueryParams(BaseModel): @@ -40,3 +41,8 @@ class MediaMjpegFeedQueryParams(BaseModel): mask: Optional[int] = None motion: Optional[int] = None regions: Optional[int] = None + + +class MediaRecordingsSummaryQueryParams(BaseModel): + timezone: str = "utc" + cameras: Optional[str] = "all" diff --git a/frigate/api/defs/request/events_body.py b/frigate/api/defs/request/events_body.py index 1c8576f02..0fefbe43f 100644 --- a/frigate/api/defs/request/events_body.py +++ b/frigate/api/defs/request/events_body.py @@ -8,6 +8,9 @@ class EventsSubLabelBody(BaseModel): subLabelScore: Optional[float] = Field( title="Score for sub label", default=None, gt=0.0, le=1.0 ) + camera: Optional[str] = Field( + title="Camera this object is detected on.", default=None + ) class EventsDescriptionBody(BaseModel): diff --git a/frigate/api/defs/tags.py b/frigate/api/defs/tags.py index 80faf255c..9e61da9e9 100644 --- a/frigate/api/defs/tags.py +++ b/frigate/api/defs/tags.py @@ -10,4 +10,5 @@ class Tags(Enum): review = "Review" export = "Export" events = "Events" + classification = "classification" auth = "Auth" diff --git a/frigate/api/event.py b/frigate/api/event.py index 3ba4ae426..247366920 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -92,6 +92,8 @@ def events(params: EventsQueryParams = Depends()): favorites = params.favorites min_score = params.min_score max_score = params.max_score + min_speed = params.min_speed + max_speed = params.max_speed is_submitted = params.is_submitted min_length = params.min_length max_length = params.max_length @@ -226,6 +228,12 @@ def events(params: EventsQueryParams = Depends()): if min_score is not None: clauses.append((Event.data["score"] >= min_score)) + if max_speed is not None: + clauses.append((Event.data["average_estimated_speed"] <= max_speed)) + + if min_speed is not None: + clauses.append((Event.data["average_estimated_speed"] >= min_speed)) + if min_length is not None: clauses.append(((Event.end_time - Event.start_time) >= min_length)) @@ -249,6 +257,10 @@ def events(params: EventsQueryParams = Depends()): order_by = Event.data["score"].asc() elif sort == "score_desc": order_by = Event.data["score"].desc() + elif sort == "speed_asc": + order_by = Event.data["average_estimated_speed"].asc() + elif sort == "speed_desc": + order_by = Event.data["average_estimated_speed"].desc() elif sort == "date_asc": order_by = Event.start_time.asc() elif sort == "date_desc": @@ -316,7 +328,15 @@ def events_explore(limit: int = 10): k: v for k, v in event.data.items() if k - in ["type", "score", "top_score", "description", "sub_label_score"] + in [ + "type", + "score", + "top_score", + "description", + "sub_label_score", + "average_estimated_speed", + "velocity_angle", + ] }, "event_count": label_counts[event.label], } @@ -367,6 +387,8 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends()) before = params.before min_score = params.min_score max_score = params.max_score + min_speed = params.min_speed + max_speed = params.max_speed time_range = params.time_range has_clip = params.has_clip has_snapshot = params.has_snapshot @@ -466,6 +488,16 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends()) if max_score is not None: event_filters.append((Event.data["score"] <= max_score)) + if min_speed is not None and max_speed is not None: + event_filters.append( + (Event.data["average_estimated_speed"].between(min_speed, max_speed)) + ) + else: + if min_speed is not None: + event_filters.append((Event.data["average_estimated_speed"] >= min_speed)) + if max_speed is not None: + event_filters.append((Event.data["average_estimated_speed"] <= max_speed)) + if time_range != DEFAULT_TIME_RANGE: tz_name = params.timezone hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name) @@ -581,7 +613,16 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends()) processed_event["data"] = { k: v for k, v in event["data"].items() - if k in ["type", "score", "top_score", "description"] + if k + in [ + "type", + "score", + "top_score", + "description", + "sub_label_score", + "average_estimated_speed", + "velocity_angle", + ] } if event["id"] in search_results: @@ -596,6 +637,10 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends()) processed_events.sort(key=lambda x: x["score"]) elif min_score is not None and max_score is not None and sort == "score_desc": processed_events.sort(key=lambda x: x["score"], reverse=True) + elif min_speed is not None and max_speed is not None and sort == "speed_asc": + processed_events.sort(key=lambda x: x["average_estimated_speed"]) + elif min_speed is not None and max_speed is not None and sort == "speed_desc": + processed_events.sort(key=lambda x: x["average_estimated_speed"], reverse=True) elif sort == "date_asc": processed_events.sort(key=lambda x: x["start_time"]) else: @@ -909,38 +954,59 @@ def set_sub_label( try: event: Event = Event.get(Event.id == event_id) except DoesNotExist: + if not body.camera: + return JSONResponse( + content=( + { + "success": False, + "message": "Event " + + event_id + + " not found and camera is not provided.", + } + ), + status_code=404, + ) + + event = None + + if request.app.detected_frames_processor: + tracked_obj: TrackedObject = ( + request.app.detected_frames_processor.camera_states[ + event.camera if event else body.camera + ].tracked_objects.get(event_id) + ) + else: + tracked_obj = None + + if not event and not tracked_obj: return JSONResponse( - content=({"success": False, "message": "Event " + event_id + " not found"}), + content=( + {"success": False, "message": "Event " + event_id + " not found."} + ), status_code=404, ) new_sub_label = body.subLabel new_score = body.subLabelScore - if not event.end_time: - # update tracked object - tracked_obj: TrackedObject = ( - request.app.detected_frames_processor.camera_states[ - event.camera - ].tracked_objects.get(event.id) - ) - - if tracked_obj: - tracked_obj.obj_data["sub_label"] = (new_sub_label, new_score) + if tracked_obj: + tracked_obj.obj_data["sub_label"] = (new_sub_label, new_score) # update timeline items Timeline.update( data=Timeline.data.update({"sub_label": (new_sub_label, new_score)}) ).where(Timeline.source_id == event_id).execute() - event.sub_label = new_sub_label + if event: + event.sub_label = new_sub_label - if new_score: - data = event.data - data["sub_label_score"] = new_score - event.data = data + if new_score: + data = event.data + data["sub_label_score"] = new_score + event.data = data + + event.save() - event.save() return JSONResponse( content=( { diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index 168404ea6..40df19343 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -11,7 +11,16 @@ from starlette_context import middleware, plugins from starlette_context.plugins import Plugin from frigate.api import app as main_app -from frigate.api import auth, event, export, media, notification, preview, review +from frigate.api import ( + auth, + classification, + event, + export, + media, + notification, + preview, + review, +) from frigate.api.auth import get_jwt_secret, limiter from frigate.comms.event_metadata_updater import ( EventMetadataPublisher, @@ -26,14 +35,13 @@ from frigate.storage import StorageMaintainer logger = logging.getLogger(__name__) -def check_csrf(request: Request): +def check_csrf(request: Request) -> bool: if request.method in ["GET", "HEAD", "OPTIONS", "TRACE"]: - pass + return True if "origin" in request.headers and "x-csrf-token" not in request.headers: - return JSONResponse( - content={"success": False, "message": "Missing CSRF header"}, - status_code=401, - ) + return False + + return True # Used to retrieve the remote-user header: https://starlette-context.readthedocs.io/en/latest/plugins.html#easy-mode @@ -71,7 +79,12 @@ def create_fastapi_app( @app.middleware("http") async def frigate_middleware(request: Request, call_next): # Before request - check_csrf(request) + if not check_csrf(request): + return JSONResponse( + content={"success": False, "message": "Missing CSRF header"}, + status_code=401, + ) + if database.is_closed(): database.connect() @@ -99,6 +112,7 @@ def create_fastapi_app( # Routes # Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters app.include_router(auth.router) + app.include_router(classification.router) app.include_router(review.router) app.include_router(main_app.router) app.include_router(preview.router) diff --git a/frigate/api/media.py b/frigate/api/media.py index e19fe547f..39a6f7d1e 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -25,6 +25,7 @@ from frigate.api.defs.query.media_query_parameters import ( MediaEventsSnapshotQueryParams, MediaLatestFrameQueryParams, MediaMjpegFeedQueryParams, + MediaRecordingsSummaryQueryParams, ) from frigate.api.defs.tags import Tags from frigate.config import FrigateConfig @@ -133,6 +134,15 @@ def latest_frame( "regions": params.regions, } quality = params.quality + mime_type = extension + + if extension == "png": + quality_params = None + elif extension == "webp": + quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), quality] + else: + quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), quality] + mime_type = "jpeg" if camera_name in request.app.frigate_config.cameras: frame = frame_processor.get_current_frame(camera_name, draw_options) @@ -173,13 +183,16 @@ def latest_frame( frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA) - ret, img = cv2.imencode( - f".{extension}", frame, [int(cv2.IMWRITE_WEBP_QUALITY), quality] - ) + _, img = cv2.imencode(f".{extension}", frame, quality_params) return Response( content=img.tobytes(), - media_type=f"image/{extension}", - headers={"Content-Type": f"image/{extension}", "Cache-Control": "no-store"}, + media_type=f"image/{mime_type}", + headers={ + "Content-Type": f"image/{mime_type}", + "Cache-Control": "no-store" + if not params.store + else "private, max-age=60", + }, ) elif camera_name == "birdseye" and request.app.frigate_config.birdseye.restream: frame = cv2.cvtColor( @@ -192,13 +205,16 @@ def latest_frame( frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA) - ret, img = cv2.imencode( - f".{extension}", frame, [int(cv2.IMWRITE_WEBP_QUALITY), quality] - ) + _, img = cv2.imencode(f".{extension}", frame, quality_params) return Response( content=img.tobytes(), - media_type=f"image/{extension}", - headers={"Content-Type": f"image/{extension}", "Cache-Control": "no-store"}, + media_type=f"image/{mime_type}", + headers={ + "Content-Type": f"image/{mime_type}", + "Cache-Control": "no-store" + if not params.store + else "private, max-age=60", + }, ) else: return JSONResponse( @@ -241,6 +257,7 @@ def get_snapshot_from_recording( recording: Recordings = recording_query.get() time_in_segment = frame_time - recording.start_time codec = "png" if format == "png" else "mjpeg" + mime_type = "png" if format == "png" else "jpeg" config: FrigateConfig = request.app.frigate_config image_data = get_image_from_recording( @@ -257,7 +274,7 @@ def get_snapshot_from_recording( ), status_code=404, ) - return Response(image_data, headers={"Content-Type": f"image/{format}"}) + return Response(image_data, headers={"Content-Type": f"image/{mime_type}"}) except DoesNotExist: return JSONResponse( content={ @@ -356,6 +373,48 @@ def get_recordings_storage_usage(request: Request): return JSONResponse(content=camera_usages) +@router.get("/recordings/summary") +def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends()): + """Returns true/false by day indicating if recordings exist""" + hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone) + + cameras = params.cameras + + query = ( + Recordings.select( + fn.strftime( + "%Y-%m-%d", + fn.datetime( + Recordings.start_time + seconds_offset, + "unixepoch", + hour_modifier, + minute_modifier, + ), + ).alias("day") + ) + .group_by( + fn.strftime( + "%Y-%m-%d", + fn.datetime( + Recordings.start_time + seconds_offset, + "unixepoch", + hour_modifier, + minute_modifier, + ), + ) + ) + .order_by(Recordings.start_time.desc()) + ) + + if cameras != "all": + query = query.where(Recordings.camera << cameras.split(",")) + + recording_days = query.namedtuples() + days = {day.day: True for day in recording_days} + + return JSONResponse(content=days) + + @router.get("/{camera_name}/recordings/summary") def recordings_summary(camera_name: str, timezone: str = "utc"): """Returns hourly summary for recordings of given camera""" diff --git a/frigate/api/review.py b/frigate/api/review.py index e5692f009..3e503d400 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -110,6 +110,28 @@ def review(params: ReviewQueryParams = Depends()): return JSONResponse(content=[r for r in review]) +@router.get("/review_ids", response_model=list[ReviewSegmentResponse]) +def review_ids(ids: str): + ids = ids.split(",") + + if not ids: + return JSONResponse( + content=({"success": False, "message": "Valid list of ids must be sent"}), + status_code=400, + ) + + try: + reviews = ( + ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator() + ) + return JSONResponse(list(reviews)) + except Exception: + return JSONResponse( + content=({"success": False, "message": "Review segments not found"}), + status_code=400, + ) + + @router.get("/review/summary", response_model=ReviewSummaryResponse) def review_summary(params: ReviewSummaryQueryParams = Depends()): hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone) @@ -490,8 +512,6 @@ def set_not_reviewed(review_id: str): review.save() return JSONResponse( - content=( - {"success": True, "message": "Set Review " + review_id + " as not viewed"} - ), + content=({"success": True, "message": f"Set Review {review_id} as not viewed"}), status_code=200, ) diff --git a/frigate/app.py b/frigate/app.py index 02955b6c9..6ff4a1a41 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -17,8 +17,9 @@ import frigate.util as util from frigate.api.auth import hash_password from frigate.api.fastapi_app import create_fastapi_app from frigate.camera import CameraMetrics, PTZMetrics +from frigate.comms.base_communicator import Communicator from frigate.comms.config_updater import ConfigPublisher -from frigate.comms.dispatcher import Communicator, Dispatcher +from frigate.comms.dispatcher import Dispatcher from frigate.comms.event_metadata_updater import ( EventMetadataPublisher, EventMetadataTypeEnum, @@ -34,10 +35,12 @@ from frigate.const import ( CLIPS_DIR, CONFIG_DIR, EXPORT_DIR, + FACE_DIR, MODEL_CACHE_DIR, RECORD_DIR, SHM_FRAMES_VAR, ) +from frigate.data_processing.types import DataProcessorMetrics from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.embeddings import EmbeddingsContext, manage_embeddings from frigate.events.audio import AudioProcessor @@ -88,6 +91,9 @@ class FrigateApp: self.detection_shms: list[mp.shared_memory.SharedMemory] = [] self.log_queue: Queue = mp.Queue() self.camera_metrics: dict[str, CameraMetrics] = {} + self.embeddings_metrics: DataProcessorMetrics | None = ( + DataProcessorMetrics() if config.semantic_search.enabled else None + ) self.ptz_metrics: dict[str, PTZMetrics] = {} self.processes: dict[str, int] = {} self.embeddings: Optional[EmbeddingsContext] = None @@ -96,14 +102,19 @@ class FrigateApp: self.config = config def ensure_dirs(self) -> None: - for d in [ + dirs = [ CONFIG_DIR, RECORD_DIR, f"{CLIPS_DIR}/cache", CACHE_DIR, MODEL_CACHE_DIR, EXPORT_DIR, - ]: + ] + + if self.config.face_recognition.enabled: + dirs.append(FACE_DIR) + + for d in dirs: if not os.path.exists(d) and not os.path.islink(d): logger.info(f"Creating directory: {d}") os.makedirs(d) @@ -229,7 +240,10 @@ class FrigateApp: embedding_process = util.Process( target=manage_embeddings, name="embeddings_manager", - args=(self.config,), + args=( + self.config, + self.embeddings_metrics, + ), ) embedding_process.daemon = True self.embedding_process = embedding_process @@ -301,8 +315,14 @@ class FrigateApp: if self.config.mqtt.enabled: comms.append(MqttClient(self.config)) - if self.config.notifications.enabled_in_config: - comms.append(WebPushClient(self.config)) + notification_cameras = [ + c + for c in self.config.cameras.values() + if c.enabled and c.notifications.enabled_in_config + ] + + if notification_cameras: + comms.append(WebPushClient(self.config, self.stop_event)) comms.append(WebSocketClient(self.config)) comms.append(self.inter_process_communicator) @@ -491,7 +511,11 @@ class FrigateApp: self.stats_emitter = StatsEmitter( self.config, stats_init( - self.config, self.camera_metrics, self.detectors, self.processes + self.config, + self.camera_metrics, + self.embeddings_metrics, + self.detectors, + self.processes, ), self.stop_event, ) diff --git a/frigate/camera/activity_manager.py b/frigate/camera/activity_manager.py new file mode 100644 index 000000000..9c06cf6f9 --- /dev/null +++ b/frigate/camera/activity_manager.py @@ -0,0 +1,130 @@ +"""Manage camera activity and updating listeners.""" + +from collections import Counter +from typing import Callable + +from frigate.config.config import FrigateConfig + + +class CameraActivityManager: + def __init__( + self, config: FrigateConfig, publish: Callable[[str, any], None] + ) -> None: + self.config = config + self.publish = publish + self.last_camera_activity: dict[str, dict[str, any]] = {} + self.camera_all_object_counts: dict[str, Counter] = {} + self.camera_active_object_counts: dict[str, Counter] = {} + self.zone_all_object_counts: dict[str, Counter] = {} + self.zone_active_object_counts: dict[str, Counter] = {} + self.all_zone_labels: dict[str, set[str]] = {} + + for camera_config in config.cameras.values(): + if not camera_config.enabled: + continue + + self.last_camera_activity[camera_config.name] = {} + self.camera_all_object_counts[camera_config.name] = Counter() + self.camera_active_object_counts[camera_config.name] = Counter() + + for zone, zone_config in camera_config.zones.items(): + if zone not in self.all_zone_labels: + self.zone_all_object_counts[zone] = Counter() + self.zone_active_object_counts[zone] = Counter() + self.all_zone_labels[zone] = set() + + self.all_zone_labels[zone].update(zone_config.objects) + + def update_activity(self, new_activity: dict[str, dict[str, any]]) -> None: + all_objects: list[dict[str, any]] = [] + + for camera in new_activity.keys(): + new_objects = new_activity[camera].get("objects", []) + all_objects.extend(new_objects) + + if self.last_camera_activity.get(camera, {}).get("objects") != new_objects: + self.compare_camera_activity(camera, new_objects) + + # run through every zone, getting a count of objects in that zone right now + for zone, labels in self.all_zone_labels.items(): + all_zone_objects = Counter( + obj["label"].replace("-verified", "") + for obj in all_objects + if zone in obj["current_zones"] + ) + active_zone_objects = Counter( + obj["label"].replace("-verified", "") + for obj in all_objects + if zone in obj["current_zones"] and not obj["stationary"] + ) + any_changed = False + + # run through each object and check what topics need to be updated for this zone + for label in labels: + new_count = all_zone_objects[label] + new_active_count = active_zone_objects[label] + + if ( + new_count != self.zone_all_object_counts[zone][label] + or label not in self.zone_all_object_counts[zone] + ): + any_changed = True + self.publish(f"{zone}/{label}", new_count) + self.zone_all_object_counts[zone][label] = new_count + + if ( + new_active_count != self.zone_active_object_counts[zone][label] + or label not in self.zone_active_object_counts[zone] + ): + any_changed = True + self.publish(f"{zone}/{label}/active", new_active_count) + self.zone_active_object_counts[zone][label] = new_active_count + + if any_changed: + self.publish(f"{zone}/all", sum(list(all_zone_objects.values()))) + self.publish( + f"{zone}/all/active", sum(list(active_zone_objects.values())) + ) + + self.last_camera_activity = new_activity + + def compare_camera_activity( + self, camera: str, new_activity: dict[str, any] + ) -> None: + all_objects = Counter( + obj["label"].replace("-verified", "") for obj in new_activity + ) + active_objects = Counter( + obj["label"].replace("-verified", "") + for obj in new_activity + if not obj["stationary"] + ) + any_changed = False + + # run through each object and check what topics need to be updated + for label in self.config.cameras[camera].objects.track: + if label in self.config.model.non_logo_attributes: + continue + + new_count = all_objects[label] + new_active_count = active_objects[label] + + if ( + new_count != self.camera_all_object_counts[camera][label] + or label not in self.camera_all_object_counts[camera] + ): + any_changed = True + self.publish(f"{camera}/{label}", new_count) + self.camera_all_object_counts[camera][label] = new_count + + if ( + new_active_count != self.camera_active_object_counts[camera][label] + or label not in self.camera_active_object_counts[camera] + ): + any_changed = True + self.publish(f"{camera}/{label}/active", new_active_count) + self.camera_active_object_counts[camera][label] = new_active_count + + if any_changed: + self.publish(f"{camera}/all", sum(list(all_objects.values()))) + self.publish(f"{camera}/all/active", sum(list(active_objects.values()))) diff --git a/frigate/comms/base_communicator.py b/frigate/comms/base_communicator.py new file mode 100644 index 000000000..5dfbf1115 --- /dev/null +++ b/frigate/comms/base_communicator.py @@ -0,0 +1,21 @@ +from abc import ABC, abstractmethod +from typing import Any, Callable + + +class Communicator(ABC): + """pub/sub model via specific protocol.""" + + @abstractmethod + def publish(self, topic: str, payload: Any, retain: bool = False) -> None: + """Send data via specific protocol.""" + pass + + @abstractmethod + def subscribe(self, receiver: Callable) -> None: + """Pass receiver so communicators can pass commands.""" + pass + + @abstractmethod + def stop(self) -> None: + """Stop the communicator.""" + pass diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 2bddc97a5..e0c2d96e3 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -3,16 +3,19 @@ import datetime import json import logging -from abc import ABC, abstractmethod from typing import Any, Callable, Optional from frigate.camera import PTZMetrics +from frigate.camera.activity_manager import CameraActivityManager +from frigate.comms.base_communicator import Communicator from frigate.comms.config_updater import ConfigPublisher +from frigate.comms.webpush import WebPushClient from frigate.config import BirdseyeModeEnum, FrigateConfig from frigate.const import ( CLEAR_ONGOING_REVIEW_SEGMENTS, INSERT_MANY_RECORDINGS, INSERT_PREVIEW, + NOTIFICATION_TEST, REQUEST_REGION_GRID, UPDATE_CAMERA_ACTIVITY, UPDATE_EMBEDDINGS_REINDEX_PROGRESS, @@ -29,25 +32,6 @@ from frigate.util.services import restart_frigate logger = logging.getLogger(__name__) -class Communicator(ABC): - """pub/sub model via specific protocol.""" - - @abstractmethod - def publish(self, topic: str, payload: Any, retain: bool = False) -> None: - """Send data via specific protocol.""" - pass - - @abstractmethod - def subscribe(self, receiver: Callable) -> None: - """Pass receiver so communicators can pass commands.""" - pass - - @abstractmethod - def stop(self) -> None: - """Stop the communicator.""" - pass - - class Dispatcher: """Handle communication between Frigate and communicators.""" @@ -64,7 +48,7 @@ class Dispatcher: self.onvif = onvif self.ptz_metrics = ptz_metrics self.comms = communicators - self.camera_activity = {} + self.camera_activity = CameraActivityManager(config, self.publish) self.model_state = {} self.embeddings_reindex = {} @@ -76,18 +60,23 @@ class Dispatcher: "motion": self._on_motion_command, "motion_contour_area": self._on_motion_contour_area_command, "motion_threshold": self._on_motion_threshold_command, + "notifications": self._on_camera_notification_command, "recordings": self._on_recordings_command, "snapshots": self._on_snapshots_command, "birdseye": self._on_birdseye_command, "birdseye_mode": self._on_birdseye_mode_command, } self._global_settings_handlers: dict[str, Callable] = { - "notifications": self._on_notification_command, + "notifications": self._on_global_notification_command, } for comm in self.comms: comm.subscribe(self._receive) + self.web_push_client = next( + (comm for comm in communicators if isinstance(comm, WebPushClient)), None + ) + def _receive(self, topic: str, payload: str) -> Optional[Any]: """Handle receiving of payload from communicators.""" @@ -130,7 +119,7 @@ class Dispatcher: ).execute() def handle_update_camera_activity(): - self.camera_activity = payload + self.camera_activity.update_activity(payload) def handle_update_event_description(): event: Event = Event.get(Event.id == payload["id"]) @@ -171,7 +160,7 @@ class Dispatcher: ) def handle_on_connect(): - camera_status = self.camera_activity.copy() + camera_status = self.camera_activity.last_camera_activity.copy() for camera in camera_status.keys(): camera_status[camera]["config"] = { @@ -179,6 +168,13 @@ class Dispatcher: "snapshots": self.config.cameras[camera].snapshots.enabled, "record": self.config.cameras[camera].record.enabled, "audio": self.config.cameras[camera].audio.enabled, + "notifications": self.config.cameras[camera].notifications.enabled, + "notifications_suspended": int( + self.web_push_client.suspended_cameras.get(camera, 0) + ) + if self.web_push_client + and camera in self.web_push_client.suspended_cameras + else 0, "autotracking": self.config.cameras[ camera ].onvif.autotracking.enabled, @@ -191,6 +187,9 @@ class Dispatcher: json.dumps(self.embeddings_reindex.copy()), ) + def handle_notification_test(): + self.publish("notification_test", "Test notification") + # Dictionary mapping topic to handlers topic_handlers = { INSERT_MANY_RECORDINGS: handle_insert_many_recordings, @@ -202,13 +201,14 @@ class Dispatcher: UPDATE_EVENT_DESCRIPTION: handle_update_event_description, UPDATE_MODEL_STATE: handle_update_model_state, UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress, + NOTIFICATION_TEST: handle_notification_test, "restart": handle_restart, "embeddingsReindexProgress": handle_embeddings_reindex_progress, "modelState": handle_model_state, "onConnect": handle_on_connect, } - if topic.endswith("set") or topic.endswith("ptz"): + if topic.endswith("set") or topic.endswith("ptz") or topic.endswith("suspend"): try: parts = topic.split("/") if len(parts) == 3 and topic.endswith("set"): @@ -223,6 +223,11 @@ class Dispatcher: # example /cam_name/ptz payload=MOVE_UP|MOVE_DOWN|STOP... camera_name = parts[-2] handle_camera_command("ptz", camera_name, "", payload) + elif len(parts) == 3 and topic.endswith("suspend"): + # example /cam_name/notifications/suspend payload=duration + camera_name = parts[-3] + command = parts[-2] + self._on_camera_notification_suspend(camera_name, payload) except IndexError: logger.error( f"Received invalid {topic.split('/')[-1]} command: {topic}" @@ -364,16 +369,18 @@ class Dispatcher: self.config_updater.publish(f"config/motion/{camera_name}", motion_settings) self.publish(f"{camera_name}/motion_threshold/state", payload, retain=True) - def _on_notification_command(self, payload: str) -> None: - """Callback for notification topic.""" + def _on_global_notification_command(self, payload: str) -> None: + """Callback for global notification topic.""" if payload != "ON" and payload != "OFF": - f"Received unsupported value for notification: {payload}" + f"Received unsupported value for all notification: {payload}" return notification_settings = self.config.notifications - logger.info(f"Setting notifications: {payload}") + logger.info(f"Setting all notifications: {payload}") notification_settings.enabled = payload == "ON" # type: ignore[union-attr] - self.config_updater.publish("config/notifications", notification_settings) + self.config_updater.publish( + "config/notifications", {"_global_notifications": notification_settings} + ) self.publish("notifications/state", payload, retain=True) def _on_audio_command(self, camera_name: str, payload: str) -> None: @@ -490,3 +497,71 @@ class Dispatcher: self.config_updater.publish(f"config/birdseye/{camera_name}", birdseye_settings) self.publish(f"{camera_name}/birdseye_mode/state", payload, retain=True) + + def _on_camera_notification_command(self, camera_name: str, payload: str) -> None: + """Callback for camera level notifications topic.""" + notification_settings = self.config.cameras[camera_name].notifications + + if payload == "ON": + if not self.config.cameras[camera_name].notifications.enabled_in_config: + logger.error( + "Notifications must be enabled in the config to be turned on via MQTT." + ) + return + + if not notification_settings.enabled: + logger.info(f"Turning on notifications for {camera_name}") + notification_settings.enabled = True + if ( + self.web_push_client + and camera_name in self.web_push_client.suspended_cameras + ): + self.web_push_client.suspended_cameras[camera_name] = 0 + elif payload == "OFF": + if notification_settings.enabled: + logger.info(f"Turning off notifications for {camera_name}") + notification_settings.enabled = False + if ( + self.web_push_client + and camera_name in self.web_push_client.suspended_cameras + ): + self.web_push_client.suspended_cameras[camera_name] = 0 + + self.config_updater.publish( + "config/notifications", {camera_name: notification_settings} + ) + self.publish(f"{camera_name}/notifications/state", payload, retain=True) + self.publish(f"{camera_name}/notifications/suspended", "0", retain=True) + + def _on_camera_notification_suspend(self, camera_name: str, payload: str) -> None: + """Callback for camera level notifications suspend topic.""" + try: + duration = int(payload) + except ValueError: + logger.error(f"Invalid suspension duration: {payload}") + return + + if self.web_push_client is None: + logger.error("WebPushClient not available for suspension") + return + + notification_settings = self.config.cameras[camera_name].notifications + + if not notification_settings.enabled: + logger.error(f"Notifications are not enabled for {camera_name}") + return + + if duration != 0: + self.web_push_client.suspend_notifications(camera_name, duration) + else: + self.web_push_client.unsuspend_notifications(camera_name) + + self.publish( + f"{camera_name}/notifications/suspended", + str( + int(self.web_push_client.suspended_cameras.get(camera_name, 0)) + if camera_name in self.web_push_client.suspended_cameras + else 0 + ), + retain=True, + ) diff --git a/frigate/comms/embeddings_updater.py b/frigate/comms/embeddings_updater.py index 9a13525f8..58f012e7d 100644 --- a/frigate/comms/embeddings_updater.py +++ b/frigate/comms/embeddings_updater.py @@ -9,9 +9,12 @@ SOCKET_REP_REQ = "ipc:///tmp/cache/embeddings" class EmbeddingsRequestEnum(Enum): + clear_face_classifier = "clear_face_classifier" embed_description = "embed_description" embed_thumbnail = "embed_thumbnail" generate_search = "generate_search" + register_face = "register_face" + reprocess_face = "reprocess_face" class EmbeddingsResponder: @@ -22,7 +25,7 @@ class EmbeddingsResponder: def check_for_request(self, process: Callable) -> None: while True: # load all messages that are queued - has_message, _, _ = zmq.select([self.socket], [], [], 0.1) + has_message, _, _ = zmq.select([self.socket], [], [], 0.01) if not has_message: break diff --git a/frigate/comms/inter_process.py b/frigate/comms/inter_process.py index 850e2435c..36a6857a4 100644 --- a/frigate/comms/inter_process.py +++ b/frigate/comms/inter_process.py @@ -7,7 +7,7 @@ from typing import Callable import zmq -from frigate.comms.dispatcher import Communicator +from frigate.comms.base_communicator import Communicator SOCKET_REP_REQ = "ipc:///tmp/cache/comms" diff --git a/frigate/comms/mqtt.py b/frigate/comms/mqtt.py index 5a85a710b..57460b29b 100644 --- a/frigate/comms/mqtt.py +++ b/frigate/comms/mqtt.py @@ -5,7 +5,7 @@ from typing import Any, Callable import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion -from frigate.comms.dispatcher import Communicator +from frigate.comms.base_communicator import Communicator from frigate.config import FrigateConfig logger = logging.getLogger(__name__) @@ -31,7 +31,10 @@ class MqttClient(Communicator): # type: ignore[misc] return self.client.publish( - f"{self.mqtt_config.topic_prefix}/{topic}", payload, retain=retain + f"{self.mqtt_config.topic_prefix}/{topic}", + payload, + qos=self.config.mqtt.qos, + retain=retain, ) def stop(self) -> None: @@ -151,7 +154,7 @@ class MqttClient(Communicator): # type: ignore[misc] self.connected = True logger.debug("MQTT connected") - client.subscribe(f"{self.mqtt_config.topic_prefix}/#") + client.subscribe(f"{self.mqtt_config.topic_prefix}/#", qos=self.config.mqtt.qos) self._set_initial_topics() def _on_disconnect( diff --git a/frigate/comms/webpush.py b/frigate/comms/webpush.py index 602f8d11e..b55b7e82c 100644 --- a/frigate/comms/webpush.py +++ b/frigate/comms/webpush.py @@ -4,13 +4,17 @@ import datetime import json import logging import os +import queue +import threading +from dataclasses import dataclass +from multiprocessing.synchronize import Event as MpEvent from typing import Any, Callable from py_vapid import Vapid01 from pywebpush import WebPusher +from frigate.comms.base_communicator import Communicator from frigate.comms.config_updater import ConfigSubscriber -from frigate.comms.dispatcher import Communicator from frigate.config import FrigateConfig from frigate.const import CONFIG_DIR from frigate.models import User @@ -18,15 +22,36 @@ from frigate.models import User logger = logging.getLogger(__name__) +@dataclass +class PushNotification: + user: str + payload: dict[str, Any] + title: str + message: str + direct_url: str = "" + image: str = "" + notification_type: str = "alert" + ttl: int = 0 + + class WebPushClient(Communicator): # type: ignore[misc] """Frigate wrapper for webpush client.""" - def __init__(self, config: FrigateConfig) -> None: + def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None: self.config = config + self.stop_event = stop_event self.claim_headers: dict[str, dict[str, str]] = {} self.refresh: int = 0 self.web_pushers: dict[str, list[WebPusher]] = {} self.expired_subs: dict[str, list[str]] = {} + self.suspended_cameras: dict[str, int] = { + c.name: 0 for c in self.config.cameras.values() + } + self.notification_queue: queue.Queue[PushNotification] = queue.Queue() + self.notification_thread = threading.Thread( + target=self._process_notifications, daemon=True + ) + self.notification_thread.start() if not self.config.notifications.email: logger.warning("Email must be provided for push notifications to be sent.") @@ -103,30 +128,144 @@ class WebPushClient(Communicator): # type: ignore[misc] self.expired_subs = {} + def suspend_notifications(self, camera: str, minutes: int) -> None: + """Suspend notifications for a specific camera.""" + suspend_until = int( + (datetime.datetime.now() + datetime.timedelta(minutes=minutes)).timestamp() + ) + self.suspended_cameras[camera] = suspend_until + logger.info( + f"Notifications for {camera} suspended until {datetime.datetime.fromtimestamp(suspend_until).strftime('%Y-%m-%d %H:%M:%S')}" + ) + + def unsuspend_notifications(self, camera: str) -> None: + """Unsuspend notifications for a specific camera.""" + self.suspended_cameras[camera] = 0 + logger.info(f"Notifications for {camera} unsuspended") + + def is_camera_suspended(self, camera: str) -> bool: + return datetime.datetime.now().timestamp() <= self.suspended_cameras[camera] + def publish(self, topic: str, payload: Any, retain: bool = False) -> None: """Wrapper for publishing when client is in valid state.""" # check for updated notification config _, updated_notification_config = self.config_subscriber.check_for_update() if updated_notification_config: - self.config.notifications = updated_notification_config + for key, value in updated_notification_config.items(): + if key == "_global_notifications": + self.config.notifications = value - if not self.config.notifications.enabled: - return + elif key in self.config.cameras: + self.config.cameras[key].notifications = value if topic == "reviews": - self.send_alert(json.loads(payload)) + decoded = json.loads(payload) + camera = decoded["before"]["camera"] + if not self.config.cameras[camera].notifications.enabled: + return + if self.is_camera_suspended(camera): + logger.debug(f"Notifications for {camera} are currently suspended.") + return + self.send_alert(decoded) + elif topic == "notification_test": + if not self.config.notifications.enabled: + return + self.send_notification_test() - def send_alert(self, payload: dict[str, any]) -> None: + def send_push_notification( + self, + user: str, + payload: dict[str, Any], + title: str, + message: str, + direct_url: str = "", + image: str = "", + notification_type: str = "alert", + ttl: int = 0, + ) -> None: + notification = PushNotification( + user=user, + payload=payload, + title=title, + message=message, + direct_url=direct_url, + image=image, + notification_type=notification_type, + ttl=ttl, + ) + self.notification_queue.put(notification) + + def _process_notifications(self) -> None: + while not self.stop_event.is_set(): + try: + notification = self.notification_queue.get(timeout=1.0) + self.check_registrations() + + for pusher in self.web_pushers[notification.user]: + endpoint = pusher.subscription_info["endpoint"] + headers = self.claim_headers[ + endpoint[: endpoint.index("/", 10)] + ].copy() + headers["urgency"] = "high" + + resp = pusher.send( + headers=headers, + ttl=notification.ttl, + data=json.dumps( + { + "title": notification.title, + "message": notification.message, + "direct_url": notification.direct_url, + "image": notification.image, + "id": notification.payload.get("after", {}).get( + "id", "" + ), + "type": notification.notification_type, + } + ), + timeout=10, + ) + + if resp.status_code in (404, 410): + self.expired_subs.setdefault(notification.user, []).append( + endpoint + ) + elif resp.status_code != 201: + logger.warning( + f"Failed to send notification to {notification.user} :: {resp.status_code}" + ) + + except queue.Empty: + continue + except Exception as e: + logger.error(f"Error processing notification: {str(e)}") + + def send_notification_test(self) -> None: if not self.config.notifications.email: return self.check_registrations() - # Only notify for alerts - if payload["after"]["severity"] != "alert": + for user in self.web_pushers: + self.send_push_notification( + user=user, + payload={}, + title="Test Notification", + message="This is a test notification from Frigate.", + direct_url="/", + notification_type="test", + ) + + def send_alert(self, payload: dict[str, Any]) -> None: + if ( + not self.config.notifications.email + or payload["after"]["severity"] != "alert" + ): return + self.check_registrations() + state = payload["type"] # Don't notify if message is an update and important fields don't have an update @@ -151,53 +290,25 @@ class WebPushClient(Communicator): # type: ignore[misc] camera: str = payload["after"]["camera"] title = f"{', '.join(sorted_objects).replace('_', ' ').title()}{' was' if state == 'end' else ''} detected in {', '.join(payload['after']['data']['zones']).replace('_', ' ').title()}" message = f"Detected on {camera.replace('_', ' ').title()}" - image = f'{payload["after"]["thumb_path"].replace("/media/frigate", "")}' + image = f"{payload['after']['thumb_path'].replace('/media/frigate', '')}" # if event is ongoing open to live view otherwise open to recordings view direct_url = f"/review?id={reviewId}" if state == "end" else f"/#{camera}" + ttl = 3600 if state == "end" else 0 - for user, pushers in self.web_pushers.items(): - for pusher in pushers: - endpoint = pusher.subscription_info["endpoint"] - - # set headers for notification behavior - headers = self.claim_headers[ - endpoint[0 : endpoint.index("/", 10)] - ].copy() - headers["urgency"] = "high" - ttl = 3600 if state == "end" else 0 - - # send message - resp = pusher.send( - headers=headers, - ttl=ttl, - data=json.dumps( - { - "title": title, - "message": message, - "direct_url": direct_url, - "image": image, - "id": reviewId, - "type": "alert", - } - ), - ) - - if resp.status_code == 201: - pass - elif resp.status_code == 404 or resp.status_code == 410: - # subscription is not found or has been unsubscribed - if not self.expired_subs.get(user): - self.expired_subs[user] = [] - - self.expired_subs[user].append(pusher.subscription_info["endpoint"]) - # the subscription no longer exists and should be removed - else: - logger.warning( - f"Failed to send notification to {user} :: {resp.headers}" - ) + for user in self.web_pushers: + self.send_push_notification( + user=user, + payload=payload, + title=title, + message=message, + direct_url=direct_url, + image=image, + ttl=ttl, + ) self.cleanup_registrations() def stop(self) -> None: - pass + logger.info("Closing notification queue") + self.notification_thread.join() diff --git a/frigate/comms/ws.py b/frigate/comms/ws.py index fccd8db5c..1eed290f7 100644 --- a/frigate/comms/ws.py +++ b/frigate/comms/ws.py @@ -15,7 +15,7 @@ from ws4py.server.wsgirefserver import ( from ws4py.server.wsgiutils import WebSocketWSGIApplication from ws4py.websocket import WebSocket as WebSocket_ -from frigate.comms.dispatcher import Communicator +from frigate.comms.base_communicator import Communicator from frigate.config import FrigateConfig logger = logging.getLogger(__name__) diff --git a/frigate/config/__init__.py b/frigate/config/__init__.py index 1af2f08fe..c6ff535b0 100644 --- a/frigate/config/__init__.py +++ b/frigate/config/__init__.py @@ -3,13 +3,12 @@ from frigate.detectors import DetectorConfig, ModelConfig # noqa: F401 from .auth import * # noqa: F403 from .camera import * # noqa: F403 from .camera_group import * # noqa: F403 +from .classification import * # noqa: F403 from .config import * # noqa: F403 from .database import * # noqa: F403 from .logger import * # noqa: F403 from .mqtt import * # noqa: F403 -from .notification import * # noqa: F403 from .proxy import * # noqa: F403 -from .semantic_search import * # noqa: F403 from .telemetry import * # noqa: F403 from .tls import * # noqa: F403 from .ui import * # noqa: F403 diff --git a/frigate/config/camera/camera.py b/frigate/config/camera/camera.py index 37e5f408e..50f61f33c 100644 --- a/frigate/config/camera/camera.py +++ b/frigate/config/camera/camera.py @@ -25,6 +25,7 @@ from .genai import GenAICameraConfig from .live import CameraLiveConfig from .motion import MotionConfig from .mqtt import CameraMqttConfig +from .notification import NotificationConfig from .objects import ObjectConfig from .onvif import OnvifConfig from .record import RecordConfig @@ -85,6 +86,9 @@ class CameraConfig(FrigateBaseModel): mqtt: CameraMqttConfig = Field( default_factory=CameraMqttConfig, title="MQTT configuration." ) + notifications: NotificationConfig = Field( + default_factory=NotificationConfig, title="Notifications configuration." + ) onvif: OnvifConfig = Field( default_factory=OnvifConfig, title="Camera Onvif Configuration." ) @@ -167,7 +171,7 @@ class CameraConfig(FrigateBaseModel): record_args = get_ffmpeg_arg_list( parse_preset_output_record( self.ffmpeg.output_args.record, - self.ffmpeg.output_args._force_record_hvc1, + self.ffmpeg.apple_compatibility, ) or self.ffmpeg.output_args.record ) diff --git a/frigate/config/camera/ffmpeg.py b/frigate/config/camera/ffmpeg.py index 4750a950f..4ab93d7b9 100644 --- a/frigate/config/camera/ffmpeg.py +++ b/frigate/config/camera/ffmpeg.py @@ -2,7 +2,7 @@ import shutil from enum import Enum from typing import Union -from pydantic import Field, PrivateAttr, field_validator +from pydantic import Field, field_validator from frigate.const import DEFAULT_FFMPEG_VERSION, INCLUDED_FFMPEG_VERSIONS @@ -42,7 +42,6 @@ class FfmpegOutputArgsConfig(FrigateBaseModel): default=RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT, title="Record role FFmpeg output arguments.", ) - _force_record_hvc1: bool = PrivateAttr(default=False) class FfmpegConfig(FrigateBaseModel): @@ -64,6 +63,10 @@ class FfmpegConfig(FrigateBaseModel): default=10.0, title="Time in seconds to wait before FFmpeg retries connecting to the camera.", ) + apple_compatibility: bool = Field( + default=False, + title="Set tag on HEVC (H.265) recording stream to improve compatibility with Apple players.", + ) @property def ffmpeg_path(self) -> str: diff --git a/frigate/config/camera/live.py b/frigate/config/camera/live.py index 9f15f2645..13ae2d04f 100644 --- a/frigate/config/camera/live.py +++ b/frigate/config/camera/live.py @@ -1,3 +1,5 @@ +from typing import Dict + from pydantic import Field from ..base import FrigateBaseModel @@ -6,6 +8,9 @@ __all__ = ["CameraLiveConfig"] class CameraLiveConfig(FrigateBaseModel): - stream_name: str = Field(default="", title="Name of restream to use as live view.") + streams: Dict[str, str] = Field( + default_factory=list, + title="Friendly names and restream names to use for live view.", + ) height: int = Field(default=720, title="Live camera view height") quality: int = Field(default=8, ge=1, le=31, title="Live camera view quality") diff --git a/frigate/config/notification.py b/frigate/config/camera/notification.py similarity index 92% rename from frigate/config/notification.py rename to frigate/config/camera/notification.py index 0ffebff3c..79355b8ae 100644 --- a/frigate/config/notification.py +++ b/frigate/config/camera/notification.py @@ -2,7 +2,7 @@ from typing import Optional from pydantic import Field -from .base import FrigateBaseModel +from ..base import FrigateBaseModel __all__ = ["NotificationConfig"] diff --git a/frigate/config/camera/objects.py b/frigate/config/camera/objects.py index 22cd92f1c..0d559b6ce 100644 --- a/frigate/config/camera/objects.py +++ b/frigate/config/camera/objects.py @@ -1,6 +1,6 @@ from typing import Any, Optional, Union -from pydantic import Field, field_serializer +from pydantic import Field, PrivateAttr, field_serializer from ..base import FrigateBaseModel @@ -11,11 +11,13 @@ DEFAULT_TRACKED_OBJECTS = ["person"] class FilterConfig(FrigateBaseModel): - min_area: int = Field( - default=0, title="Minimum area of bounding box for object to be counted." + min_area: Union[int, float] = Field( + default=0, + title="Minimum area of bounding box for object to be counted. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", ) - max_area: int = Field( - default=24000000, title="Maximum area of bounding box for object to be counted." + max_area: Union[int, float] = Field( + default=24000000, + title="Maximum area of bounding box for object to be counted. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", ) min_ratio: float = Field( default=0, @@ -53,3 +55,20 @@ class ObjectConfig(FrigateBaseModel): default_factory=dict, title="Object filters." ) mask: Union[str, list[str]] = Field(default="", title="Object mask.") + _all_objects: list[str] = PrivateAttr() + + @property + def all_objects(self) -> list[str]: + return self._all_objects + + def parse_all_objects(self, cameras): + if "_all_objects" in self: + return + + # get list of unique enabled labels for tracking + enabled_labels = set(self.track) + + for camera in cameras.values(): + enabled_labels.update(camera.objects.track) + + self._all_objects = list(enabled_labels) diff --git a/frigate/config/camera/onvif.py b/frigate/config/camera/onvif.py index 0c7985454..ff34e2a10 100644 --- a/frigate/config/camera/onvif.py +++ b/frigate/config/camera/onvif.py @@ -64,7 +64,9 @@ class PtzAutotrackConfig(FrigateBaseModel): raise ValueError("Invalid type for movement_weights") if len(weights) != 5: - raise ValueError("movement_weights must have exactly 5 floats") + raise ValueError( + "movement_weights must have exactly 5 floats, remove this line from your config and run autotracking calibration" + ) return weights diff --git a/frigate/config/camera/zone.py b/frigate/config/camera/zone.py index 65b34a049..3e69240d5 100644 --- a/frigate/config/camera/zone.py +++ b/frigate/config/camera/zone.py @@ -1,13 +1,16 @@ # this uses the base model because the color is an extra attribute +import logging from typing import Optional, Union import numpy as np -from pydantic import BaseModel, Field, PrivateAttr, field_validator +from pydantic import BaseModel, Field, PrivateAttr, field_validator, model_validator from .objects import FilterConfig __all__ = ["ZoneConfig"] +logger = logging.getLogger(__name__) + class ZoneConfig(BaseModel): filters: dict[str, FilterConfig] = Field( @@ -16,6 +19,10 @@ class ZoneConfig(BaseModel): coordinates: Union[str, list[str]] = Field( title="Coordinates polygon for the defined zone." ) + distances: Optional[Union[str, list[str]]] = Field( + default_factory=list, + title="Real-world distances for the sides of quadrilateral for the defined zone.", + ) inertia: int = Field( default=3, title="Number of consecutive frames required for object to be considered present in the zone.", @@ -26,6 +33,11 @@ class ZoneConfig(BaseModel): ge=0, title="Number of seconds that an object must loiter to be considered in the zone.", ) + speed_threshold: Optional[float] = Field( + default=None, + ge=0.1, + title="Minimum speed value for an object to be considered in the zone.", + ) objects: Union[str, list[str]] = Field( default_factory=list, title="List of objects that can trigger the zone.", @@ -49,6 +61,34 @@ class ZoneConfig(BaseModel): return v + @field_validator("distances", mode="before") + @classmethod + def validate_distances(cls, v): + if v is None: + return None + + if isinstance(v, str): + distances = list(map(str, map(float, v.split(",")))) + elif isinstance(v, list): + distances = [str(float(val)) for val in v] + else: + raise ValueError("Invalid type for distances") + + if len(distances) != 4: + raise ValueError("distances must have exactly 4 values") + + return distances + + @model_validator(mode="after") + def check_loitering_time_constraints(self): + if self.loitering_time > 0 and ( + self.speed_threshold is not None or len(self.distances) > 0 + ): + logger.warning( + "loitering_time should not be set on a zone if speed_threshold or distances is set." + ) + return self + def __init__(self, **config): super().__init__(**config) @@ -85,7 +125,7 @@ class ZoneConfig(BaseModel): if explicit: self.coordinates = ",".join( [ - f'{round(int(p.split(",")[0]) / frame_shape[1], 3)},{round(int(p.split(",")[1]) / frame_shape[0], 3)}' + f"{round(int(p.split(',')[0]) / frame_shape[1], 3)},{round(int(p.split(',')[1]) / frame_shape[0], 3)}" for p in coordinates ] ) diff --git a/frigate/config/classification.py b/frigate/config/classification.py new file mode 100644 index 000000000..4e806f9d9 --- /dev/null +++ b/frigate/config/classification.py @@ -0,0 +1,74 @@ +from typing import Dict, List, Optional + +from pydantic import Field + +from .base import FrigateBaseModel + +__all__ = [ + "FaceRecognitionConfig", + "SemanticSearchConfig", + "LicensePlateRecognitionConfig", +] + + +class BirdClassificationConfig(FrigateBaseModel): + enabled: bool = Field(default=False, title="Enable bird classification.") + threshold: float = Field( + default=0.9, + title="Minimum classification score required to be considered a match.", + gt=0.0, + le=1.0, + ) + + +class ClassificationConfig(FrigateBaseModel): + bird: BirdClassificationConfig = Field( + default_factory=BirdClassificationConfig, title="Bird classification config." + ) + + +class SemanticSearchConfig(FrigateBaseModel): + enabled: bool = Field(default=False, title="Enable semantic search.") + reindex: Optional[bool] = Field( + default=False, title="Reindex all detections on startup." + ) + model_size: str = Field( + default="small", title="The size of the embeddings model used." + ) + + +class FaceRecognitionConfig(FrigateBaseModel): + enabled: bool = Field(default=False, title="Enable face recognition.") + min_score: float = Field( + title="Minimum face distance score required to save the attempt.", + default=0.8, + gt=0.0, + le=1.0, + ) + threshold: float = Field( + default=0.9, + title="Minimum face distance score required to be considered a match.", + gt=0.0, + le=1.0, + ) + min_area: int = Field( + default=500, title="Min area of face box to consider running face recognition." + ) + save_attempts: bool = Field( + default=True, title="Save images of face detections for training." + ) + + +class LicensePlateRecognitionConfig(FrigateBaseModel): + enabled: bool = Field(default=False, title="Enable license plate recognition.") + threshold: float = Field( + default=0.9, + title="License plate confidence score required to be added to the object as a sub label.", + ) + min_area: int = Field( + default=500, + title="Min area of license plate to consider running license plate recognition.", + ) + known_plates: Optional[Dict[str, List[str]]] = Field( + default={}, title="Known plates to track." + ) diff --git a/frigate/config/config.py b/frigate/config/config.py index 43db89b4f..aea41a7bc 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -29,6 +29,7 @@ from frigate.util.builtin import ( ) from frigate.util.config import ( StreamInfoRetriever, + convert_area_to_pixels, find_config_file, get_relative_coordinates, migrate_frigate_config, @@ -45,19 +46,24 @@ from .camera.detect import DetectConfig from .camera.ffmpeg import FfmpegConfig from .camera.genai import GenAIConfig from .camera.motion import MotionConfig +from .camera.notification import NotificationConfig from .camera.objects import FilterConfig, ObjectConfig from .camera.record import RecordConfig, RetainModeEnum from .camera.review import ReviewConfig from .camera.snapshots import SnapshotsConfig from .camera.timestamp import TimestampStyleConfig from .camera_group import CameraGroupConfig +from .classification import ( + ClassificationConfig, + FaceRecognitionConfig, + LicensePlateRecognitionConfig, + SemanticSearchConfig, +) from .database import DatabaseConfig from .env import EnvVars from .logger import LoggerConfig from .mqtt import MqttConfig -from .notification import NotificationConfig from .proxy import ProxyConfig -from .semantic_search import SemanticSearchConfig from .telemetry import TelemetryConfig from .tls import TlsConfig from .ui import UIConfig @@ -143,6 +149,13 @@ class RuntimeFilterConfig(FilterConfig): if mask is not None: config["mask"] = create_mask(frame_shape, mask) + # Convert min_area and max_area to pixels if they're percentages + if "min_area" in config: + config["min_area"] = convert_area_to_pixels(config["min_area"], frame_shape) + + if "max_area" in config: + config["max_area"] = convert_area_to_pixels(config["max_area"], frame_shape) + super().__init__(**config) def dict(self, **kwargs): @@ -159,6 +172,16 @@ class RestreamConfig(BaseModel): model_config = ConfigDict(extra="allow") +def verify_semantic_search_dependent_configs(config: FrigateConfig) -> None: + """Verify that semantic search is enabled if required features are enabled.""" + if not config.semantic_search.enabled: + if config.genai.enabled: + raise ValueError("Genai requires semantic search to be enabled.") + + if config.face_recognition.enabled: + raise ValueError("Face recognition requires semantic to be enabled.") + + def verify_config_roles(camera_config: CameraConfig) -> None: """Verify that roles are setup in the config correctly.""" assigned_roles = list( @@ -176,17 +199,18 @@ def verify_config_roles(camera_config: CameraConfig) -> None: ) -def verify_valid_live_stream_name( +def verify_valid_live_stream_names( frigate_config: FrigateConfig, camera_config: CameraConfig ) -> ValueError | None: """Verify that a restream exists to use for live view.""" - if ( - camera_config.live.stream_name - not in frigate_config.go2rtc.model_dump().get("streams", {}).keys() - ): - return ValueError( - f"No restream with name {camera_config.live.stream_name} exists for camera {camera_config.name}." - ) + for _, stream_name in camera_config.live.streams.items(): + if ( + stream_name + not in frigate_config.go2rtc.model_dump().get("streams", {}).keys() + ): + return ValueError( + f"No restream with name {stream_name} exists for camera {camera_config.name}." + ) def verify_recording_retention(camera_config: CameraConfig) -> None: @@ -308,7 +332,7 @@ class FrigateConfig(FrigateBaseModel): ) mqtt: MqttConfig = Field(title="MQTT configuration.") notifications: NotificationConfig = Field( - default_factory=NotificationConfig, title="Notification configuration." + default_factory=NotificationConfig, title="Global notification configuration." ) proxy: ProxyConfig = Field( default_factory=ProxyConfig, title="Proxy configuration." @@ -317,9 +341,19 @@ class FrigateConfig(FrigateBaseModel): default_factory=TelemetryConfig, title="Telemetry configuration." ) tls: TlsConfig = Field(default_factory=TlsConfig, title="TLS configuration.") + classification: ClassificationConfig = Field( + default_factory=ClassificationConfig, title="Object classification config." + ) semantic_search: SemanticSearchConfig = Field( default_factory=SemanticSearchConfig, title="Semantic search configuration." ) + face_recognition: FaceRecognitionConfig = Field( + default_factory=FaceRecognitionConfig, title="Face recognition config." + ) + lpr: LicensePlateRecognitionConfig = Field( + default_factory=LicensePlateRecognitionConfig, + title="License Plate recognition config.", + ) ui: UIConfig = Field(default_factory=UIConfig, title="UI configuration.") # Detector config @@ -418,6 +452,7 @@ class FrigateConfig(FrigateBaseModel): "review": ..., "genai": ..., "motion": ..., + "notifications": ..., "detect": ..., "ffmpeg": ..., "timestamp_style": ..., @@ -437,13 +472,12 @@ class FrigateConfig(FrigateBaseModel): camera_config.ffmpeg.hwaccel_args = self.ffmpeg.hwaccel_args for input in camera_config.ffmpeg.inputs: - need_record_fourcc = False and "record" in input.roles need_detect_dimensions = "detect" in input.roles and ( camera_config.detect.height is None or camera_config.detect.width is None ) - if need_detect_dimensions or need_record_fourcc: + if need_detect_dimensions: stream_info = {"width": 0, "height": 0, "fourcc": None} try: stream_info = stream_info_retriever.get_stream_info( @@ -467,14 +501,6 @@ class FrigateConfig(FrigateBaseModel): else DEFAULT_DETECT_DIMENSIONS["height"] ) - if need_record_fourcc: - # Apple only supports HEVC if it is hvc1 (vs. hev1) - camera_config.ffmpeg.output_args._force_record_hvc1 = ( - stream_info["fourcc"] == "hevc" - if stream_info.get("hevc") - else False - ) - # Warn if detect fps > 10 if camera_config.detect.fps > 10: logger.warning( @@ -502,6 +528,9 @@ class FrigateConfig(FrigateBaseModel): # set config pre-value camera_config.audio.enabled_in_config = camera_config.audio.enabled camera_config.record.enabled_in_config = camera_config.record.enabled + camera_config.notifications.enabled_in_config = ( + camera_config.notifications.enabled + ) camera_config.onvif.autotracking.enabled_in_config = ( camera_config.onvif.autotracking.enabled ) @@ -562,15 +591,15 @@ class FrigateConfig(FrigateBaseModel): zone.generate_contour(camera_config.frame_shape) # Set live view stream if none is set - if not camera_config.live.stream_name: - camera_config.live.stream_name = name + if not camera_config.live.streams: + camera_config.live.streams = {name: name} # generate the ffmpeg commands camera_config.create_ffmpeg_cmds() self.cameras[name] = camera_config verify_config_roles(camera_config) - verify_valid_live_stream_name(self, camera_config) + verify_valid_live_stream_names(self, camera_config) verify_recording_retention(camera_config) verify_recording_segments_setup_with_reasonable_time(camera_config) verify_zone_objects_are_tracked(camera_config) @@ -578,13 +607,8 @@ class FrigateConfig(FrigateBaseModel): verify_autotrack_zones(camera_config) verify_motion_and_detect(camera_config) - # get list of unique enabled labels for tracking - enabled_labels = set(self.objects.track) - - for camera in self.cameras.values(): - enabled_labels.update(camera.objects.track) - - self.model.create_colormap(sorted(enabled_labels)) + self.objects.parse_all_objects(self.cameras) + self.model.create_colormap(sorted(self.objects.all_objects)) self.model.check_and_load_plus_model(self.plus_api) for key, detector in self.detectors.items(): @@ -617,6 +641,7 @@ class FrigateConfig(FrigateBaseModel): detector_config.model = model self.detectors[key] = detector_config + verify_semantic_search_dependent_configs(self) return self @field_validator("cameras") diff --git a/frigate/config/logger.py b/frigate/config/logger.py index 120642042..45b8a3abe 100644 --- a/frigate/config/logger.py +++ b/frigate/config/logger.py @@ -29,6 +29,7 @@ class LoggerConfig(FrigateBaseModel): logging.getLogger().setLevel(self.default.value.upper()) log_levels = { + "httpx": LogLevel.error, "werkzeug": LogLevel.error, "ws4py": LogLevel.error, **self.logs, diff --git a/frigate/config/mqtt.py b/frigate/config/mqtt.py index 1f3bb025d..cedd53734 100644 --- a/frigate/config/mqtt.py +++ b/frigate/config/mqtt.py @@ -30,6 +30,7 @@ class MqttConfig(FrigateBaseModel): ) tls_client_key: Optional[str] = Field(default=None, title="MQTT TLS Client Key") tls_insecure: Optional[bool] = Field(default=None, title="MQTT TLS Insecure") + qos: Optional[int] = Field(default=0, title="MQTT QoS") @model_validator(mode="after") def user_requires_pass(self, info: ValidationInfo) -> Self: diff --git a/frigate/config/semantic_search.py b/frigate/config/semantic_search.py deleted file mode 100644 index 2891050a1..000000000 --- a/frigate/config/semantic_search.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Optional - -from pydantic import Field - -from .base import FrigateBaseModel - -__all__ = ["SemanticSearchConfig"] - - -class SemanticSearchConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable semantic search.") - reindex: Optional[bool] = Field( - default=False, title="Reindex all detections on startup." - ) - model_size: str = Field( - default="small", title="The size of the embeddings model used." - ) diff --git a/frigate/config/telemetry.py b/frigate/config/telemetry.py index 0610c1f06..628d93427 100644 --- a/frigate/config/telemetry.py +++ b/frigate/config/telemetry.py @@ -11,6 +11,9 @@ class StatsConfig(FrigateBaseModel): network_bandwidth: bool = Field( default=False, title="Enable network bandwidth for ffmpeg processes." ) + sriov: bool = Field( + default=False, title="Treat device as SR-IOV to support GPU stats." + ) class TelemetryConfig(FrigateBaseModel): diff --git a/frigate/config/ui.py b/frigate/config/ui.py index a562edf61..2f66aeed3 100644 --- a/frigate/config/ui.py +++ b/frigate/config/ui.py @@ -5,7 +5,7 @@ from pydantic import Field from .base import FrigateBaseModel -__all__ = ["TimeFormatEnum", "DateTimeStyleEnum", "UIConfig"] +__all__ = ["TimeFormatEnum", "DateTimeStyleEnum", "UnitSystemEnum", "UIConfig"] class TimeFormatEnum(str, Enum): @@ -21,6 +21,11 @@ class DateTimeStyleEnum(str, Enum): short = "short" +class UnitSystemEnum(str, Enum): + imperial = "imperial" + metric = "metric" + + class UIConfig(FrigateBaseModel): timezone: Optional[str] = Field(default=None, title="Override UI timezone.") time_format: TimeFormatEnum = Field( @@ -35,3 +40,6 @@ class UIConfig(FrigateBaseModel): strftime_fmt: Optional[str] = Field( default=None, title="Override date and time format using strftime syntax." ) + unit_system: UnitSystemEnum = Field( + default=UnitSystemEnum.metric, title="The unit system to use for measurements." + ) diff --git a/frigate/const.py b/frigate/const.py index 5976f47b1..16df8b887 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -5,8 +5,9 @@ DEFAULT_DB_PATH = f"{CONFIG_DIR}/frigate.db" MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache" BASE_DIR = "/media/frigate" CLIPS_DIR = f"{BASE_DIR}/clips" -RECORD_DIR = f"{BASE_DIR}/recordings" EXPORT_DIR = f"{BASE_DIR}/exports" +FACE_DIR = f"{CLIPS_DIR}/faces" +RECORD_DIR = f"{BASE_DIR}/recordings" BIRDSEYE_PIPE = "/tmp/cache/birdseye" CACHE_DIR = "/tmp/cache" FRIGATE_LOCALHOST = "http://127.0.0.1:5000" @@ -64,6 +65,7 @@ INCLUDED_FFMPEG_VERSIONS = ["7.0", "5.0"] FFMPEG_HWACCEL_NVIDIA = "preset-nvidia" FFMPEG_HWACCEL_VAAPI = "preset-vaapi" FFMPEG_HWACCEL_VULKAN = "preset-vulkan" +FFMPEG_HVC1_ARGS = ["-tag:v", "hvc1"] # Regex constants @@ -102,6 +104,7 @@ UPDATE_CAMERA_ACTIVITY = "update_camera_activity" UPDATE_EVENT_DESCRIPTION = "update_event_description" UPDATE_MODEL_STATE = "update_model_state" UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress" +NOTIFICATION_TEST = "notification_test" # Stats Values diff --git a/frigate/data_processing/post/api.py b/frigate/data_processing/post/api.py new file mode 100644 index 000000000..5c88221c2 --- /dev/null +++ b/frigate/data_processing/post/api.py @@ -0,0 +1,43 @@ +"""Local or remote processors to handle post processing.""" + +import logging +from abc import ABC, abstractmethod + +from frigate.config import FrigateConfig + +from ..types import DataProcessorMetrics, PostProcessDataEnum + +logger = logging.getLogger(__name__) + + +class PostProcessorApi(ABC): + @abstractmethod + def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics) -> None: + self.config = config + self.metrics = metrics + pass + + @abstractmethod + def process_data( + self, data: dict[str, any], data_type: PostProcessDataEnum + ) -> None: + """Processes the data of data type. + Args: + data (dict): containing data about the input. + data_type (enum): Describing the data that is being processed. + + Returns: + None. + """ + pass + + @abstractmethod + def handle_request(self, request_data: dict[str, any]) -> dict[str, any] | None: + """Handle metadata requests. + Args: + request_data (dict): containing data about requested change to process. + + Returns: + None if request was not handled, otherwise return response. + """ + pass diff --git a/frigate/data_processing/real_time/api.py b/frigate/data_processing/real_time/api.py new file mode 100644 index 000000000..205431a36 --- /dev/null +++ b/frigate/data_processing/real_time/api.py @@ -0,0 +1,57 @@ +"""Local only processors for handling real time object processing.""" + +import logging +from abc import ABC, abstractmethod + +import numpy as np + +from frigate.config import FrigateConfig + +from ..types import DataProcessorMetrics + +logger = logging.getLogger(__name__) + + +class RealTimeProcessorApi(ABC): + @abstractmethod + def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics) -> None: + self.config = config + self.metrics = metrics + pass + + @abstractmethod + def process_frame(self, obj_data: dict[str, any], frame: np.ndarray) -> None: + """Processes the frame with object data. + Args: + obj_data (dict): containing data about focused object in frame. + frame (ndarray): full yuv frame. + + Returns: + None. + """ + pass + + @abstractmethod + def handle_request( + self, topic: str, request_data: dict[str, any] + ) -> dict[str, any] | None: + """Handle metadata requests. + Args: + topic (str): topic that dictates what work is requested. + request_data (dict): containing data about requested change to process. + + Returns: + None if request was not handled, otherwise return response. + """ + pass + + @abstractmethod + def expire_object(self, object_id: str) -> None: + """Handle objects that are no longer detected. + Args: + object_id (str): id of object that is no longer detected. + + Returns: + None. + """ + pass diff --git a/frigate/data_processing/real_time/bird_processor.py b/frigate/data_processing/real_time/bird_processor.py new file mode 100644 index 000000000..1199f6124 --- /dev/null +++ b/frigate/data_processing/real_time/bird_processor.py @@ -0,0 +1,154 @@ +"""Handle processing images to classify birds.""" + +import logging +import os + +import cv2 +import numpy as np +import requests + +from frigate.config import FrigateConfig +from frigate.const import FRIGATE_LOCALHOST, MODEL_CACHE_DIR +from frigate.util.object import calculate_region + +from ..types import DataProcessorMetrics +from .api import RealTimeProcessorApi + +try: + from tflite_runtime.interpreter import Interpreter +except ModuleNotFoundError: + from tensorflow.lite.python.interpreter import Interpreter + +logger = logging.getLogger(__name__) + + +class BirdProcessor(RealTimeProcessorApi): + def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics): + super().__init__(config, metrics) + self.interpreter: Interpreter = None + self.tensor_input_details: dict[str, any] = None + self.tensor_output_details: dict[str, any] = None + self.detected_birds: dict[str, float] = {} + self.labelmap: dict[int, str] = {} + + download_path = os.path.join(MODEL_CACHE_DIR, "bird") + self.model_files = { + "bird.tflite": "https://raw.githubusercontent.com/google-coral/test_data/master/mobilenet_v2_1.0_224_inat_bird_quant.tflite", + "birdmap.txt": "https://raw.githubusercontent.com/google-coral/test_data/master/inat_bird_labels.txt", + } + + if not all( + os.path.exists(os.path.join(download_path, n)) + for n in self.model_files.keys() + ): + # conditionally import ModelDownloader + from frigate.util.downloader import ModelDownloader + + self.downloader = ModelDownloader( + model_name="bird", + download_path=download_path, + file_names=self.model_files.keys(), + download_func=self.__download_models, + complete_func=self.__build_detector, + ) + self.downloader.ensure_model_files() + else: + self.__build_detector() + + def __download_models(self, path: str) -> None: + try: + file_name = os.path.basename(path) + + # conditionally import ModelDownloader + from frigate.util.downloader import ModelDownloader + + ModelDownloader.download_from_url(self.model_files[file_name], path) + except Exception as e: + logger.error(f"Failed to download {path}: {e}") + + def __build_detector(self) -> None: + self.interpreter = Interpreter( + model_path=os.path.join(MODEL_CACHE_DIR, "bird/bird.tflite"), + num_threads=2, + ) + self.interpreter.allocate_tensors() + self.tensor_input_details = self.interpreter.get_input_details() + self.tensor_output_details = self.interpreter.get_output_details() + + i = 0 + + with open(os.path.join(MODEL_CACHE_DIR, "bird/birdmap.txt")) as f: + line = f.readline() + while line: + start = line.find("(") + end = line.find(")") + self.labelmap[i] = line[start + 1 : end] + i += 1 + line = f.readline() + + def process_frame(self, obj_data, frame): + if obj_data["label"] != "bird": + return + + x, y, x2, y2 = calculate_region( + frame.shape, + obj_data["box"][0], + obj_data["box"][1], + obj_data["box"][2], + obj_data["box"][3], + 224, + 1.0, + ) + + rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420) + input = rgb[ + y:y2, + x:x2, + ] + + cv2.imwrite("/media/frigate/test_class.png", input) + + input = np.expand_dims(input, axis=0) + + self.interpreter.set_tensor(self.tensor_input_details[0]["index"], input) + self.interpreter.invoke() + res: np.ndarray = self.interpreter.get_tensor( + self.tensor_output_details[0]["index"] + )[0] + probs = res / res.sum(axis=0) + best_id = np.argmax(probs) + + if best_id == 964: + logger.debug("No bird classification was detected.") + return + + score = round(probs[best_id], 2) + + if score < self.config.classification.bird.threshold: + logger.debug(f"Score {score} is not above required threshold") + return + + previous_score = self.detected_birds.get(obj_data["id"], 0.0) + + if score <= previous_score: + logger.debug(f"Score {score} is worse than previous score {previous_score}") + return + + resp = requests.post( + f"{FRIGATE_LOCALHOST}/api/events/{obj_data['id']}/sub_label", + json={ + "camera": obj_data.get("camera"), + "subLabel": self.labelmap[best_id], + "subLabelScore": score, + }, + ) + + if resp.status_code == 200: + self.detected_birds[obj_data["id"]] = score + + def handle_request(self, topic, request_data): + return None + + def expire_object(self, object_id): + if object_id in self.detected_birds: + self.detected_birds.pop(object_id) diff --git a/frigate/data_processing/real_time/face_processor.py b/frigate/data_processing/real_time/face_processor.py new file mode 100644 index 000000000..086c59658 --- /dev/null +++ b/frigate/data_processing/real_time/face_processor.py @@ -0,0 +1,442 @@ +"""Handle processing images for face detection and recognition.""" + +import base64 +import datetime +import logging +import os +import random +import shutil +import string +from typing import Optional + +import cv2 +import numpy as np +import requests + +from frigate.comms.embeddings_updater import EmbeddingsRequestEnum +from frigate.config import FrigateConfig +from frigate.const import FACE_DIR, FRIGATE_LOCALHOST, MODEL_CACHE_DIR +from frigate.util.image import area + +from ..types import DataProcessorMetrics +from .api import RealTimeProcessorApi + +logger = logging.getLogger(__name__) + + +MIN_MATCHING_FACES = 2 + + +class FaceProcessor(RealTimeProcessorApi): + def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics): + super().__init__(config, metrics) + self.face_config = config.face_recognition + self.face_detector: cv2.FaceDetectorYN = None + self.landmark_detector: cv2.face.FacemarkLBF = None + self.recognizer: cv2.face.LBPHFaceRecognizer = None + self.requires_face_detection = "face" not in self.config.objects.all_objects + self.detected_faces: dict[str, float] = {} + + download_path = os.path.join(MODEL_CACHE_DIR, "facedet") + self.model_files = { + "facedet.onnx": "https://github.com/NickM-27/facenet-onnx/releases/download/v1.0/facedet.onnx", + "landmarkdet.yaml": "https://github.com/NickM-27/facenet-onnx/releases/download/v1.0/landmarkdet.yaml", + } + + if not all( + os.path.exists(os.path.join(download_path, n)) + for n in self.model_files.keys() + ): + # conditionally import ModelDownloader + from frigate.util.downloader import ModelDownloader + + self.downloader = ModelDownloader( + model_name="facedet", + download_path=download_path, + file_names=self.model_files.keys(), + download_func=self.__download_models, + complete_func=self.__build_detector, + ) + self.downloader.ensure_model_files() + else: + self.__build_detector() + + self.label_map: dict[int, str] = {} + self.__build_classifier() + + def __download_models(self, path: str) -> None: + try: + file_name = os.path.basename(path) + # conditionally import ModelDownloader + from frigate.util.downloader import ModelDownloader + + ModelDownloader.download_from_url(self.model_files[file_name], path) + except Exception as e: + logger.error(f"Failed to download {path}: {e}") + + def __build_detector(self) -> None: + self.face_detector = cv2.FaceDetectorYN.create( + "/config/model_cache/facedet/facedet.onnx", + config="", + input_size=(320, 320), + score_threshold=0.8, + nms_threshold=0.3, + ) + self.landmark_detector = cv2.face.createFacemarkLBF() + self.landmark_detector.loadModel("/config/model_cache/facedet/landmarkdet.yaml") + + def __build_classifier(self) -> None: + if not self.landmark_detector: + return None + + labels = [] + faces = [] + + dir = "/media/frigate/clips/faces" + for idx, name in enumerate(os.listdir(dir)): + if name == "train": + continue + + face_folder = os.path.join(dir, name) + + if not os.path.isdir(face_folder): + continue + + self.label_map[idx] = name + for image in os.listdir(face_folder): + img = cv2.imread(os.path.join(face_folder, image)) + + if img is None: + continue + + img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + img = self.__align_face(img, img.shape[1], img.shape[0]) + faces.append(img) + labels.append(idx) + + if not faces: + return + + self.recognizer: cv2.face.LBPHFaceRecognizer = ( + cv2.face.LBPHFaceRecognizer_create( + radius=2, threshold=(1 - self.face_config.min_score) * 1000 + ) + ) + self.recognizer.train(faces, np.array(labels)) + + def __align_face( + self, + image: np.ndarray, + output_width: int, + output_height: int, + ) -> np.ndarray: + _, lands = self.landmark_detector.fit( + image, np.array([(0, 0, image.shape[1], image.shape[0])]) + ) + landmarks: np.ndarray = lands[0][0] + + # get landmarks for eyes + leftEyePts = landmarks[42:48] + rightEyePts = landmarks[36:42] + + # compute the center of mass for each eye + leftEyeCenter = leftEyePts.mean(axis=0).astype("int") + rightEyeCenter = rightEyePts.mean(axis=0).astype("int") + + # compute the angle between the eye centroids + dY = rightEyeCenter[1] - leftEyeCenter[1] + dX = rightEyeCenter[0] - leftEyeCenter[0] + angle = np.degrees(np.arctan2(dY, dX)) - 180 + + # compute the desired right eye x-coordinate based on the + # desired x-coordinate of the left eye + desiredRightEyeX = 1.0 - 0.35 + + # determine the scale of the new resulting image by taking + # the ratio of the distance between eyes in the *current* + # image to the ratio of distance between eyes in the + # *desired* image + dist = np.sqrt((dX**2) + (dY**2)) + desiredDist = desiredRightEyeX - 0.35 + desiredDist *= output_width + scale = desiredDist / dist + + # compute center (x, y)-coordinates (i.e., the median point) + # between the two eyes in the input image + # grab the rotation matrix for rotating and scaling the face + eyesCenter = ( + int((leftEyeCenter[0] + rightEyeCenter[0]) // 2), + int((leftEyeCenter[1] + rightEyeCenter[1]) // 2), + ) + M = cv2.getRotationMatrix2D(eyesCenter, angle, scale) + + # update the translation component of the matrix + tX = output_width * 0.5 + tY = output_height * 0.35 + M[0, 2] += tX - eyesCenter[0] + M[1, 2] += tY - eyesCenter[1] + + # apply the affine transformation + return cv2.warpAffine( + image, M, (output_width, output_height), flags=cv2.INTER_CUBIC + ) + + def __clear_classifier(self) -> None: + self.face_recognizer = None + self.label_map = {} + + def __detect_face(self, input: np.ndarray) -> tuple[int, int, int, int]: + """Detect faces in input image.""" + if not self.face_detector: + return None + + self.face_detector.setInputSize((input.shape[1], input.shape[0])) + faces = self.face_detector.detect(input) + + if faces is None or faces[1] is None: + return None + + face = None + + for _, potential_face in enumerate(faces[1]): + raw_bbox = potential_face[0:4].astype(np.uint16) + x: int = max(raw_bbox[0], 0) + y: int = max(raw_bbox[1], 0) + w: int = raw_bbox[2] + h: int = raw_bbox[3] + bbox = (x, y, x + w, y + h) + + if face is None or area(bbox) > area(face): + face = bbox + + return face + + def __classify_face(self, face_image: np.ndarray) -> tuple[str, float] | None: + if not self.landmark_detector: + return None + + if not self.label_map or not self.recognizer: + self.__build_classifier() + + if not self.recognizer: + return None + + img = cv2.cvtColor(face_image, cv2.COLOR_BGR2GRAY) + img = self.__align_face(img, img.shape[1], img.shape[0]) + index, distance = self.recognizer.predict(img) + + if index == -1: + return None + + score = 1.0 - (distance / 1000) + return self.label_map[index], round(score, 2) + + def __update_metrics(self, duration: float) -> None: + self.metrics.face_rec_fps.value = ( + self.metrics.face_rec_fps.value * 9 + duration + ) / 10 + + def process_frame(self, obj_data: dict[str, any], frame: np.ndarray): + """Look for faces in image.""" + start = datetime.datetime.now().timestamp() + id = obj_data["id"] + + # don't run for non person objects + if obj_data.get("label") != "person": + logger.debug("Not a processing face for non person object.") + return + + # don't overwrite sub label for objects that have a sub label + # that is not a face + if obj_data.get("sub_label") and id not in self.detected_faces: + logger.debug( + f"Not processing face due to existing sub label: {obj_data.get('sub_label')}." + ) + return + + face: Optional[dict[str, any]] = None + + if self.requires_face_detection: + logger.debug("Running manual face detection.") + person_box = obj_data.get("box") + + if not person_box: + return + + rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420) + left, top, right, bottom = person_box + person = rgb[top:bottom, left:right] + face_box = self.__detect_face(person) + + if not face_box: + logger.debug("Detected no faces for person object.") + return + + face_frame = person[ + max(0, face_box[1]) : min(frame.shape[0], face_box[3]), + max(0, face_box[0]) : min(frame.shape[1], face_box[2]), + ] + face_frame = cv2.cvtColor(face_frame, cv2.COLOR_RGB2BGR) + else: + # don't run for object without attributes + if not obj_data.get("current_attributes"): + logger.debug("No attributes to parse.") + return + + attributes: list[dict[str, any]] = obj_data.get("current_attributes", []) + for attr in attributes: + if attr.get("label") != "face": + continue + + if face is None or attr.get("score", 0.0) > face.get("score", 0.0): + face = attr + + # no faces detected in this frame + if not face: + return + + face_box = face.get("box") + + # check that face is valid + if not face_box or area(face_box) < self.config.face_recognition.min_area: + logger.debug(f"Invalid face box {face}") + return + + face_frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) + + face_frame = face_frame[ + max(0, face_box[1]) : min(frame.shape[0], face_box[3]), + max(0, face_box[0]) : min(frame.shape[1], face_box[2]), + ] + + res = self.__classify_face(face_frame) + + if not res: + return + + sub_label, score = res + + # calculate the overall face score as the probability * area of face + # this will help to reduce false positives from small side-angle faces + # if a large front-on face image may have scored slightly lower but + # is more likely to be accurate due to the larger face area + face_score = round(score * face_frame.shape[0] * face_frame.shape[1], 2) + + logger.debug( + f"Detected best face for person as: {sub_label} with probability {score} and overall face score {face_score}" + ) + + if self.config.face_recognition.save_attempts: + # write face to library + folder = os.path.join(FACE_DIR, "train") + file = os.path.join(folder, f"{id}-{sub_label}-{score}-{face_score}.webp") + os.makedirs(folder, exist_ok=True) + cv2.imwrite(file, face_frame) + + if score < self.config.face_recognition.threshold: + logger.debug( + f"Recognized face distance {score} is less than threshold {self.config.face_recognition.threshold}" + ) + self.__update_metrics(datetime.datetime.now().timestamp() - start) + return + + if id in self.detected_faces and face_score <= self.detected_faces[id]: + logger.debug( + f"Recognized face distance {score} and overall score {face_score} is less than previous overall face score ({self.detected_faces.get(id)})." + ) + self.__update_metrics(datetime.datetime.now().timestamp() - start) + return + + resp = requests.post( + f"{FRIGATE_LOCALHOST}/api/events/{id}/sub_label", + json={ + "camera": obj_data.get("camera"), + "subLabel": sub_label, + "subLabelScore": score, + }, + ) + + if resp.status_code == 200: + self.detected_faces[id] = face_score + + self.__update_metrics(datetime.datetime.now().timestamp() - start) + + def handle_request(self, topic, request_data) -> dict[str, any] | None: + if topic == EmbeddingsRequestEnum.clear_face_classifier.value: + self.__clear_classifier() + elif topic == EmbeddingsRequestEnum.register_face.value: + rand_id = "".join( + random.choices(string.ascii_lowercase + string.digits, k=6) + ) + label = request_data["face_name"] + id = f"{label}-{rand_id}" + + if request_data.get("cropped"): + thumbnail = request_data["image"] + else: + img = cv2.imdecode( + np.frombuffer( + base64.b64decode(request_data["image"]), dtype=np.uint8 + ), + cv2.IMREAD_COLOR, + ) + face_box = self.__detect_face(img) + + if not face_box: + return { + "message": "No face was detected.", + "success": False, + } + + face = img[face_box[1] : face_box[3], face_box[0] : face_box[2]] + _, thumbnail = cv2.imencode( + ".webp", face, [int(cv2.IMWRITE_WEBP_QUALITY), 100] + ) + + # write face to library + folder = os.path.join(FACE_DIR, label) + file = os.path.join(folder, f"{id}.webp") + os.makedirs(folder, exist_ok=True) + + # save face image + with open(file, "wb") as output: + output.write(thumbnail.tobytes()) + + self.__clear_classifier() + return { + "message": "Successfully registered face.", + "success": True, + } + elif topic == EmbeddingsRequestEnum.reprocess_face.value: + current_file: str = request_data["image_file"] + id = current_file[0 : current_file.index("-", current_file.index("-") + 1)] + face_score = current_file[current_file.rfind("-") : current_file.rfind(".")] + img = None + + if current_file: + img = cv2.imread(current_file) + + if img is None: + return { + "message": "Invalid image file.", + "success": False, + } + + res = self.__classify_face(img) + + if not res: + return + + sub_label, score = res + + if self.config.face_recognition.save_attempts: + # write face to library + folder = os.path.join(FACE_DIR, "train") + new_file = os.path.join( + folder, f"{id}-{sub_label}-{score}-{face_score}.webp" + ) + shutil.move(current_file, new_file) + + def expire_object(self, object_id: str): + if object_id in self.detected_faces: + self.detected_faces.pop(object_id) diff --git a/frigate/data_processing/types.py b/frigate/data_processing/types.py new file mode 100644 index 000000000..39f355667 --- /dev/null +++ b/frigate/data_processing/types.py @@ -0,0 +1,24 @@ +"""Embeddings types.""" + +import multiprocessing as mp +from enum import Enum +from multiprocessing.sharedctypes import Synchronized + + +class DataProcessorMetrics: + image_embeddings_fps: Synchronized + text_embeddings_sps: Synchronized + face_rec_fps: Synchronized + alpr_pps: Synchronized + + def __init__(self): + self.image_embeddings_fps = mp.Value("d", 0.01) + self.text_embeddings_sps = mp.Value("d", 0.01) + self.face_rec_fps = mp.Value("d", 0.01) + self.alpr_pps = mp.Value("d", 0.01) + + +class PostProcessDataEnum(str, Enum): + recording = "recording" + review = "review" + tracked_object = "tracked_object" diff --git a/frigate/detectors/detector_config.py b/frigate/detectors/detector_config.py index 452f1feed..c8aea0a1d 100644 --- a/frigate/detectors/detector_config.py +++ b/frigate/detectors/detector_config.py @@ -35,6 +35,7 @@ class InputDTypeEnum(str, Enum): class ModelTypeEnum(str, Enum): ssd = "ssd" yolox = "yolox" + yolov9 = "yolov9" yolonas = "yolonas" @@ -78,6 +79,10 @@ class ModelConfig(BaseModel): def colormap(self) -> Dict[int, Tuple[int, int, int]]: return self._colormap + @property + def non_logo_attributes(self) -> list[str]: + return ["face", "license_plate"] + @property def all_attributes(self) -> list[str]: return self._all_attributes @@ -107,7 +112,7 @@ class ModelConfig(BaseModel): self._all_attributes = list(unique_attributes) self._all_attribute_logos = list( - unique_attributes - set(["face", "license_plate"]) + unique_attributes - set(self.non_logo_attributes) ) def check_and_load_plus_model( diff --git a/frigate/detectors/plugins/onnx.py b/frigate/detectors/plugins/onnx.py index 7004f28fa..c8589145a 100644 --- a/frigate/detectors/plugins/onnx.py +++ b/frigate/detectors/plugins/onnx.py @@ -9,7 +9,7 @@ from frigate.detectors.detector_config import ( BaseDetectorConfig, ModelTypeEnum, ) -from frigate.util.model import get_ort_providers +from frigate.util.model import get_ort_providers, post_process_yolov9 logger = logging.getLogger(__name__) @@ -79,6 +79,9 @@ class ONNXDetector(DetectionApi): x_max / self.w, ] return detections + elif self.onnx_model_type == ModelTypeEnum.yolov9: + predictions: np.ndarray = tensor_output[0] + return post_process_yolov9(predictions, self.w, self.h) else: raise Exception( f"{self.onnx_model_type} is currently not supported for rocm. See the docs for more info on supported models." diff --git a/frigate/detectors/plugins/rknn.py b/frigate/detectors/plugins/rknn.py index df94d7b62..bfd7866e6 100644 --- a/frigate/detectors/plugins/rknn.py +++ b/frigate/detectors/plugins/rknn.py @@ -108,7 +108,7 @@ class Rknn(DetectionApi): model_props["model_type"] = model_type if model_matched: - model_props["filename"] = model_path + f"-{soc}-v2.0.0-1.rknn" + model_props["filename"] = model_path + f"-{soc}-v2.3.0-1.rknn" model_props["path"] = model_cache_dir + model_props["filename"] @@ -129,7 +129,7 @@ class Rknn(DetectionApi): os.mkdir(model_cache_dir) urllib.request.urlretrieve( - f"https://github.com/MarcA711/rknn-models/releases/download/v2.0.0/{filename}", + f"https://github.com/MarcA711/rknn-models/releases/download/v2.3.0/{filename}", model_cache_dir + filename, ) diff --git a/frigate/detectors/plugins/tensorrt.py b/frigate/detectors/plugins/tensorrt.py index 380fa9107..de5459c6d 100644 --- a/frigate/detectors/plugins/tensorrt.py +++ b/frigate/detectors/plugins/tensorrt.py @@ -219,19 +219,19 @@ class TensorRtDetector(DetectionApi): ] def __init__(self, detector_config: TensorRTDetectorConfig): - assert ( - TRT_SUPPORT - ), f"TensorRT libraries not found, {DETECTOR_KEY} detector not present" + assert TRT_SUPPORT, ( + f"TensorRT libraries not found, {DETECTOR_KEY} detector not present" + ) (cuda_err,) = cuda.cuInit(0) - assert ( - cuda_err == cuda.CUresult.CUDA_SUCCESS - ), f"Failed to initialize cuda {cuda_err}" + assert cuda_err == cuda.CUresult.CUDA_SUCCESS, ( + f"Failed to initialize cuda {cuda_err}" + ) err, dev_count = cuda.cuDeviceGetCount() logger.debug(f"Num Available Devices: {dev_count}") - assert ( - detector_config.device < dev_count - ), f"Invalid TensorRT Device Config. Device {detector_config.device} Invalid." + assert detector_config.device < dev_count, ( + f"Invalid TensorRT Device Config. Device {detector_config.device} Invalid." + ) err, self.cu_ctx = cuda.cuCtxCreate( cuda.CUctx_flags.CU_CTX_MAP_HOST, detector_config.device ) diff --git a/frigate/embeddings/__init__.py b/frigate/embeddings/__init__.py index 7f2e1a10c..185d5436b 100644 --- a/frigate/embeddings/__init__.py +++ b/frigate/embeddings/__init__.py @@ -1,5 +1,6 @@ """SQLite-vec embeddings database.""" +import base64 import json import logging import multiprocessing as mp @@ -13,7 +14,8 @@ from setproctitle import setproctitle from frigate.comms.embeddings_updater import EmbeddingsRequestEnum, EmbeddingsRequestor from frigate.config import FrigateConfig -from frigate.const import CONFIG_DIR +from frigate.const import CONFIG_DIR, FACE_DIR +from frigate.data_processing.types import DataProcessorMetrics from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.models import Event from frigate.util.builtin import serialize @@ -25,7 +27,7 @@ from .util import ZScoreNormalization logger = logging.getLogger(__name__) -def manage_embeddings(config: FrigateConfig) -> None: +def manage_embeddings(config: FrigateConfig, metrics: DataProcessorMetrics) -> None: # Only initialize embeddings if semantic search is enabled if not config.semantic_search.enabled: return @@ -59,6 +61,7 @@ def manage_embeddings(config: FrigateConfig) -> None: maintainer = EmbeddingMaintainer( db, config, + metrics, stop_event, ) maintainer.start() @@ -189,6 +192,43 @@ class EmbeddingsContext: return results + def register_face(self, face_name: str, image_data: bytes) -> dict[str, any]: + return self.requestor.send_data( + EmbeddingsRequestEnum.register_face.value, + { + "face_name": face_name, + "image": base64.b64encode(image_data).decode("ASCII"), + }, + ) + + def get_face_ids(self, name: str) -> list[str]: + sql_query = f""" + SELECT + id + FROM vec_descriptions + WHERE id LIKE '%{name}%' + """ + + return self.db.execute_sql(sql_query).fetchall() + + def reprocess_face(self, face_file: str) -> dict[str, any]: + return self.requestor.send_data( + EmbeddingsRequestEnum.reprocess_face.value, {"image_file": face_file} + ) + + def clear_face_classifier(self) -> None: + self.requestor.send_data( + EmbeddingsRequestEnum.clear_face_classifier.value, None + ) + + def delete_face_ids(self, face: str, ids: list[str]) -> None: + folder = os.path.join(FACE_DIR, face) + for id in ids: + file_path = os.path.join(folder, id) + + if os.path.isfile(file_path): + os.unlink(file_path) + def update_description(self, event_id: str, description: str) -> None: self.requestor.send_data( EmbeddingsRequestEnum.embed_description.value, diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py index d77a9eecf..852806a8d 100644 --- a/frigate/embeddings/embeddings.py +++ b/frigate/embeddings/embeddings.py @@ -1,6 +1,7 @@ """SQLite-vec embeddings database.""" import base64 +import datetime import logging import os import time @@ -9,12 +10,13 @@ from numpy import ndarray from playhouse.shortcuts import model_to_dict from frigate.comms.inter_process import InterProcessRequestor -from frigate.config.semantic_search import SemanticSearchConfig +from frigate.config import FrigateConfig from frigate.const import ( CONFIG_DIR, UPDATE_EMBEDDINGS_REINDEX_PROGRESS, UPDATE_MODEL_STATE, ) +from frigate.data_processing.types import DataProcessorMetrics from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.models import Event from frigate.types import ModelStatusTypesEnum @@ -60,10 +62,14 @@ class Embeddings: """SQLite-vec embeddings database.""" def __init__( - self, config: SemanticSearchConfig, db: SqliteVecQueueDatabase + self, + config: FrigateConfig, + db: SqliteVecQueueDatabase, + metrics: DataProcessorMetrics, ) -> None: self.config = config self.db = db + self.metrics = metrics self.requestor = InterProcessRequestor() # Create tables if they don't exist @@ -73,9 +79,13 @@ class Embeddings: "jinaai/jina-clip-v1-text_model_fp16.onnx", "jinaai/jina-clip-v1-tokenizer", "jinaai/jina-clip-v1-vision_model_fp16.onnx" - if config.model_size == "large" + if config.semantic_search.model_size == "large" else "jinaai/jina-clip-v1-vision_model_quantized.onnx", "jinaai/jina-clip-v1-preprocessor_config.json", + "facenet-facenet.onnx", + "paddleocr-onnx-detection.onnx", + "paddleocr-onnx-classification.onnx", + "paddleocr-onnx-recognition.onnx", ] for model in models: @@ -94,7 +104,7 @@ class Embeddings: download_urls={ "text_model_fp16.onnx": "https://huggingface.co/jinaai/jina-clip-v1/resolve/main/onnx/text_model_fp16.onnx", }, - model_size=config.model_size, + model_size=config.semantic_search.model_size, model_type=ModelTypeEnum.text, requestor=self.requestor, device="CPU", @@ -102,7 +112,7 @@ class Embeddings: model_file = ( "vision_model_fp16.onnx" - if self.config.model_size == "large" + if self.config.semantic_search.model_size == "large" else "vision_model_quantized.onnx" ) @@ -115,12 +125,53 @@ class Embeddings: model_name="jinaai/jina-clip-v1", model_file=model_file, download_urls=download_urls, - model_size=config.model_size, + model_size=config.semantic_search.model_size, model_type=ModelTypeEnum.vision, requestor=self.requestor, - device="GPU" if config.model_size == "large" else "CPU", + device="GPU" if config.semantic_search.model_size == "large" else "CPU", ) + self.lpr_detection_model = None + self.lpr_classification_model = None + self.lpr_recognition_model = None + + if self.config.lpr.enabled: + self.lpr_detection_model = GenericONNXEmbedding( + model_name="paddleocr-onnx", + model_file="detection.onnx", + download_urls={ + "detection.onnx": "https://github.com/hawkeye217/paddleocr-onnx/raw/refs/heads/master/models/detection.onnx" + }, + model_size="large", + model_type=ModelTypeEnum.lpr_detect, + requestor=self.requestor, + device="CPU", + ) + + self.lpr_classification_model = GenericONNXEmbedding( + model_name="paddleocr-onnx", + model_file="classification.onnx", + download_urls={ + "classification.onnx": "https://github.com/hawkeye217/paddleocr-onnx/raw/refs/heads/master/models/classification.onnx" + }, + model_size="large", + model_type=ModelTypeEnum.lpr_classify, + requestor=self.requestor, + device="CPU", + ) + + self.lpr_recognition_model = GenericONNXEmbedding( + model_name="paddleocr-onnx", + model_file="recognition.onnx", + download_urls={ + "recognition.onnx": "https://github.com/hawkeye217/paddleocr-onnx/raw/refs/heads/master/models/recognition.onnx" + }, + model_size="large", + model_type=ModelTypeEnum.lpr_recognize, + requestor=self.requestor, + device="CPU", + ) + def embed_thumbnail( self, event_id: str, thumbnail: bytes, upsert: bool = True ) -> ndarray: @@ -130,6 +181,7 @@ class Embeddings: @param: thumbnail bytes in jpg format @param: upsert If embedding should be upserted into vec DB """ + start = datetime.datetime.now().timestamp() # Convert thumbnail bytes to PIL Image embedding = self.vision_embedding([thumbnail])[0] @@ -142,6 +194,11 @@ class Embeddings: (event_id, serialize(embedding)), ) + duration = datetime.datetime.now().timestamp() - start + self.metrics.image_embeddings_fps.value = ( + self.metrics.image_embeddings_fps.value * 9 + duration + ) / 10 + return embedding def batch_embed_thumbnail( @@ -152,6 +209,7 @@ class Embeddings: @param: event_thumbs Map of Event IDs in DB to thumbnail bytes in jpg format @param: upsert If embedding should be upserted into vec DB """ + start = datetime.datetime.now().timestamp() ids = list(event_thumbs.keys()) embeddings = self.vision_embedding(list(event_thumbs.values())) @@ -170,11 +228,17 @@ class Embeddings: items, ) + duration = datetime.datetime.now().timestamp() - start + self.metrics.text_embeddings_sps.value = ( + self.metrics.text_embeddings_sps.value * 9 + (duration / len(ids)) + ) / 10 + return embeddings def embed_description( self, event_id: str, description: str, upsert: bool = True ) -> ndarray: + start = datetime.datetime.now().timestamp() embedding = self.text_embedding([description])[0] if upsert: @@ -186,11 +250,17 @@ class Embeddings: (event_id, serialize(embedding)), ) + duration = datetime.datetime.now().timestamp() - start + self.metrics.text_embeddings_sps.value = ( + self.metrics.text_embeddings_sps.value * 9 + duration + ) / 10 + return embedding def batch_embed_description( self, event_descriptions: dict[str, str], upsert: bool = True ) -> ndarray: + start = datetime.datetime.now().timestamp() # upsert embeddings one by one to avoid token limit embeddings = [] @@ -213,6 +283,11 @@ class Embeddings: items, ) + duration = datetime.datetime.now().timestamp() - start + self.metrics.text_embeddings_sps.value = ( + self.metrics.text_embeddings_sps.value * 9 + (duration / len(ids)) + ) / 10 + return embeddings def reindex(self) -> None: diff --git a/frigate/embeddings/functions/onnx.py b/frigate/embeddings/functions/onnx.py index 6ea495a30..67b2c44a2 100644 --- a/frigate/embeddings/functions/onnx.py +++ b/frigate/embeddings/functions/onnx.py @@ -31,11 +31,16 @@ warnings.filterwarnings( disable_progress_bar() logger = logging.getLogger(__name__) +FACE_EMBEDDING_SIZE = 160 + class ModelTypeEnum(str, Enum): face = "face" vision = "vision" text = "text" + lpr_detect = "lpr_detect" + lpr_classify = "lpr_classify" + lpr_recognize = "lpr_recognize" class GenericONNXEmbedding: @@ -47,7 +52,7 @@ class GenericONNXEmbedding: model_file: str, download_urls: Dict[str, str], model_size: str, - model_type: str, + model_type: ModelTypeEnum, requestor: InterProcessRequestor, tokenizer_file: Optional[str] = None, device: str = "AUTO", @@ -57,7 +62,7 @@ class GenericONNXEmbedding: self.tokenizer_file = tokenizer_file self.requestor = requestor self.download_urls = download_urls - self.model_type = model_type # 'text' or 'vision' + self.model_type = model_type self.model_size = model_size self.device = device self.download_path = os.path.join(MODEL_CACHE_DIR, self.model_name) @@ -87,12 +92,13 @@ class GenericONNXEmbedding: files_names, ModelStatusTypesEnum.downloaded, ) - self._load_model_and_tokenizer() + self._load_model_and_utils() logger.debug(f"models are already downloaded for {self.model_name}") def _download_model(self, path: str): try: file_name = os.path.basename(path) + if file_name in self.download_urls: ModelDownloader.download_from_url(self.download_urls[file_name], path) elif ( @@ -101,6 +107,7 @@ class GenericONNXEmbedding: ): if not os.path.exists(path + "/" + self.model_name): logger.info(f"Downloading {self.model_name} tokenizer") + tokenizer = AutoTokenizer.from_pretrained( self.model_name, trust_remote_code=True, @@ -125,14 +132,23 @@ class GenericONNXEmbedding: }, ) - def _load_model_and_tokenizer(self): + def _load_model_and_utils(self): if self.runner is None: if self.downloader: self.downloader.wait_for_download() if self.model_type == ModelTypeEnum.text: self.tokenizer = self._load_tokenizer() - else: + elif self.model_type == ModelTypeEnum.vision: self.feature_extractor = self._load_feature_extractor() + elif self.model_type == ModelTypeEnum.face: + self.feature_extractor = [] + elif self.model_type == ModelTypeEnum.lpr_detect: + self.feature_extractor = [] + elif self.model_type == ModelTypeEnum.lpr_classify: + self.feature_extractor = [] + elif self.model_type == ModelTypeEnum.lpr_recognize: + self.feature_extractor = [] + self.runner = ONNXModelRunner( os.path.join(self.download_path, self.model_file), self.device, @@ -172,23 +188,72 @@ class GenericONNXEmbedding: self.feature_extractor(images=image, return_tensors="np") for image in processed_images ] + elif self.model_type == ModelTypeEnum.face: + if isinstance(raw_inputs, list): + raise ValueError("Face embedding does not support batch inputs.") + + pil = self._process_image(raw_inputs) + + # handle images larger than input size + width, height = pil.size + if width != FACE_EMBEDDING_SIZE or height != FACE_EMBEDDING_SIZE: + if width > height: + new_height = int(((height / width) * FACE_EMBEDDING_SIZE) // 4 * 4) + pil = pil.resize((FACE_EMBEDDING_SIZE, new_height)) + else: + new_width = int(((width / height) * FACE_EMBEDDING_SIZE) // 4 * 4) + pil = pil.resize((new_width, FACE_EMBEDDING_SIZE)) + + og = np.array(pil).astype(np.float32) + + # Image must be FACE_EMBEDDING_SIZExFACE_EMBEDDING_SIZE + og_h, og_w, channels = og.shape + frame = np.full( + (FACE_EMBEDDING_SIZE, FACE_EMBEDDING_SIZE, channels), + (0, 0, 0), + dtype=np.float32, + ) + + # compute center offset + x_center = (FACE_EMBEDDING_SIZE - og_w) // 2 + y_center = (FACE_EMBEDDING_SIZE - og_h) // 2 + + # copy img image into center of result image + frame[y_center : y_center + og_h, x_center : x_center + og_w] = og + frame = np.expand_dims(frame, axis=0) + return [{"input_2": frame}] + elif self.model_type == ModelTypeEnum.lpr_detect: + preprocessed = [] + for x in raw_inputs: + preprocessed.append(x) + return [{"x": preprocessed[0]}] + elif self.model_type == ModelTypeEnum.lpr_classify: + processed = [] + for img in raw_inputs: + processed.append({"x": img}) + return processed + elif self.model_type == ModelTypeEnum.lpr_recognize: + processed = [] + for img in raw_inputs: + processed.append({"x": img}) + return processed else: raise ValueError(f"Unable to preprocess inputs for {self.model_type}") - def _process_image(self, image): + def _process_image(self, image, output: str = "RGB") -> Image.Image: if isinstance(image, str): if image.startswith("http"): response = requests.get(image) - image = Image.open(BytesIO(response.content)).convert("RGB") + image = Image.open(BytesIO(response.content)).convert(output) elif isinstance(image, bytes): - image = Image.open(BytesIO(image)).convert("RGB") + image = Image.open(BytesIO(image)).convert(output) return image def __call__( self, inputs: Union[List[str], List[Image.Image], List[str]] ) -> List[np.ndarray]: - self._load_model_and_tokenizer() + self._load_model_and_utils() if self.runner is None or ( self.tokenizer is None and self.feature_extractor is None ): diff --git a/frigate/embeddings/lpr/lpr.py b/frigate/embeddings/lpr/lpr.py new file mode 100644 index 000000000..d7e513c73 --- /dev/null +++ b/frigate/embeddings/lpr/lpr.py @@ -0,0 +1,808 @@ +import logging +import math +from typing import List, Tuple + +import cv2 +import numpy as np +from pyclipper import ET_CLOSEDPOLYGON, JT_ROUND, PyclipperOffset +from shapely.geometry import Polygon + +from frigate.comms.inter_process import InterProcessRequestor +from frigate.config.classification import LicensePlateRecognitionConfig +from frigate.embeddings.embeddings import Embeddings + +logger = logging.getLogger(__name__) + +MIN_PLATE_LENGTH = 3 + + +class LicensePlateRecognition: + def __init__( + self, + config: LicensePlateRecognitionConfig, + requestor: InterProcessRequestor, + embeddings: Embeddings, + ): + self.lpr_config = config + self.requestor = requestor + self.embeddings = embeddings + self.detection_model = self.embeddings.lpr_detection_model + self.classification_model = self.embeddings.lpr_classification_model + self.recognition_model = self.embeddings.lpr_recognition_model + self.ctc_decoder = CTCDecoder() + + self.batch_size = 6 + + # Detection specific parameters + self.min_size = 3 + self.max_size = 960 + self.box_thresh = 0.8 + self.mask_thresh = 0.8 + + if self.lpr_config.enabled: + # all models need to be loaded to run LPR + self.detection_model._load_model_and_utils() + self.classification_model._load_model_and_utils() + self.recognition_model._load_model_and_utils() + + def detect(self, image: np.ndarray) -> List[np.ndarray]: + """ + Detect possible license plates in the input image by first resizing and normalizing it, + running a detection model, and filtering out low-probability regions. + + Args: + image (np.ndarray): The input image in which license plates will be detected. + + Returns: + List[np.ndarray]: A list of bounding box coordinates representing detected license plates. + """ + h, w = image.shape[:2] + + if sum([h, w]) < 64: + image = self.zero_pad(image) + + resized_image = self.resize_image(image) + normalized_image = self.normalize_image(resized_image) + + outputs = self.detection_model([normalized_image])[0] + outputs = outputs[0, :, :] + + boxes, _ = self.boxes_from_bitmap(outputs, outputs > self.mask_thresh, w, h) + return self.filter_polygon(boxes, (h, w)) + + def classify( + self, images: List[np.ndarray] + ) -> Tuple[List[np.ndarray], List[Tuple[str, float]]]: + """ + Classify the orientation or category of each detected license plate. + + Args: + images (List[np.ndarray]): A list of images of detected license plates. + + Returns: + Tuple[List[np.ndarray], List[Tuple[str, float]]]: A tuple of rotated/normalized plate images + and classification results with confidence scores. + """ + num_images = len(images) + indices = np.argsort([x.shape[1] / x.shape[0] for x in images]) + + for i in range(0, num_images, self.batch_size): + norm_images = [] + for j in range(i, min(num_images, i + self.batch_size)): + norm_img = self._preprocess_classification_image(images[indices[j]]) + norm_img = norm_img[np.newaxis, :] + norm_images.append(norm_img) + + outputs = self.classification_model(norm_images) + + return self._process_classification_output(images, outputs) + + def recognize( + self, images: List[np.ndarray] + ) -> Tuple[List[str], List[List[float]]]: + """ + Recognize the characters on the detected license plates using the recognition model. + + Args: + images (List[np.ndarray]): A list of images of license plates to recognize. + + Returns: + Tuple[List[str], List[List[float]]]: A tuple of recognized license plate texts and confidence scores. + """ + input_shape = [3, 48, 320] + num_images = len(images) + + # sort images by aspect ratio for processing + indices = np.argsort(np.array([x.shape[1] / x.shape[0] for x in images])) + + for index in range(0, num_images, self.batch_size): + input_h, input_w = input_shape[1], input_shape[2] + max_wh_ratio = input_w / input_h + norm_images = [] + + # calculate the maximum aspect ratio in the current batch + for i in range(index, min(num_images, index + self.batch_size)): + h, w = images[indices[i]].shape[0:2] + max_wh_ratio = max(max_wh_ratio, w * 1.0 / h) + + # preprocess the images based on the max aspect ratio + for i in range(index, min(num_images, index + self.batch_size)): + norm_image = self._preprocess_recognition_image( + images[indices[i]], max_wh_ratio + ) + norm_image = norm_image[np.newaxis, :] + norm_images.append(norm_image) + + outputs = self.recognition_model(norm_images) + return self.ctc_decoder(outputs) + + def process_license_plate( + self, image: np.ndarray + ) -> Tuple[List[str], List[float], List[int]]: + """ + Complete pipeline for detecting, classifying, and recognizing license plates in the input image. + + Args: + image (np.ndarray): The input image in which to detect, classify, and recognize license plates. + + Returns: + Tuple[List[str], List[float], List[int]]: Detected license plate texts, confidence scores, and areas of the plates. + """ + if ( + self.detection_model.runner is None + or self.classification_model.runner is None + or self.recognition_model.runner is None + ): + # we might still be downloading the models + logger.debug("Model runners not loaded") + return [], [], [] + + plate_points = self.detect(image) + if len(plate_points) == 0: + return [], [], [] + + plate_points = self.sort_polygon(list(plate_points)) + plate_images = [self._crop_license_plate(image, x) for x in plate_points] + rotated_images, _ = self.classify(plate_images) + + # keep track of the index of each image for correct area calc later + sorted_indices = np.argsort([x.shape[1] / x.shape[0] for x in rotated_images]) + reverse_mapping = { + idx: original_idx for original_idx, idx in enumerate(sorted_indices) + } + + results, confidences = self.recognize(rotated_images) + + if results: + license_plates = [""] * len(rotated_images) + average_confidences = [[0.0]] * len(rotated_images) + areas = [0] * len(rotated_images) + + # map results back to original image order + for i, (plate, conf) in enumerate(zip(results, confidences)): + original_idx = reverse_mapping[i] + + height, width = rotated_images[original_idx].shape[:2] + area = height * width + + average_confidence = conf + + # set to True to write each cropped image for debugging + if False: + save_image = cv2.cvtColor( + rotated_images[original_idx], cv2.COLOR_RGB2BGR + ) + filename = f"/config/plate_{original_idx}_{plate}_{area}.jpg" + cv2.imwrite(filename, save_image) + + license_plates[original_idx] = plate + average_confidences[original_idx] = average_confidence + areas[original_idx] = area + + # Filter out plates that have a length of less than 3 characters + # Sort by area, then by plate length, then by confidence all desc + sorted_data = sorted( + [ + (plate, conf, area) + for plate, conf, area in zip( + license_plates, average_confidences, areas + ) + if len(plate) >= MIN_PLATE_LENGTH + ], + key=lambda x: (x[2], len(x[0]), x[1]), + reverse=True, + ) + + if sorted_data: + return map(list, zip(*sorted_data)) + + return [], [], [] + + def resize_image(self, image: np.ndarray) -> np.ndarray: + """ + Resize the input image while maintaining the aspect ratio, ensuring dimensions are multiples of 32. + + Args: + image (np.ndarray): The input image to resize. + + Returns: + np.ndarray: The resized image. + """ + h, w = image.shape[:2] + ratio = min(self.max_size / max(h, w), 1.0) + resize_h = max(int(round(int(h * ratio) / 32) * 32), 32) + resize_w = max(int(round(int(w * ratio) / 32) * 32), 32) + return cv2.resize(image, (resize_w, resize_h)) + + def normalize_image(self, image: np.ndarray) -> np.ndarray: + """ + Normalize the input image by subtracting the mean and multiplying by the standard deviation. + + Args: + image (np.ndarray): The input image to normalize. + + Returns: + np.ndarray: The normalized image, transposed to match the model's expected input format. + """ + mean = np.array([123.675, 116.28, 103.53]).reshape(1, -1).astype("float64") + std = 1 / np.array([58.395, 57.12, 57.375]).reshape(1, -1).astype("float64") + + image = image.astype("float32") + cv2.subtract(image, mean, image) + cv2.multiply(image, std, image) + return image.transpose((2, 0, 1))[np.newaxis, ...] + + def boxes_from_bitmap( + self, output: np.ndarray, mask: np.ndarray, dest_width: int, dest_height: int + ) -> Tuple[np.ndarray, List[float]]: + """ + Process the binary mask to extract bounding boxes and associated confidence scores. + + Args: + output (np.ndarray): Output confidence map from the model. + mask (np.ndarray): Binary mask of detected regions. + dest_width (int): Target width for scaling the box coordinates. + dest_height (int): Target height for scaling the box coordinates. + + Returns: + Tuple[np.ndarray, List[float]]: Array of bounding boxes and list of corresponding scores. + """ + + mask = (mask * 255).astype(np.uint8) + height, width = mask.shape + outs = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) + + # handle different return values of findContours between OpenCV versions + contours = outs[0] if len(outs) == 2 else outs[1] + + boxes = [] + scores = [] + + for index in range(len(contours)): + contour = contours[index] + + # get minimum bounding box (rotated rectangle) around the contour and the smallest side length. + points, min_side = self.get_min_boxes(contour) + + if min_side < self.min_size: + continue + + points = np.array(points) + + score = self.box_score(output, contour) + if self.box_thresh > score: + continue + + polygon = Polygon(points) + distance = polygon.area / polygon.length + + # Use pyclipper to shrink the polygon slightly based on the computed distance. + offset = PyclipperOffset() + offset.AddPath(points, JT_ROUND, ET_CLOSEDPOLYGON) + points = np.array(offset.Execute(distance * 1.5)).reshape((-1, 1, 2)) + + # get the minimum bounding box around the shrunken polygon. + box, min_side = self.get_min_boxes(points) + + if min_side < self.min_size + 2: + continue + + box = np.array(box) + + # normalize and clip box coordinates to fit within the destination image size. + box[:, 0] = np.clip(np.round(box[:, 0] / width * dest_width), 0, dest_width) + box[:, 1] = np.clip( + np.round(box[:, 1] / height * dest_height), 0, dest_height + ) + + boxes.append(box.astype("int32")) + scores.append(score) + + return np.array(boxes, dtype="int32"), scores + + @staticmethod + def get_min_boxes(contour: np.ndarray) -> Tuple[List[Tuple[float, float]], float]: + """ + Calculate the minimum bounding box (rotated rectangle) for a given contour. + + Args: + contour (np.ndarray): The contour points of the detected shape. + + Returns: + Tuple[List[Tuple[float, float]], float]: A list of four points representing the + corners of the bounding box, and the length of the shortest side. + """ + bounding_box = cv2.minAreaRect(contour) + points = sorted(cv2.boxPoints(bounding_box), key=lambda x: x[0]) + index_1, index_4 = (0, 1) if points[1][1] > points[0][1] else (1, 0) + index_2, index_3 = (2, 3) if points[3][1] > points[2][1] else (3, 2) + box = [points[index_1], points[index_2], points[index_3], points[index_4]] + return box, min(bounding_box[1]) + + @staticmethod + def box_score(bitmap: np.ndarray, contour: np.ndarray) -> float: + """ + Calculate the average score within the bounding box of a contour. + + Args: + bitmap (np.ndarray): The output confidence map from the model. + contour (np.ndarray): The contour of the detected shape. + + Returns: + float: The average score of the pixels inside the contour region. + """ + h, w = bitmap.shape[:2] + contour = contour.reshape(-1, 2) + x1, y1 = np.clip(contour.min(axis=0), 0, [w - 1, h - 1]) + x2, y2 = np.clip(contour.max(axis=0), 0, [w - 1, h - 1]) + mask = np.zeros((y2 - y1 + 1, x2 - x1 + 1), dtype=np.uint8) + cv2.fillPoly(mask, [contour - [x1, y1]], 1) + return cv2.mean(bitmap[y1 : y2 + 1, x1 : x2 + 1], mask)[0] + + @staticmethod + def expand_box(points: List[Tuple[float, float]]) -> np.ndarray: + """ + Expand a polygonal shape slightly by a factor determined by the area-to-perimeter ratio. + + Args: + points (List[Tuple[float, float]]): Points of the polygon to expand. + + Returns: + np.ndarray: Expanded polygon points. + """ + polygon = Polygon(points) + distance = polygon.area / polygon.length + offset = PyclipperOffset() + offset.AddPath(points, JT_ROUND, ET_CLOSEDPOLYGON) + expanded = np.array(offset.Execute(distance * 1.5)).reshape((-1, 2)) + return expanded + + def filter_polygon( + self, points: List[np.ndarray], shape: Tuple[int, int] + ) -> np.ndarray: + """ + Filter a set of polygons to include only valid ones that fit within an image shape + and meet size constraints. + + Args: + points (List[np.ndarray]): List of polygons to filter. + shape (Tuple[int, int]): Shape of the image (height, width). + + Returns: + np.ndarray: List of filtered polygons. + """ + height, width = shape + return np.array( + [ + self.clockwise_order(point) + for point in points + if self.is_valid_polygon(point, width, height) + ] + ) + + @staticmethod + def is_valid_polygon(point: np.ndarray, width: int, height: int) -> bool: + """ + Check if a polygon is valid, meaning it fits within the image bounds + and has sides of a minimum length. + + Args: + point (np.ndarray): The polygon to validate. + width (int): Image width. + height (int): Image height. + + Returns: + bool: Whether the polygon is valid or not. + """ + return ( + point[:, 0].min() >= 0 + and point[:, 0].max() < width + and point[:, 1].min() >= 0 + and point[:, 1].max() < height + and np.linalg.norm(point[0] - point[1]) > 3 + and np.linalg.norm(point[0] - point[3]) > 3 + ) + + @staticmethod + def clockwise_order(point: np.ndarray) -> np.ndarray: + """ + Arrange the points of a polygon in clockwise order based on their angular positions + around the polygon's center. + + Args: + point (np.ndarray): Array of points of the polygon. + + Returns: + np.ndarray: Points ordered in clockwise direction. + """ + center = point.mean(axis=0) + return point[ + np.argsort(np.arctan2(point[:, 1] - center[1], point[:, 0] - center[0])) + ] + + @staticmethod + def sort_polygon(points): + """ + Sort polygons based on their position in the image. If polygons are close in vertical + position (within 10 pixels), sort them by horizontal position. + + Args: + points: List of polygons to sort. + + Returns: + List: Sorted list of polygons. + """ + points.sort(key=lambda x: (x[0][1], x[0][0])) + for i in range(len(points) - 1): + for j in range(i, -1, -1): + if abs(points[j + 1][0][1] - points[j][0][1]) < 10 and ( + points[j + 1][0][0] < points[j][0][0] + ): + temp = points[j] + points[j] = points[j + 1] + points[j + 1] = temp + else: + break + return points + + @staticmethod + def zero_pad(image: np.ndarray) -> np.ndarray: + """ + Apply zero-padding to an image, ensuring its dimensions are at least 32x32. + The padding is added only if needed. + + Args: + image (np.ndarray): Input image. + + Returns: + np.ndarray: Zero-padded image. + """ + h, w, c = image.shape + pad = np.zeros((max(32, h), max(32, w), c), np.uint8) + pad[:h, :w, :] = image + return pad + + @staticmethod + def _preprocess_classification_image(image: np.ndarray) -> np.ndarray: + """ + Preprocess a single image for classification by resizing, normalizing, and padding. + + This method resizes the input image to a fixed height of 48 pixels while adjusting + the width dynamically up to a maximum of 192 pixels. The image is then normalized and + padded to fit the required input dimensions for classification. + + Args: + image (np.ndarray): Input image to preprocess. + + Returns: + np.ndarray: Preprocessed and padded image. + """ + # fixed height of 48, dynamic width up to 192 + input_shape = (3, 48, 192) + input_c, input_h, input_w = input_shape + + h, w = image.shape[:2] + ratio = w / h + resized_w = min(input_w, math.ceil(input_h * ratio)) + + resized_image = cv2.resize(image, (resized_w, input_h)) + + # handle single-channel images (grayscale) if needed + if input_c == 1 and resized_image.ndim == 2: + resized_image = resized_image[np.newaxis, :, :] + else: + resized_image = resized_image.transpose((2, 0, 1)) + + # normalize + resized_image = (resized_image.astype("float32") / 255.0 - 0.5) / 0.5 + + padded_image = np.zeros((input_c, input_h, input_w), dtype=np.float32) + padded_image[:, :, :resized_w] = resized_image + + return padded_image + + def _process_classification_output( + self, images: List[np.ndarray], outputs: List[np.ndarray] + ) -> Tuple[List[np.ndarray], List[Tuple[str, float]]]: + """ + Process the classification model output by matching labels with confidence scores. + + This method processes the outputs from the classification model and rotates images + with high confidence of being labeled "180". It ensures that results are mapped to + the original image order. + + Args: + images (List[np.ndarray]): List of input images. + outputs (List[np.ndarray]): Corresponding model outputs. + + Returns: + Tuple[List[np.ndarray], List[Tuple[str, float]]]: A tuple of processed images and + classification results (label and confidence score). + """ + labels = ["0", "180"] + results = [["", 0.0]] * len(images) + indices = np.argsort(np.array([x.shape[1] / x.shape[0] for x in images])) + + outputs = np.stack(outputs) + + outputs = [ + (labels[idx], outputs[i, idx]) + for i, idx in enumerate(outputs.argmax(axis=1)) + ] + + for i in range(0, len(images), self.batch_size): + for j in range(len(outputs)): + label, score = outputs[j] + results[indices[i + j]] = [label, score] + if "180" in label and score >= self.lpr_config.threshold: + images[indices[i + j]] = cv2.rotate(images[indices[i + j]], 1) + + return images, results + + def _preprocess_recognition_image( + self, image: np.ndarray, max_wh_ratio: float + ) -> np.ndarray: + """ + Preprocess an image for recognition by dynamically adjusting its width. + + This method adjusts the width of the image based on the maximum width-to-height ratio + while keeping the height fixed at 48 pixels. The image is then normalized and padded + to fit the required input dimensions for recognition. + + Args: + image (np.ndarray): Input image to preprocess. + max_wh_ratio (float): Maximum width-to-height ratio for resizing. + + Returns: + np.ndarray: Preprocessed and padded image. + """ + # fixed height of 48, dynamic width based on ratio + input_shape = [3, 48, 320] + input_h, input_w = input_shape[1], input_shape[2] + + assert image.shape[2] == input_shape[0], "Unexpected number of image channels." + + # dynamically adjust input width based on max_wh_ratio + input_w = int(input_h * max_wh_ratio) + + # check for model-specific input width + model_input_w = self.recognition_model.runner.ort.get_inputs()[0].shape[3] + if isinstance(model_input_w, int) and model_input_w > 0: + input_w = model_input_w + + h, w = image.shape[:2] + aspect_ratio = w / h + resized_w = min(input_w, math.ceil(input_h * aspect_ratio)) + + resized_image = cv2.resize(image, (resized_w, input_h)) + resized_image = resized_image.transpose((2, 0, 1)) + resized_image = (resized_image.astype("float32") / 255.0 - 0.5) / 0.5 + + padded_image = np.zeros((input_shape[0], input_h, input_w), dtype=np.float32) + padded_image[:, :, :resized_w] = resized_image + + return padded_image + + @staticmethod + def _crop_license_plate(image: np.ndarray, points: np.ndarray) -> np.ndarray: + """ + Crop the license plate from the image using four corner points. + + This method crops the region containing the license plate by using the perspective + transformation based on four corner points. If the resulting image is significantly + taller than wide, the image is rotated to the correct orientation. + + Args: + image (np.ndarray): Input image containing the license plate. + points (np.ndarray): Four corner points defining the plate's position. + + Returns: + np.ndarray: Cropped and potentially rotated license plate image. + """ + assert len(points) == 4, "shape of points must be 4*2" + points = points.astype(np.float32) + crop_width = int( + max( + np.linalg.norm(points[0] - points[1]), + np.linalg.norm(points[2] - points[3]), + ) + ) + crop_height = int( + max( + np.linalg.norm(points[0] - points[3]), + np.linalg.norm(points[1] - points[2]), + ) + ) + pts_std = np.float32( + [[0, 0], [crop_width, 0], [crop_width, crop_height], [0, crop_height]] + ) + matrix = cv2.getPerspectiveTransform(points, pts_std) + image = cv2.warpPerspective( + image, + matrix, + (crop_width, crop_height), + borderMode=cv2.BORDER_REPLICATE, + flags=cv2.INTER_CUBIC, + ) + height, width = image.shape[0:2] + if height * 1.0 / width >= 1.5: + image = np.rot90(image, k=3) + return image + + +class CTCDecoder: + """ + A decoder for interpreting the output of a CTC (Connectionist Temporal Classification) model. + + This decoder converts the model's output probabilities into readable sequences of characters + while removing duplicates and handling blank tokens. It also calculates the confidence scores + for each decoded character sequence. + """ + + def __init__(self): + """ + Initialize the CTCDecoder with a list of characters and a character map. + + The character set includes digits, letters, special characters, and a "blank" token + (used by the CTC model for decoding purposes). A character map is created to map + indices to characters. + """ + self.characters = [ + "blank", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + ":", + ";", + "<", + "=", + ">", + "?", + "@", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "[", + "\\", + "]", + "^", + "_", + "`", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "{", + "|", + "}", + "~", + "!", + '"', + "#", + "$", + "%", + "&", + "'", + "(", + ")", + "*", + "+", + ",", + "-", + ".", + "/", + " ", + " ", + ] + self.char_map = {i: char for i, char in enumerate(self.characters)} + + def __call__( + self, outputs: List[np.ndarray] + ) -> Tuple[List[str], List[List[float]]]: + """ + Decode a batch of model outputs into character sequences and their confidence scores. + + The method takes the output probability distributions for each time step and uses + the best path decoding strategy. It then merges repeating characters and ignores + blank tokens. Confidence scores for each decoded character are also calculated. + + Args: + outputs (List[np.ndarray]): A list of model outputs, where each element is + a probability distribution for each time step. + + Returns: + Tuple[List[str], List[List[float]]]: A tuple of decoded character sequences + and confidence scores for each sequence. + """ + results = [] + confidences = [] + for output in outputs: + seq_log_probs = np.log(output + 1e-8) + best_path = np.argmax(seq_log_probs, axis=1) + + merged_path = [] + merged_probs = [] + for t, char_index in enumerate(best_path): + if char_index != 0 and (t == 0 or char_index != best_path[t - 1]): + merged_path.append(char_index) + merged_probs.append(seq_log_probs[t, char_index]) + + result = "".join(self.char_map[idx] for idx in merged_path) + results.append(result) + + confidence = np.exp(merged_probs).tolist() + confidences.append(confidence) + + return results, confidences diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index 341a2e25d..e221bd146 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -1,8 +1,10 @@ """Maintain embeddings in SQLite-vec.""" import base64 +import datetime import logging import os +import re import threading from multiprocessing.synchronize import Event as MpEvent from pathlib import Path @@ -10,6 +12,7 @@ from typing import Optional import cv2 import numpy as np +import requests from peewee import DoesNotExist from playhouse.sqliteq import SqliteQueueDatabase @@ -21,13 +24,22 @@ from frigate.comms.event_metadata_updater import ( from frigate.comms.events_updater import EventEndSubscriber, EventUpdateSubscriber from frigate.comms.inter_process import InterProcessRequestor from frigate.config import FrigateConfig -from frigate.const import CLIPS_DIR, UPDATE_EVENT_DESCRIPTION +from frigate.const import ( + CLIPS_DIR, + FRIGATE_LOCALHOST, + UPDATE_EVENT_DESCRIPTION, +) +from frigate.data_processing.real_time.api import RealTimeProcessorApi +from frigate.data_processing.real_time.bird_processor import BirdProcessor +from frigate.data_processing.real_time.face_processor import FaceProcessor +from frigate.data_processing.types import DataProcessorMetrics +from frigate.embeddings.lpr.lpr import LicensePlateRecognition from frigate.events.types import EventTypeEnum from frigate.genai import get_genai_client from frigate.models import Event from frigate.types import TrackedObjectUpdateTypesEnum from frigate.util.builtin import serialize -from frigate.util.image import SharedMemoryFrameManager, calculate_region +from frigate.util.image import SharedMemoryFrameManager, area, calculate_region from .embeddings import Embeddings @@ -43,11 +55,13 @@ class EmbeddingMaintainer(threading.Thread): self, db: SqliteQueueDatabase, config: FrigateConfig, + metrics: DataProcessorMetrics, stop_event: MpEvent, ) -> None: super().__init__(name="embeddings_maintainer") self.config = config - self.embeddings = Embeddings(config.semantic_search, db) + self.metrics = metrics + self.embeddings = Embeddings(config, db, metrics) # Check if we need to re-index events if config.semantic_search.reindex: @@ -60,12 +74,32 @@ class EmbeddingMaintainer(threading.Thread): ) self.embeddings_responder = EmbeddingsResponder() self.frame_manager = SharedMemoryFrameManager() + self.processors: list[RealTimeProcessorApi] = [] + + if self.config.face_recognition.enabled: + self.processors.append(FaceProcessor(self.config, metrics)) + + if self.config.classification.bird.enabled: + self.processors.append(BirdProcessor(self.config, metrics)) + # create communication for updating event descriptions self.requestor = InterProcessRequestor() self.stop_event = stop_event - self.tracked_events = {} + self.tracked_events: dict[str, list[any]] = {} self.genai_client = get_genai_client(config) + # set license plate recognition conditions + self.lpr_config = self.config.lpr + self.requires_license_plate_detection = ( + "license_plate" not in self.config.objects.all_objects + ) + self.detected_license_plates: dict[str, dict[str, any]] = {} + + if self.lpr_config.enabled: + self.license_plate_recognition = LicensePlateRecognition( + self.lpr_config, self.requestor, self.embeddings + ) + def run(self) -> None: """Maintain a SQLite-vec database for semantic search.""" while not self.stop_event.is_set(): @@ -84,7 +118,7 @@ class EmbeddingMaintainer(threading.Thread): def _process_requests(self) -> None: """Process embeddings requests""" - def _handle_request(topic: str, data: str) -> str: + def _handle_request(topic: str, data: dict[str, any]) -> str: try: if topic == EmbeddingsRequestEnum.embed_description.value: return serialize( @@ -101,8 +135,15 @@ class EmbeddingMaintainer(threading.Thread): ) elif topic == EmbeddingsRequestEnum.generate_search.value: return serialize( - self.embeddings.text_embedding([data])[0], pack=False + self.embeddings.embed_description("", data, upsert=False), + pack=False, ) + else: + for processor in self.processors: + resp = processor.handle_request(topic, data) + + if resp is not None: + return resp except Exception as e: logger.error(f"Unable to handle embeddings request {e}") @@ -110,7 +151,7 @@ class EmbeddingMaintainer(threading.Thread): def _process_updates(self) -> None: """Process event updates""" - update = self.event_subscriber.check_for_update(timeout=0.1) + update = self.event_subscriber.check_for_update(timeout=0.01) if update is None: return @@ -121,42 +162,63 @@ class EmbeddingMaintainer(threading.Thread): return camera_config = self.config.cameras[camera] - # no need to save our own thumbnails if genai is not enabled - # or if the object has become stationary + + # no need to process updated objects if face recognition, lpr, genai are disabled if ( not camera_config.genai.enabled - or self.genai_client is None - or data["stationary"] + and not self.lpr_config.enabled + and len(self.processors) == 0 ): return - if data["id"] not in self.tracked_events: - self.tracked_events[data["id"]] = [] - # Create our own thumbnail based on the bounding box and the frame time try: yuv_frame = self.frame_manager.get( frame_name, camera_config.frame_shape_yuv ) - - if yuv_frame is not None: - data["thumbnail"] = self._create_thumbnail(yuv_frame, data["box"]) - - # Limit the number of thumbnails saved - if len(self.tracked_events[data["id"]]) >= MAX_THUMBNAILS: - # Always keep the first thumbnail for the event - self.tracked_events[data["id"]].pop(1) - - self.tracked_events[data["id"]].append(data) - - self.frame_manager.close(frame_name) except FileNotFoundError: pass + if yuv_frame is None: + logger.debug( + "Unable to process object update because frame is unavailable." + ) + return + + for processor in self.processors: + processor.process_frame(data, yuv_frame) + + if self.lpr_config.enabled: + start = datetime.datetime.now().timestamp() + processed = self._process_license_plate(data, yuv_frame) + + if processed: + duration = datetime.datetime.now().timestamp() - start + self.metrics.alpr_pps.value = ( + self.metrics.alpr_pps.value * 9 + duration + ) / 10 + + # no need to save our own thumbnails if genai is not enabled + # or if the object has become stationary + if self.genai_client is not None and not data["stationary"]: + if data["id"] not in self.tracked_events: + self.tracked_events[data["id"]] = [] + + data["thumbnail"] = self._create_thumbnail(yuv_frame, data["box"]) + + # Limit the number of thumbnails saved + if len(self.tracked_events[data["id"]]) >= MAX_THUMBNAILS: + # Always keep the first thumbnail for the event + self.tracked_events[data["id"]].pop(1) + + self.tracked_events[data["id"]].append(data) + + self.frame_manager.close(frame_name) + def _process_finalized(self) -> None: """Process the end of an event.""" while True: - ended = self.event_end_subscriber.check_for_update(timeout=0.1) + ended = self.event_end_subscriber.check_for_update(timeout=0.01) if ended == None: break @@ -164,6 +226,12 @@ class EmbeddingMaintainer(threading.Thread): event_id, camera, updated_db = ended camera_config = self.config.cameras[camera] + for processor in self.processors: + processor.expire_object(event_id) + + if event_id in self.detected_license_plates: + self.detected_license_plates.pop(event_id) + if updated_db: try: event: Event = Event.get(Event.id == event_id) @@ -277,7 +345,7 @@ class EmbeddingMaintainer(threading.Thread): def _process_event_metadata(self): # Check for regenerate description requests (topic, event_id, source) = self.event_metadata_subscriber.check_for_update( - timeout=0.1 + timeout=0.01 ) if topic is None: @@ -286,6 +354,199 @@ class EmbeddingMaintainer(threading.Thread): if event_id: self.handle_regenerate_description(event_id, source) + def _detect_license_plate(self, input: np.ndarray) -> tuple[int, int, int, int]: + """Return the dimensions of the input image as [x, y, width, height].""" + height, width = input.shape[:2] + return (0, 0, width, height) + + def _process_license_plate( + self, obj_data: dict[str, any], frame: np.ndarray + ) -> bool: + """Look for license plates in image.""" + id = obj_data["id"] + + # don't run for non car objects + if obj_data.get("label") != "car": + logger.debug("Not a processing license plate for non car object.") + return False + + # don't run for stationary car objects + if obj_data.get("stationary") == True: + logger.debug("Not a processing license plate for a stationary car object.") + return False + + # don't overwrite sub label for objects that have a sub label + # that is not a license plate + if obj_data.get("sub_label") and id not in self.detected_license_plates: + logger.debug( + f"Not processing license plate due to existing sub label: {obj_data.get('sub_label')}." + ) + return False + + license_plate: Optional[dict[str, any]] = None + + if self.requires_license_plate_detection: + logger.debug("Running manual license_plate detection.") + car_box = obj_data.get("box") + + if not car_box: + return False + + rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420) + left, top, right, bottom = car_box + car = rgb[top:bottom, left:right] + license_plate = self._detect_license_plate(car) + + if not license_plate: + logger.debug("Detected no license plates for car object.") + return False + + license_plate_frame = car[ + license_plate[1] : license_plate[3], license_plate[0] : license_plate[2] + ] + license_plate_frame = cv2.cvtColor(license_plate_frame, cv2.COLOR_RGB2BGR) + else: + # don't run for object without attributes + if not obj_data.get("current_attributes"): + logger.debug("No attributes to parse.") + return False + + attributes: list[dict[str, any]] = obj_data.get("current_attributes", []) + for attr in attributes: + if attr.get("label") != "license_plate": + continue + + if license_plate is None or attr.get("score", 0.0) > license_plate.get( + "score", 0.0 + ): + license_plate = attr + + # no license plates detected in this frame + if not license_plate: + return False + + license_plate_box = license_plate.get("box") + + # check that license plate is valid + if ( + not license_plate_box + or area(license_plate_box) < self.config.lpr.min_area + ): + logger.debug(f"Invalid license plate box {license_plate}") + return False + + license_plate_frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) + license_plate_frame = license_plate_frame[ + license_plate_box[1] : license_plate_box[3], + license_plate_box[0] : license_plate_box[2], + ] + + # run detection, returns results sorted by confidence, best first + license_plates, confidences, areas = ( + self.license_plate_recognition.process_license_plate(license_plate_frame) + ) + + logger.debug(f"Text boxes: {license_plates}") + logger.debug(f"Confidences: {confidences}") + logger.debug(f"Areas: {areas}") + + if license_plates: + for plate, confidence, text_area in zip(license_plates, confidences, areas): + avg_confidence = ( + (sum(confidence) / len(confidence)) if confidence else 0 + ) + + logger.debug( + f"Detected text: {plate} (average confidence: {avg_confidence:.2f}, area: {text_area} pixels)" + ) + else: + # no plates found + logger.debug("No text detected") + return True + + top_plate, top_char_confidences, top_area = ( + license_plates[0], + confidences[0], + areas[0], + ) + avg_confidence = ( + (sum(top_char_confidences) / len(top_char_confidences)) + if top_char_confidences + else 0 + ) + + # Check if we have a previously detected plate for this ID + if id in self.detected_license_plates: + prev_plate = self.detected_license_plates[id]["plate"] + prev_char_confidences = self.detected_license_plates[id]["char_confidences"] + prev_area = self.detected_license_plates[id]["area"] + prev_avg_confidence = ( + (sum(prev_char_confidences) / len(prev_char_confidences)) + if prev_char_confidences + else 0 + ) + + # Define conditions for keeping the previous plate + shorter_than_previous = len(top_plate) < len(prev_plate) + lower_avg_confidence = avg_confidence <= prev_avg_confidence + smaller_area = top_area < prev_area + + # Compare character-by-character confidence where possible + min_length = min(len(top_plate), len(prev_plate)) + char_confidence_comparison = sum( + 1 + for i in range(min_length) + if top_char_confidences[i] <= prev_char_confidences[i] + ) + worse_char_confidences = char_confidence_comparison >= min_length / 2 + + if (shorter_than_previous or smaller_area) and ( + lower_avg_confidence and worse_char_confidences + ): + logger.debug( + f"Keeping previous plate. New plate stats: " + f"length={len(top_plate)}, avg_conf={avg_confidence:.2f}, area={top_area} " + f"vs Previous: length={len(prev_plate)}, avg_conf={prev_avg_confidence:.2f}, area={prev_area}" + ) + return True + + # Check against minimum confidence threshold + if avg_confidence < self.lpr_config.threshold: + logger.debug( + f"Average confidence {avg_confidence} is less than threshold ({self.lpr_config.threshold})" + ) + return True + + # Determine subLabel based on known plates, use regex matching + # Default to the detected plate, use label name if there's a match + sub_label = next( + ( + label + for label, plates in self.lpr_config.known_plates.items() + if any(re.match(f"^{plate}$", top_plate) for plate in plates) + ), + top_plate, + ) + + # Send the result to the API + resp = requests.post( + f"{FRIGATE_LOCALHOST}/api/events/{id}/sub_label", + json={ + "camera": obj_data.get("camera"), + "subLabel": sub_label, + "subLabelScore": avg_confidence, + }, + ) + + if resp.status_code == 200: + self.detected_license_plates[id] = { + "plate": top_plate, + "char_confidences": top_char_confidences, + "area": top_area, + } + + return True + def _create_thumbnail(self, yuv_frame, box, height=500) -> Optional[bytes]: """Return jpg thumbnail of a region of the frame.""" frame = cv2.cvtColor(yuv_frame, cv2.COLOR_YUV2BGR_I420) diff --git a/frigate/events/maintainer.py b/frigate/events/maintainer.py index 68e7432ab..ebc506c73 100644 --- a/frigate/events/maintainer.py +++ b/frigate/events/maintainer.py @@ -25,6 +25,9 @@ def should_update_db(prev_event: Event, current_event: Event) -> bool: or prev_event["entered_zones"] != current_event["entered_zones"] or prev_event["thumbnail"] != current_event["thumbnail"] or prev_event["end_time"] != current_event["end_time"] + or prev_event["average_estimated_speed"] + != current_event["average_estimated_speed"] + or prev_event["velocity_angle"] != current_event["velocity_angle"] ): return True return False @@ -210,6 +213,8 @@ class EventProcessor(threading.Thread): "score": score, "top_score": event_data["top_score"], "attributes": attributes, + "average_estimated_speed": event_data["average_estimated_speed"], + "velocity_angle": event_data["velocity_angle"], "type": "object", "max_severity": event_data.get("max_severity"), }, diff --git a/frigate/ffmpeg_presets.py b/frigate/ffmpeg_presets.py index c2dc09d1f..208948044 100644 --- a/frigate/ffmpeg_presets.py +++ b/frigate/ffmpeg_presets.py @@ -6,6 +6,7 @@ from enum import Enum from typing import Any from frigate.const import ( + FFMPEG_HVC1_ARGS, FFMPEG_HWACCEL_NVIDIA, FFMPEG_HWACCEL_VAAPI, FFMPEG_HWACCEL_VULKAN, @@ -50,16 +51,9 @@ class LibvaGpuSelector: return "" -FPS_VFR_PARAM = ( - "-fps_mode vfr" - if int(os.getenv("LIBAVFORMAT_VERSION_MAJOR", "59") or "59") >= 59 - else "-vsync 2" -) -TIMEOUT_PARAM = ( - "-timeout" - if int(os.getenv("LIBAVFORMAT_VERSION_MAJOR", "59") or "59") >= 59 - else "-stimeout" -) +LIBAV_VERSION = int(os.getenv("LIBAVFORMAT_VERSION_MAJOR", "59") or "59") +FPS_VFR_PARAM = "-fps_mode vfr" if LIBAV_VERSION >= 59 else "-vsync 2" +TIMEOUT_PARAM = "-timeout" if LIBAV_VERSION >= 59 else "-stimeout" _gpu_selector = LibvaGpuSelector() _user_agent_args = [ @@ -71,8 +65,8 @@ PRESETS_HW_ACCEL_DECODE = { "preset-rpi-64-h264": "-c:v:1 h264_v4l2m2m", "preset-rpi-64-h265": "-c:v:1 hevc_v4l2m2m", FFMPEG_HWACCEL_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 -bsf:v dump_extra", # https://trac.ffmpeg.org/ticket/9766#comment:17 - "preset-intel-qsv-h265": f"-load_plugin hevc_hw -hwaccel qsv -qsv_device {_gpu_selector.get_selected_gpu()} -hwaccel_output_format qsv -c:v hevc_qsv -bsf:v dump_extra", # https://trac.ffmpeg.org/ticket/9766#comment:17 + "preset-intel-qsv-h264": f"-hwaccel qsv -qsv_device {_gpu_selector.get_selected_gpu()} -hwaccel_output_format qsv -c:v h264_qsv{' -bsf:v dump_extra' if LIBAV_VERSION >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17 + "preset-intel-qsv-h265": f"-load_plugin hevc_hw -hwaccel qsv -qsv_device {_gpu_selector.get_selected_gpu()} -hwaccel_output_format qsv{' -bsf:v dump_extra' if LIBAV_VERSION >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17 FFMPEG_HWACCEL_NVIDIA: "-hwaccel cuda -hwaccel_output_format cuda", "preset-jetson-h264": "-c:v h264_nvmpi -resize {1}x{2}", "preset-jetson-h265": "-c:v hevc_nvmpi -resize {1}x{2}", @@ -118,12 +112,12 @@ PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = { "preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m {2}", FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf format=vaapi|nv12,hwupload {2}", "preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1 {2}", - "preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1 {2}", + "preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v h264_qsv -g 50 -bf 0 -profile:v main -level:v 4.1 -async_depth:v 1 {2}", FFMPEG_HWACCEL_NVIDIA: "{0} -hide_banner {1} -c:v h264_nvenc -g 50 -profile:v high -level:v auto -preset:v p2 -tune:v ll {2}", "preset-jetson-h264": "{0} -hide_banner {1} -c:v h264_nvmpi -profile high {2}", - "preset-jetson-h265": "{0} -hide_banner {1} -c:v h264_nvmpi -profile high {2}", + "preset-jetson-h265": "{0} -hide_banner {1} -c:v h264_nvmpi -profile main {2}", "preset-rk-h264": "{0} -hide_banner {1} -c:v h264_rkmpp -profile:v high {2}", - "preset-rk-h265": "{0} -hide_banner {1} -c:v hevc_rkmpp -profile:v high {2}", + "preset-rk-h265": "{0} -hide_banner {1} -c:v hevc_rkmpp -profile:v main {2}", "default": "{0} -hide_banner {1} -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency {2}", } PRESETS_HW_ACCEL_ENCODE_BIRDSEYE["preset-nvidia-h264"] = ( @@ -138,13 +132,13 @@ PRESETS_HW_ACCEL_ENCODE_TIMELAPSE = { "preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}", FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi {2}", "preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -profile:v high -level:v 4.1 -async_depth:v 1 {2}", - "preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v hevc_qsv -profile:v high -level:v 4.1 -async_depth:v 1 {2}", + "preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v hevc_qsv -profile:v main -level:v 4.1 -async_depth:v 1 {2}", FFMPEG_HWACCEL_NVIDIA: "{0} -hide_banner -hwaccel cuda -hwaccel_output_format cuda -extra_hw_frames 8 {1} -c:v h264_nvenc {2}", "preset-nvidia-h265": "{0} -hide_banner -hwaccel cuda -hwaccel_output_format cuda -extra_hw_frames 8 {1} -c:v hevc_nvenc {2}", "preset-jetson-h264": "{0} -hide_banner {1} -c:v h264_nvmpi -profile high {2}", - "preset-jetson-h265": "{0} -hide_banner {1} -c:v hevc_nvmpi -profile high {2}", + "preset-jetson-h265": "{0} -hide_banner {1} -c:v hevc_nvmpi -profile main {2}", "preset-rk-h264": "{0} -hide_banner {1} -c:v h264_rkmpp -profile:v high {2}", - "preset-rk-h265": "{0} -hide_banner {1} -c:v hevc_rkmpp -profile:v high {2}", + "preset-rk-h265": "{0} -hide_banner {1} -c:v hevc_rkmpp -profile:v main {2}", "default": "{0} -hide_banner {1} -c:v libx264 -preset:v ultrafast -tune:v zerolatency {2}", } PRESETS_HW_ACCEL_ENCODE_TIMELAPSE["preset-nvidia-h264"] = ( @@ -497,6 +491,6 @@ def parse_preset_output_record(arg: Any, force_record_hvc1: bool) -> list[str]: if force_record_hvc1: # Apple only supports HEVC if it is hvc1 (vs. hev1) - preset += ["-tag:v", "hvc1"] + preset += FFMPEG_HVC1_ARGS return preset diff --git a/frigate/log.py b/frigate/log.py index 079fc6107..53e9004f5 100644 --- a/frigate/log.py +++ b/frigate/log.py @@ -18,12 +18,19 @@ LOG_HANDLER.setFormatter( ) ) +# filter out norfair warning LOG_HANDLER.addFilter( lambda record: not record.getMessage().startswith( "You are using a scalar distance function" ) ) +# filter out tflite logging +LOG_HANDLER.addFilter( + lambda record: "Created TensorFlow Lite XNNPACK delegate for CPU." + not in record.getMessage() +) + log_listener: Optional[QueueListener] = None diff --git a/frigate/mypy.ini b/frigate/mypy.ini index dd726f454..c687a254d 100644 --- a/frigate/mypy.ini +++ b/frigate/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.9 +python_version = 3.11 show_error_codes = true follow_imports = normal ignore_missing_imports = true diff --git a/frigate/object_processing.py b/frigate/object_processing.py index b5196e686..dfaf894f7 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -4,7 +4,7 @@ import logging import os import queue import threading -from collections import Counter, defaultdict +from collections import defaultdict from multiprocessing.synchronize import Event as MpEvent from typing import Callable, Optional @@ -51,8 +51,6 @@ class CameraState: self.camera_config = config.cameras[name] self.frame_manager = frame_manager self.best_objects: dict[str, TrackedObject] = {} - self.object_counts = defaultdict(int) - self.active_object_counts = defaultdict(int) self.tracked_objects: dict[str, TrackedObject] = {} self.frame_cache = {} self.zone_objects = defaultdict(list) @@ -162,7 +160,12 @@ class CameraState: box[2], box[3], text, - f"{obj['score']:.0%} {int(obj['area'])}", + f"{obj['score']:.0%} {int(obj['area'])}" + + ( + f" {float(obj['current_estimated_speed']):.1f}" + if obj["current_estimated_speed"] != 0 + else "" + ), thickness=thickness, color=color, ) @@ -256,6 +259,7 @@ class CameraState: new_obj = tracked_objects[id] = TrackedObject( self.config.model, self.camera_config, + self.config.ui, self.frame_cache, current_detections[id], ) @@ -338,6 +342,7 @@ class CameraState: "ratio": obj.obj_data["ratio"], "score": obj.obj_data["score"], "sub_label": sub_label, + "current_zones": obj.current_zones, } ) @@ -377,78 +382,6 @@ class CameraState: for c in self.callbacks["camera_activity"]: c(self.name, camera_activity) - # update overall camera state for each object type - obj_counter = Counter( - obj.obj_data["label"] - for obj in tracked_objects.values() - if not obj.false_positive - ) - - active_obj_counter = Counter( - obj.obj_data["label"] - for obj in tracked_objects.values() - if not obj.false_positive and obj.active - ) - - # keep track of all labels detected for this camera - total_label_count = 0 - total_active_label_count = 0 - - # report on all detected objects - for obj_name, count in obj_counter.items(): - total_label_count += count - - if count != self.object_counts[obj_name]: - self.object_counts[obj_name] = count - for c in self.callbacks["object_status"]: - c(self.name, obj_name, count) - - # update the active count on all detected objects - # To ensure we emit 0's if all objects are stationary, we need to loop - # over the set of all objects, not just active ones. - for obj_name in set(obj_counter): - count = active_obj_counter[obj_name] - total_active_label_count += count - - if count != self.active_object_counts[obj_name]: - self.active_object_counts[obj_name] = count - for c in self.callbacks["active_object_status"]: - c(self.name, obj_name, count) - - # publish for all labels detected for this camera - if total_label_count != self.object_counts.get("all"): - self.object_counts["all"] = total_label_count - for c in self.callbacks["object_status"]: - c(self.name, "all", total_label_count) - - # publish active label counts for this camera - if total_active_label_count != self.active_object_counts.get("all"): - self.active_object_counts["all"] = total_active_label_count - for c in self.callbacks["active_object_status"]: - c(self.name, "all", total_active_label_count) - - # expire any objects that are >0 and no longer detected - expired_objects = [ - obj_name - for obj_name, count in self.object_counts.items() - if count > 0 and obj_name not in obj_counter - ] - for obj_name in expired_objects: - # Ignore the artificial all label - if obj_name == "all": - continue - - self.object_counts[obj_name] = 0 - for c in self.callbacks["object_status"]: - c(self.name, obj_name, 0) - # Only publish if the object was previously active. - if self.active_object_counts[obj_name] > 0: - for c in self.callbacks["active_object_status"]: - c(self.name, obj_name, 0) - self.active_object_counts[obj_name] = 0 - for c in self.callbacks["snapshot"]: - c(self.name, self.best_objects[obj_name], frame_name) - # cleanup thumbnail frame cache current_thumb_frames = { obj.thumbnail_data["frame_time"] @@ -635,14 +568,6 @@ class TrackedObjectProcessor(threading.Thread): retain=True, ) - def object_status(camera, object_name, status): - self.dispatcher.publish(f"{camera}/{object_name}", status, retain=False) - - def active_object_status(camera, object_name, status): - self.dispatcher.publish( - f"{camera}/{object_name}/active", status, retain=False - ) - def camera_activity(camera, activity): last_activity = self.camera_activity.get(camera) @@ -659,8 +584,6 @@ class TrackedObjectProcessor(threading.Thread): camera_state.on("update", update) camera_state.on("end", end) camera_state.on("snapshot", snapshot) - camera_state.on("object_status", object_status) - camera_state.on("active_object_status", active_object_status) camera_state.on("camera_activity", camera_activity) self.camera_states[camera] = camera_state @@ -817,124 +740,6 @@ class TrackedObjectProcessor(threading.Thread): ) ) - # update zone counts for each label - # for each zone in the current camera - for zone in self.config.cameras[camera].zones.keys(): - # count labels for the camera in the zone - obj_counter = Counter( - obj.obj_data["label"] - for obj in camera_state.tracked_objects.values() - if zone in obj.current_zones and not obj.false_positive - ) - active_obj_counter = Counter( - obj.obj_data["label"] - for obj in camera_state.tracked_objects.values() - if ( - zone in obj.current_zones - and not obj.false_positive - and obj.active - ) - ) - total_label_count = 0 - total_active_label_count = 0 - - # update counts and publish status - for label in set(self.zone_data[zone].keys()) | set(obj_counter.keys()): - # Ignore the artificial all label - if label == "all": - continue - - # if we have previously published a count for this zone/label - zone_label = self.zone_data[zone][label] - active_zone_label = self.active_zone_data[zone][label] - if camera in zone_label: - current_count = sum(zone_label.values()) - current_active_count = sum(active_zone_label.values()) - zone_label[camera] = ( - obj_counter[label] if label in obj_counter else 0 - ) - active_zone_label[camera] = ( - active_obj_counter[label] - if label in active_obj_counter - else 0 - ) - new_count = sum(zone_label.values()) - new_active_count = sum(active_zone_label.values()) - if new_count != current_count: - self.dispatcher.publish( - f"{zone}/{label}", - new_count, - retain=False, - ) - if new_active_count != current_active_count: - self.dispatcher.publish( - f"{zone}/{label}/active", - new_active_count, - retain=False, - ) - - # Set the count for the /zone/all topic. - total_label_count += new_count - total_active_label_count += new_active_count - - # if this is a new zone/label combo for this camera - else: - if label in obj_counter: - zone_label[camera] = obj_counter[label] - active_zone_label[camera] = active_obj_counter[label] - self.dispatcher.publish( - f"{zone}/{label}", - obj_counter[label], - retain=False, - ) - self.dispatcher.publish( - f"{zone}/{label}/active", - active_obj_counter[label], - retain=False, - ) - - # Set the count for the /zone/all topic. - total_label_count += obj_counter[label] - total_active_label_count += active_obj_counter[label] - - # if we have previously published a count for this zone all labels - zone_label = self.zone_data[zone]["all"] - active_zone_label = self.active_zone_data[zone]["all"] - if camera in zone_label: - current_count = sum(zone_label.values()) - current_active_count = sum(active_zone_label.values()) - zone_label[camera] = total_label_count - active_zone_label[camera] = total_active_label_count - new_count = sum(zone_label.values()) - new_active_count = sum(active_zone_label.values()) - - if new_count != current_count: - self.dispatcher.publish( - f"{zone}/all", - new_count, - retain=False, - ) - if new_active_count != current_active_count: - self.dispatcher.publish( - f"{zone}/all/active", - new_active_count, - retain=False, - ) - # if this is a new zone all label for this camera - else: - zone_label[camera] = total_label_count - active_zone_label[camera] = total_active_label_count - self.dispatcher.publish( - f"{zone}/all", - total_label_count, - retain=False, - ) - self.dispatcher.publish( - f"{zone}/all/active", - total_active_label_count, - retain=False, - ) - # cleanup event finished queue while not self.stop_event.is_set(): update = self.event_end_subscriber.check_for_update(timeout=0.01) diff --git a/frigate/plus.py b/frigate/plus.py index 83203356c..758089b85 100644 --- a/frigate/plus.py +++ b/frigate/plus.py @@ -68,11 +68,13 @@ class PlusApi: or self._token_data["expires"] - datetime.datetime.now().timestamp() < 60 ): if self.key is None: - raise Exception("Plus API not activated") + raise Exception( + "Plus API key not set. See https://docs.frigate.video/integrations/plus#set-your-api-key" + ) parts = self.key.split(":") r = requests.get(f"{self.host}/v1/auth/token", auth=(parts[0], parts[1])) if not r.ok: - raise Exception("Unable to refresh API token") + raise Exception(f"Unable to refresh API token: {r.text}") self._token_data = r.json() def _get_authorization_header(self) -> dict: @@ -116,15 +118,6 @@ class PlusApi: logger.error(f"Failed to upload original: {r.status_code} {r.text}") raise Exception(r.text) - # resize and submit annotate - files = {"file": get_jpg_bytes(image, 640, 70)} - data = presigned_urls["annotate"]["fields"] - data["content-type"] = "image/jpeg" - r = requests.post(presigned_urls["annotate"]["url"], files=files, data=data) - if not r.ok: - logger.error(f"Failed to upload annotate: {r.status_code} {r.text}") - raise Exception(r.text) - # resize and submit thumbnail files = {"file": get_jpg_bytes(image, 200, 70)} data = presigned_urls["thumbnail"]["fields"] diff --git a/frigate/ptz/autotrack.py b/frigate/ptz/autotrack.py index 9f7f5f1b8..c1184f5b5 100644 --- a/frigate/ptz/autotrack.py +++ b/frigate/ptz/autotrack.py @@ -1,5 +1,6 @@ """Automatically pan, tilt, and zoom on detected objects via onvif.""" +import asyncio import copy import logging import queue @@ -135,7 +136,7 @@ class PtzMotionEstimator: try: logger.debug( - f"{camera}: Motion estimator transformation: {self.coord_transformations.rel_to_abs([[0,0]])}" + f"{camera}: Motion estimator transformation: {self.coord_transformations.rel_to_abs([[0, 0]])}" ) except Exception: pass @@ -253,7 +254,7 @@ class PtzAutoTracker: return if not self.onvif.cams[camera]["init"]: - if not self.onvif._init_onvif(camera): + if not asyncio.run(self.onvif._init_onvif(camera)): logger.warning( f"Disabling autotracking for {camera}: Unable to initialize onvif" ) @@ -471,7 +472,7 @@ class PtzAutoTracker: self.onvif.get_camera_status(camera) logger.info( - f"Calibration for {camera} in progress: {round((step/num_steps)*100)}% complete" + f"Calibration for {camera} in progress: {round((step / num_steps) * 100)}% complete" ) self.calibrating[camera] = False @@ -500,9 +501,28 @@ class PtzAutoTracker: # simple linear regression with intercept X_with_intercept = np.column_stack((np.ones(X.shape[0]), X)) - self.move_coefficients[camera] = np.linalg.lstsq( - X_with_intercept, y, rcond=None - )[0] + coefficients = np.linalg.lstsq(X_with_intercept, y, rcond=None)[0] + + intercept, slope = coefficients + + # Define reasonable bounds for PTZ movement times + MIN_MOVEMENT_TIME = 0.1 # Minimum time for any movement (100ms) + MAX_MOVEMENT_TIME = 10.0 # Maximum time for any movement + MAX_SLOPE = 2.0 # Maximum seconds per unit of movement + + coefficients_valid = ( + MIN_MOVEMENT_TIME <= intercept <= MAX_MOVEMENT_TIME + and 0 < slope <= MAX_SLOPE + ) + + if not coefficients_valid: + logger.warning( + f"{camera}: Autotracking calibration failed. See the Frigate documentation." + ) + return False + + # If coefficients are valid, proceed with updates + self.move_coefficients[camera] = coefficients # only assign a new intercept if we're calibrating if calibration: @@ -690,7 +710,7 @@ class PtzAutoTracker: f"{camera}: Predicted movement time: {self._predict_movement_time(camera, pan, tilt)}" ) logger.debug( - f"{camera}: Actual movement time: {self.ptz_metrics[camera].stop_time.value-self.ptz_metrics[camera].start_time.value}" + f"{camera}: Actual movement time: {self.ptz_metrics[camera].stop_time.value - self.ptz_metrics[camera].start_time.value}" ) # save metrics for better estimate calculations @@ -983,10 +1003,10 @@ class PtzAutoTracker: logger.debug(f"{camera}: Zoom test: at max zoom: {at_max_zoom}") logger.debug(f"{camera}: Zoom test: at min zoom: {at_min_zoom}") logger.debug( - f'{camera}: Zoom test: zoom in hysteresis limit: {zoom_in_hysteresis} value: {AUTOTRACKING_ZOOM_IN_HYSTERESIS} original: {self.tracked_object_metrics[camera]["original_target_box"]} max: {self.tracked_object_metrics[camera]["max_target_box"]} target: {calculated_target_box if calculated_target_box else self.tracked_object_metrics[camera]["target_box"]}' + f"{camera}: Zoom test: zoom in hysteresis limit: {zoom_in_hysteresis} value: {AUTOTRACKING_ZOOM_IN_HYSTERESIS} original: {self.tracked_object_metrics[camera]['original_target_box']} max: {self.tracked_object_metrics[camera]['max_target_box']} target: {calculated_target_box if calculated_target_box else self.tracked_object_metrics[camera]['target_box']}" ) logger.debug( - f'{camera}: Zoom test: zoom out hysteresis limit: {zoom_out_hysteresis} value: {AUTOTRACKING_ZOOM_OUT_HYSTERESIS} original: {self.tracked_object_metrics[camera]["original_target_box"]} max: {self.tracked_object_metrics[camera]["max_target_box"]} target: {calculated_target_box if calculated_target_box else self.tracked_object_metrics[camera]["target_box"]}' + f"{camera}: Zoom test: zoom out hysteresis limit: {zoom_out_hysteresis} value: {AUTOTRACKING_ZOOM_OUT_HYSTERESIS} original: {self.tracked_object_metrics[camera]['original_target_box']} max: {self.tracked_object_metrics[camera]['max_target_box']} target: {calculated_target_box if calculated_target_box else self.tracked_object_metrics[camera]['target_box']}" ) # Zoom in conditions (and) @@ -1069,7 +1089,7 @@ class PtzAutoTracker: pan = ((centroid_x / camera_width) - 0.5) * 2 tilt = (0.5 - (centroid_y / camera_height)) * 2 - logger.debug(f'{camera}: Original box: {obj.obj_data["box"]}') + logger.debug(f"{camera}: Original box: {obj.obj_data['box']}") logger.debug(f"{camera}: Predicted box: {tuple(predicted_box)}") logger.debug( f"{camera}: Velocity: {tuple(np.round(average_velocity).flatten().astype(int))}" @@ -1179,7 +1199,7 @@ class PtzAutoTracker: ) zoom = (ratio - 1) / (ratio + 1) logger.debug( - f'{camera}: limit: {self.tracked_object_metrics[camera]["max_target_box"]}, ratio: {ratio} zoom calculation: {zoom}' + f"{camera}: limit: {self.tracked_object_metrics[camera]['max_target_box']}, ratio: {ratio} zoom calculation: {zoom}" ) if not result: # zoom out with special condition if zooming out because of velocity, edges, etc. diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py index 21c973baa..7969a169b 100644 --- a/frigate/ptz/onvif.py +++ b/frigate/ptz/onvif.py @@ -1,15 +1,14 @@ """Configure and control camera via onvif.""" +import asyncio import logging from enum import Enum from importlib.util import find_spec from pathlib import Path import numpy -import requests -from onvif import ONVIFCamera, ONVIFError +from onvif import ONVIFCamera, ONVIFError, ONVIFService from zeep.exceptions import Fault, TransportError -from zeep.transports import Transport from frigate.camera import PTZMetrics from frigate.config import FrigateConfig, ZoomingModeEnum @@ -49,11 +48,6 @@ class OnvifController: if cam.onvif.host: try: - session = requests.Session() - session.verify = not cam.onvif.tls_insecure - transport = Transport( - timeout=10, operation_timeout=10, session=session - ) self.cams[cam_name] = { "onvif": ONVIFCamera( cam.onvif.host, @@ -62,9 +56,9 @@ class OnvifController: cam.onvif.password, wsdl_dir=str( Path(find_spec("onvif").origin).parent / "wsdl" - ).replace("dist-packages/onvif", "site-packages"), + ), adjust_time=cam.onvif.ignore_time_mismatch, - transport=transport, + encrypt=not cam.onvif.tls_insecure, ), "init": False, "active": False, @@ -74,11 +68,12 @@ class OnvifController: except ONVIFError as e: logger.error(f"Onvif connection to {cam.name} failed: {e}") - def _init_onvif(self, camera_name: str) -> bool: + async def _init_onvif(self, camera_name: str) -> bool: onvif: ONVIFCamera = self.cams[camera_name]["onvif"] + await onvif.update_xaddrs() # create init services - media = onvif.create_media_service() + media: ONVIFService = await onvif.create_media_service() logger.debug(f"Onvif media xaddr for {camera_name}: {media.xaddr}") try: @@ -92,7 +87,7 @@ class OnvifController: return False try: - profiles = media.GetProfiles() + profiles = await media.GetProfiles() logger.debug(f"Onvif profiles for {camera_name}: {profiles}") except (ONVIFError, Fault, TransportError) as e: logger.error( @@ -101,7 +96,7 @@ class OnvifController: return False profile = None - for key, onvif_profile in enumerate(profiles): + for _, onvif_profile in enumerate(profiles): if ( onvif_profile.VideoEncoderConfiguration and onvif_profile.PTZConfiguration @@ -135,7 +130,8 @@ class OnvifController: ) return False - ptz = onvif.create_ptz_service() + ptz: ONVIFService = await onvif.create_ptz_service() + self.cams[camera_name]["ptz"] = ptz # setup continuous moving request move_request = ptz.create_type("ContinuousMove") @@ -149,7 +145,7 @@ class OnvifController: ): request = ptz.create_type("GetConfigurationOptions") request.ConfigurationToken = profile.PTZConfiguration.token - ptz_config = ptz.GetConfigurationOptions(request) + ptz_config = await ptz.GetConfigurationOptions(request) logger.debug(f"Onvif config for {camera_name}: {ptz_config}") service_capabilities_request = ptz.create_type("GetServiceCapabilities") @@ -173,7 +169,7 @@ class OnvifController: status_request.ProfileToken = profile.token self.cams[camera_name]["status_request"] = status_request try: - status = ptz.GetStatus(status_request) + status = await 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}") @@ -246,7 +242,7 @@ class OnvifController: # setup existing presets try: - presets: list[dict] = ptz.GetPresets({"ProfileToken": profile.token}) + presets: list[dict] = await ptz.GetPresets({"ProfileToken": profile.token}) except ONVIFError as e: logger.warning(f"Unable to get presets from camera: {camera_name}: {e}") presets = [] @@ -325,19 +321,19 @@ class OnvifController: ) self.cams[camera_name]["features"] = supported_features - self.cams[camera_name]["init"] = True return True def _stop(self, camera_name: str) -> None: - onvif: ONVIFCamera = self.cams[camera_name]["onvif"] move_request = self.cams[camera_name]["move_request"] - onvif.get_service("ptz").Stop( - { - "ProfileToken": move_request.ProfileToken, - "PanTilt": True, - "Zoom": True, - } + asyncio.run( + self.cams[camera_name]["ptz"].Stop( + { + "ProfileToken": move_request.ProfileToken, + "PanTilt": True, + "Zoom": True, + } + ) ) self.cams[camera_name]["active"] = False @@ -353,7 +349,6 @@ class OnvifController: return self.cams[camera_name]["active"] = True - onvif: ONVIFCamera = self.cams[camera_name]["onvif"] move_request = self.cams[camera_name]["move_request"] if command == OnvifCommandEnum.move_left: @@ -376,7 +371,7 @@ class OnvifController: } try: - onvif.get_service("ptz").ContinuousMove(move_request) + asyncio.run(self.cams[camera_name]["ptz"].ContinuousMove(move_request)) except ONVIFError as e: logger.warning(f"Onvif sending move request to {camera_name} failed: {e}") @@ -404,26 +399,25 @@ class OnvifController: camera_name ].frame_time.value self.ptz_metrics[camera_name].stop_time.value = 0 - onvif: ONVIFCamera = self.cams[camera_name]["onvif"] move_request = self.cams[camera_name]["relative_move_request"] # function takes in -1 to 1 for pan and tilt, interpolate to the values of the camera. # The onvif spec says this can report as +INF and -INF, so this may need to be modified pan = numpy.interp( pan, + [-1, 1], [ self.cams[camera_name]["relative_fov_range"]["XRange"]["Min"], self.cams[camera_name]["relative_fov_range"]["XRange"]["Max"], ], - [-1, 1], ) tilt = numpy.interp( tilt, + [-1, 1], [ self.cams[camera_name]["relative_fov_range"]["YRange"]["Min"], self.cams[camera_name]["relative_fov_range"]["YRange"]["Max"], ], - [-1, 1], ) move_request.Speed = { @@ -450,7 +444,7 @@ class OnvifController: } move_request.Translation.Zoom.x = zoom - onvif.get_service("ptz").RelativeMove(move_request) + asyncio.run(self.cams[camera_name]["ptz"].RelativeMove(move_request)) # reset after the move request move_request.Translation.PanTilt.x = 0 @@ -475,13 +469,14 @@ class OnvifController: self.ptz_metrics[camera_name].start_time.value = 0 self.ptz_metrics[camera_name].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] - onvif.get_service("ptz").GotoPreset( - { - "ProfileToken": move_request.ProfileToken, - "PresetToken": preset_token, - } + asyncio.run( + self.cams[camera_name]["ptz"].GotoPreset( + { + "ProfileToken": move_request.ProfileToken, + "PresetToken": preset_token, + } + ) ) self.cams[camera_name]["active"] = False @@ -498,7 +493,6 @@ class OnvifController: return self.cams[camera_name]["active"] = True - onvif: ONVIFCamera = self.cams[camera_name]["onvif"] move_request = self.cams[camera_name]["move_request"] if command == OnvifCommandEnum.zoom_in: @@ -506,7 +500,7 @@ class OnvifController: elif command == OnvifCommandEnum.zoom_out: move_request.Velocity = {"Zoom": {"x": -0.5}} - onvif.get_service("ptz").ContinuousMove(move_request) + asyncio.run(self.cams[camera_name]["ptz"].ContinuousMove(move_request)) def _zoom_absolute(self, camera_name: str, zoom, speed) -> None: if "zoom-a" not in self.cams[camera_name]["features"]: @@ -530,17 +524,16 @@ class OnvifController: camera_name ].frame_time.value self.ptz_metrics[camera_name].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"], ], - [0, 1], ) move_request.Speed = {"Zoom": speed} @@ -548,7 +541,7 @@ class OnvifController: logger.debug(f"{camera_name}: Absolute zoom: {zoom}") - onvif.get_service("ptz").AbsoluteMove(move_request) + asyncio.run(self.cams[camera_name]["ptz"].AbsoluteMove(move_request)) self.cams[camera_name]["active"] = False @@ -560,7 +553,7 @@ class OnvifController: return if not self.cams[camera_name]["init"]: - if not self._init_onvif(camera_name): + if not asyncio.run(self._init_onvif(camera_name)): return try: @@ -590,7 +583,7 @@ class OnvifController: return {} if not self.cams[camera_name]["init"]: - self._init_onvif(camera_name) + asyncio.run(self._init_onvif(camera_name)) return { "name": camera_name, @@ -604,15 +597,16 @@ class OnvifController: return {} if not self.cams[camera_name]["init"]: - self._init_onvif(camera_name) + asyncio.run(self._init_onvif(camera_name)) - onvif: ONVIFCamera = self.cams[camera_name]["onvif"] service_capabilities_request = self.cams[camera_name][ "service_capabilities_request" ] try: - service_capabilities = onvif.get_service("ptz").GetServiceCapabilities( - service_capabilities_request + service_capabilities = asyncio.run( + self.cams[camera_name]["ptz"].GetServiceCapabilities( + service_capabilities_request + ) ) logger.debug( @@ -633,12 +627,13 @@ class OnvifController: return {} if not self.cams[camera_name]["init"]: - self._init_onvif(camera_name) + asyncio.run(self._init_onvif(camera_name)) - onvif: ONVIFCamera = self.cams[camera_name]["onvif"] status_request = self.cams[camera_name]["status_request"] try: - status = onvif.get_service("ptz").GetStatus(status_request) + status = asyncio.run( + self.cams[camera_name]["ptz"].GetStatus(status_request) + ) except Exception: pass # We're unsupported, that'll be reported in the next check. diff --git a/frigate/record/cleanup.py b/frigate/record/cleanup.py index b70a23b45..e526b020d 100644 --- a/frigate/record/cleanup.py +++ b/frigate/record/cleanup.py @@ -121,22 +121,29 @@ class RecordingCleanup(threading.Thread): review_start = 0 deleted_recordings = set() kept_recordings: list[tuple[float, float]] = [] + recording: Recordings for recording in recordings: keep = False mode = None # Now look for a reason to keep this recording segment for idx in range(review_start, len(reviews)): review: ReviewSegment = reviews[idx] + severity = review.severity + pre_capture = config.record.get_review_pre_capture(severity) + post_capture = config.record.get_review_post_capture(severity) # if the review starts in the future, stop checking reviews # and let this recording segment expire - if review.start_time > recording.end_time: + if review.start_time - pre_capture > recording.end_time: keep = False break # if the review is in progress or ends after the recording starts, keep it # and stop looking at reviews - if review.end_time is None or review.end_time >= recording.start_time: + if ( + review.end_time is None + or review.end_time + post_capture >= recording.start_time + ): keep = True mode = ( config.record.alerts.retain.mode @@ -149,7 +156,7 @@ class RecordingCleanup(threading.Thread): # this review and check the next review for an overlap. # since the review and recordings are sorted, we can skip review # that end before the previous recording segment started on future segments - if review.end_time < recording.start_time: + if review.end_time + post_capture < recording.start_time: review_start = idx # Delete recordings outside of the retention window or based on the retention mode diff --git a/frigate/record/export.py b/frigate/record/export.py index a4b9ee521..48fe20e83 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -19,6 +19,7 @@ from frigate.const import ( CACHE_DIR, CLIPS_DIR, EXPORT_DIR, + FFMPEG_HVC1_ARGS, MAX_PLAYLIST_SECONDS, PREVIEW_FRAME_TYPE, ) @@ -219,7 +220,7 @@ class RecordingExporter(threading.Thread): if self.playback_factor == PlaybackFactorEnum.realtime: ffmpeg_cmd = ( - f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart {video_path}" + f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart" ).split(" ") elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: ffmpeg_cmd = ( @@ -227,11 +228,16 @@ class RecordingExporter(threading.Thread): self.config.ffmpeg.ffmpeg_path, self.config.ffmpeg.hwaccel_args, f"-an {ffmpeg_input}", - f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}", + f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart", EncodeTypeEnum.timelapse, ) ).split(" ") + if self.config.ffmpeg.apple_compatibility: + ffmpeg_cmd += FFMPEG_HVC1_ARGS + + ffmpeg_cmd.append(video_path) + return ffmpeg_cmd, playlist_lines def get_preview_export_command(self, video_path: str) -> list[str]: @@ -327,7 +333,14 @@ class RecordingExporter(threading.Thread): self.user_provided_name or f"{self.camera.replace('_', ' ')} {self.get_datetime_from_timestamp(self.start_time)} {self.get_datetime_from_timestamp(self.end_time)}" ) - video_path = f"{EXPORT_DIR}/{self.export_id}.mp4" + filename_start_datetime = datetime.datetime.fromtimestamp( + self.start_time + ).strftime("%Y%m%d_%H%M%S") + filename_end_datetime = datetime.datetime.fromtimestamp(self.end_time).strftime( + "%Y%m%d_%H%M%S" + ) + cleaned_export_id = self.export_id.split("_")[-1] + video_path = f"{EXPORT_DIR}/{self.camera}_{filename_start_datetime}-{filename_end_datetime}_{cleaned_export_id}.mp4" thumb_path = self.save_thumbnail(self.export_id) Export.insert( diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index 269735670..a4c23763d 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -449,7 +449,7 @@ class RecordingMaintainer(threading.Thread): return None else: logger.debug( - f"Copied {file_path} in {datetime.datetime.now().timestamp()-start_frame} seconds." + f"Copied {file_path} in {datetime.datetime.now().timestamp() - start_frame} seconds." ) try: diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 8aa0f65e0..c99479a67 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -256,7 +256,7 @@ class ReviewSegmentMaintainer(threading.Thread): elif object["sub_label"][0] in self.config.model.all_attributes: segment.detections[object["id"]] = object["sub_label"][0] else: - segment.detections[object["id"]] = f'{object["label"]}-verified' + segment.detections[object["id"]] = f"{object['label']}-verified" segment.sub_labels[object["id"]] = object["sub_label"][0] # if object is alert label @@ -352,7 +352,7 @@ class ReviewSegmentMaintainer(threading.Thread): elif object["sub_label"][0] in self.config.model.all_attributes: detections[object["id"]] = object["sub_label"][0] else: - detections[object["id"]] = f'{object["label"]}-verified' + detections[object["id"]] = f"{object['label']}-verified" sub_labels[object["id"]] = object["sub_label"][0] # if object is alert label @@ -527,7 +527,9 @@ class ReviewSegmentMaintainer(threading.Thread): if event_id in self.indefinite_events[camera]: self.indefinite_events[camera].pop(event_id) - current_segment.last_update = manual_info["end_time"] + + if len(self.indefinite_events[camera]) == 0: + current_segment.last_update = manual_info["end_time"] else: logger.error( f"Event with ID {event_id} has a set duration and can not be ended manually." diff --git a/frigate/service_manager/multiprocessing.py b/frigate/service_manager/multiprocessing.py index d0b169275..87bb4ffee 100644 --- a/frigate/service_manager/multiprocessing.py +++ b/frigate/service_manager/multiprocessing.py @@ -72,8 +72,7 @@ class BaseServiceProcess(Service, ABC): running = False except TimeoutError: self.manager.logger.warning( - f"{self.name} is still running after " - f"{timeout} seconds. Killing." + f"{self.name} is still running after {timeout} seconds. Killing." ) if running: diff --git a/frigate/service_manager/service.py b/frigate/service_manager/service.py index 62be6205b..89d766e9d 100644 --- a/frigate/service_manager/service.py +++ b/frigate/service_manager/service.py @@ -26,7 +26,7 @@ class Service(ABC): self.__dict__["name"] = name self.__manager = manager or ServiceManager.current() - self.__lock = asyncio.Lock(loop=self.__manager._event_loop) + self.__lock = asyncio.Lock(loop=self.__manager._event_loop) # type: ignore[call-arg] self.__manager._register(self) @property diff --git a/frigate/stats/emitter.py b/frigate/stats/emitter.py index 8a09ff51b..022e99213 100644 --- a/frigate/stats/emitter.py +++ b/frigate/stats/emitter.py @@ -11,6 +11,7 @@ from typing import Optional from frigate.comms.inter_process import InterProcessRequestor from frigate.config import FrigateConfig from frigate.const import FREQUENCY_STATS_POINTS +from frigate.stats.prometheus import update_metrics from frigate.stats.util import stats_snapshot from frigate.types import StatsTrackingTypes @@ -67,6 +68,16 @@ class StatsEmitter(threading.Thread): return selected_stats + def stats_init(config, camera_metrics, detectors, processes): + stats = { + "cameras": camera_metrics, + "detectors": detectors, + "processes": processes, + } + # Update Prometheus metrics with initial stats + update_metrics(stats) + return stats + def run(self) -> None: time.sleep(10) for counter in itertools.cycle( diff --git a/frigate/stats/prometheus.py b/frigate/stats/prometheus.py new file mode 100644 index 000000000..a43c091e2 --- /dev/null +++ b/frigate/stats/prometheus.py @@ -0,0 +1,207 @@ +from typing import Dict + +from prometheus_client import ( + CONTENT_TYPE_LATEST, + Counter, + Gauge, + Info, + generate_latest, +) + +# System metrics +SYSTEM_INFO = Info("frigate_system", "System information") +CPU_USAGE = Gauge( + "frigate_cpu_usage_percent", + "Process CPU usage %", + ["pid", "name", "process", "type", "cmdline"], +) +MEMORY_USAGE = Gauge( + "frigate_mem_usage_percent", + "Process memory usage %", + ["pid", "name", "process", "type", "cmdline"], +) + +# Camera metrics +CAMERA_FPS = Gauge( + "frigate_camera_fps", + "Frames per second being consumed from your camera", + ["camera_name"], +) +DETECTION_FPS = Gauge( + "frigate_detection_fps", + "Number of times detection is run per second", + ["camera_name"], +) +PROCESS_FPS = Gauge( + "frigate_process_fps", + "Frames per second being processed by frigate", + ["camera_name"], +) +SKIPPED_FPS = Gauge( + "frigate_skipped_fps", "Frames per second skipped for processing", ["camera_name"] +) +DETECTION_ENABLED = Gauge( + "frigate_detection_enabled", "Detection enabled for camera", ["camera_name"] +) +AUDIO_DBFS = Gauge("frigate_audio_dBFS", "Audio dBFS for camera", ["camera_name"]) +AUDIO_RMS = Gauge("frigate_audio_rms", "Audio RMS for camera", ["camera_name"]) + +# Detector metrics +DETECTOR_INFERENCE = Gauge( + "frigate_detector_inference_speed_seconds", + "Time spent running object detection in seconds", + ["name"], +) +DETECTOR_START = Gauge( + "frigate_detection_start", "Detector start time (unix timestamp)", ["name"] +) + +# GPU metrics +GPU_USAGE = Gauge("frigate_gpu_usage_percent", "GPU utilisation %", ["gpu_name"]) +GPU_MEMORY = Gauge("frigate_gpu_mem_usage_percent", "GPU memory usage %", ["gpu_name"]) + +# Storage metrics +STORAGE_FREE = Gauge("frigate_storage_free_bytes", "Storage free bytes", ["storage"]) +STORAGE_TOTAL = Gauge("frigate_storage_total_bytes", "Storage total bytes", ["storage"]) +STORAGE_USED = Gauge("frigate_storage_used_bytes", "Storage used bytes", ["storage"]) +STORAGE_MOUNT = Info( + "frigate_storage_mount_type", "Storage mount type", ["mount_type", "storage"] +) + +# Service metrics +UPTIME = Gauge("frigate_service_uptime_seconds", "Uptime seconds") +LAST_UPDATE = Gauge( + "frigate_service_last_updated_timestamp", "Stats recorded time (unix timestamp)" +) +TEMPERATURE = Gauge("frigate_device_temperature", "Device Temperature", ["device"]) + +# Event metrics +CAMERA_EVENTS = Counter( + "frigate_camera_events", + "Count of camera events since exporter started", + ["camera", "label"], +) + + +def update_metrics(stats: Dict) -> None: + """Update Prometheus metrics based on Frigate stats""" + try: + # Update process metrics + if "cpu_usages" in stats: + for pid, proc_stats in stats["cpu_usages"].items(): + cmdline = proc_stats.get("cmdline", "") + process_type = "Other" + process_name = cmdline + + CPU_USAGE.labels( + pid=pid, + name=process_name, + process=process_name, + type=process_type, + cmdline=cmdline, + ).set(float(proc_stats["cpu"])) + + MEMORY_USAGE.labels( + pid=pid, + name=process_name, + process=process_name, + type=process_type, + cmdline=cmdline, + ).set(float(proc_stats["mem"])) + + # Update camera metrics + if "cameras" in stats: + for camera_name, camera_stats in stats["cameras"].items(): + if "camera_fps" in camera_stats: + CAMERA_FPS.labels(camera_name=camera_name).set( + camera_stats["camera_fps"] + ) + if "detection_fps" in camera_stats: + DETECTION_FPS.labels(camera_name=camera_name).set( + camera_stats["detection_fps"] + ) + if "process_fps" in camera_stats: + PROCESS_FPS.labels(camera_name=camera_name).set( + camera_stats["process_fps"] + ) + if "skipped_fps" in camera_stats: + SKIPPED_FPS.labels(camera_name=camera_name).set( + camera_stats["skipped_fps"] + ) + if "detection_enabled" in camera_stats: + DETECTION_ENABLED.labels(camera_name=camera_name).set( + camera_stats["detection_enabled"] + ) + if "audio_dBFS" in camera_stats: + AUDIO_DBFS.labels(camera_name=camera_name).set( + camera_stats["audio_dBFS"] + ) + if "audio_rms" in camera_stats: + AUDIO_RMS.labels(camera_name=camera_name).set( + camera_stats["audio_rms"] + ) + + # Update detector metrics + if "detectors" in stats: + for name, detector in stats["detectors"].items(): + if "inference_speed" in detector: + DETECTOR_INFERENCE.labels(name=name).set( + detector["inference_speed"] * 0.001 + ) # ms to seconds + if "detection_start" in detector: + DETECTOR_START.labels(name=name).set(detector["detection_start"]) + + # Update GPU metrics + if "gpu_usages" in stats: + for gpu_name, gpu_stats in stats["gpu_usages"].items(): + if "gpu" in gpu_stats: + GPU_USAGE.labels(gpu_name=gpu_name).set(float(gpu_stats["gpu"])) + if "mem" in gpu_stats: + GPU_MEMORY.labels(gpu_name=gpu_name).set(float(gpu_stats["mem"])) + + # Update service metrics + if "service" in stats: + service = stats["service"] + + if "uptime" in service: + UPTIME.set(service["uptime"]) + if "last_updated" in service: + LAST_UPDATE.set(service["last_updated"]) + + # Storage metrics + if "storage" in service: + for path, storage in service["storage"].items(): + if "free" in storage: + STORAGE_FREE.labels(storage=path).set( + storage["free"] * 1e6 + ) # MB to bytes + if "total" in storage: + STORAGE_TOTAL.labels(storage=path).set(storage["total"] * 1e6) + if "used" in storage: + STORAGE_USED.labels(storage=path).set(storage["used"] * 1e6) + if "mount_type" in storage: + STORAGE_MOUNT.labels(storage=path).info( + {"mount_type": storage["mount_type"], "storage": path} + ) + + # Temperature metrics + if "temperatures" in service: + for device, temp in service["temperatures"].items(): + TEMPERATURE.labels(device=device).set(temp) + + # Version info + if "version" in service and "latest_version" in service: + SYSTEM_INFO.info( + { + "version": service["version"], + "latest_version": service["latest_version"], + } + ) + + except Exception as e: + print(f"Error updating Prometheus metrics: {str(e)}") + + +def get_metrics() -> tuple[str, str]: + """Get Prometheus metrics in text format""" + return generate_latest(), CONTENT_TYPE_LATEST diff --git a/frigate/stats/util.py b/frigate/stats/util.py index d8e93c6ca..262cec3d2 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -14,6 +14,7 @@ from requests.exceptions import RequestException from frigate.camera import CameraMetrics from frigate.config import FrigateConfig from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR +from frigate.data_processing.types import DataProcessorMetrics from frigate.object_detection import ObjectDetectProcess from frigate.types import StatsTrackingTypes from frigate.util.services import ( @@ -51,11 +52,13 @@ def get_latest_version(config: FrigateConfig) -> str: def stats_init( config: FrigateConfig, camera_metrics: dict[str, CameraMetrics], + embeddings_metrics: DataProcessorMetrics | None, detectors: dict[str, ObjectDetectProcess], processes: dict[str, int], ) -> StatsTrackingTypes: stats_tracking: StatsTrackingTypes = { "camera_metrics": camera_metrics, + "embeddings_metrics": embeddings_metrics, "detectors": detectors, "started": int(time.time()), "latest_frigate_version": get_latest_version(config), @@ -195,7 +198,7 @@ async def set_gpu_stats( continue # intel QSV GPU - intel_usage = get_intel_gpu_stats() + intel_usage = get_intel_gpu_stats(config.telemetry.stats.sriov) if intel_usage is not None: stats["intel-qsv"] = intel_usage or {"gpu": "", "mem": ""} @@ -220,7 +223,7 @@ async def set_gpu_stats( continue # intel VAAPI GPU - intel_usage = get_intel_gpu_stats() + intel_usage = get_intel_gpu_stats(config.telemetry.stats.sriov) if intel_usage is not None: stats["intel-vaapi"] = intel_usage or {"gpu": "", "mem": ""} @@ -279,6 +282,27 @@ def stats_snapshot( } stats["detection_fps"] = round(total_detection_fps, 2) + if config.semantic_search.enabled: + embeddings_metrics = stats_tracking["embeddings_metrics"] + stats["embeddings"] = { + "image_embedding_speed": round( + embeddings_metrics.image_embeddings_fps.value * 1000, 2 + ), + "text_embedding_speed": round( + embeddings_metrics.text_embeddings_sps.value * 1000, 2 + ), + } + + if config.face_recognition.enabled: + stats["embeddings"]["face_recognition_speed"] = round( + embeddings_metrics.face_rec_fps.value * 1000, 2 + ) + + if config.lpr.enabled: + stats["embeddings"]["plate_recognition_speed"] = round( + embeddings_metrics.alpr_pps.value * 1000, 2 + ) + get_processing_stats(config, stats, hwaccel_errors) stats["service"] = { diff --git a/frigate/test/http_api/base_http_test.py b/frigate/test/http_api/base_http_test.py index e7a1d03e8..c16ab9926 100644 --- a/frigate/test/http_api/base_http_test.py +++ b/frigate/test/http_api/base_http_test.py @@ -6,6 +6,7 @@ import unittest from peewee_migrate import Router from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqliteq import SqliteQueueDatabase +from pydantic import Json from frigate.api.fastapi_app import create_fastapi_app from frigate.config import FrigateConfig @@ -123,7 +124,12 @@ class BaseTestHttp(unittest.TestCase): def insert_mock_event( self, id: str, - start_time: datetime.datetime = datetime.datetime.now().timestamp(), + start_time: float = datetime.datetime.now().timestamp(), + end_time: float = datetime.datetime.now().timestamp() + 20, + has_clip: bool = True, + top_score: int = 100, + score: int = 0, + data: Json = {}, ) -> Event: """Inserts a basic event model with a given id.""" return Event.insert( @@ -131,16 +137,18 @@ class BaseTestHttp(unittest.TestCase): label="Mock", camera="front_door", start_time=start_time, - end_time=start_time + 20, - top_score=100, + end_time=end_time, + top_score=top_score, + score=score, false_positive=False, zones=list(), thumbnail="", region=[], box=[], area=0, - has_clip=True, + has_clip=has_clip, has_snapshot=True, + data=data, ).execute() def insert_mock_review_segment( @@ -150,6 +158,7 @@ class BaseTestHttp(unittest.TestCase): end_time: float = datetime.datetime.now().timestamp() + 20, severity: SeverityEnum = SeverityEnum.alert, has_been_reviewed: bool = False, + data: Json = {}, ) -> Event: """Inserts a review segment model with a given id.""" return ReviewSegment.insert( @@ -160,7 +169,7 @@ class BaseTestHttp(unittest.TestCase): has_been_reviewed=has_been_reviewed, severity=severity, thumb_path=False, - data={}, + data=data, ).execute() def insert_mock_recording( @@ -168,6 +177,7 @@ class BaseTestHttp(unittest.TestCase): id: str, start_time: float = datetime.datetime.now().timestamp(), end_time: float = datetime.datetime.now().timestamp() + 20, + motion: int = 0, ) -> Event: """Inserts a recording model with a given id.""" return Recordings.insert( @@ -177,4 +187,5 @@ class BaseTestHttp(unittest.TestCase): start_time=start_time, end_time=end_time, duration=end_time - start_time, + motion=motion, ).execute() diff --git a/frigate/test/http_api/test_http_app.py b/frigate/test/http_api/test_http_app.py new file mode 100644 index 000000000..e7785a9d7 --- /dev/null +++ b/frigate/test/http_api/test_http_app.py @@ -0,0 +1,26 @@ +from unittest.mock import Mock + +from fastapi.testclient import TestClient + +from frigate.models import Event, Recordings, ReviewSegment +from frigate.stats.emitter import StatsEmitter +from frigate.test.http_api.base_http_test import BaseTestHttp + + +class TestHttpApp(BaseTestHttp): + def setUp(self): + super().setUp([Event, Recordings, ReviewSegment]) + self.app = super().create_app() + + #################################################################################################################### + ################################### GET /stats Endpoint ######################################################### + #################################################################################################################### + def test_stats_endpoint(self): + stats = Mock(spec=StatsEmitter) + stats.get_latest_stats.return_value = self.test_stats + app = super().create_app(stats) + + with TestClient(app) as client: + response = client.get("/stats") + response_json = response.json() + assert response_json == self.test_stats diff --git a/frigate/test/http_api/test_http_event.py b/frigate/test/http_api/test_http_event.py new file mode 100644 index 000000000..e3f41fdc3 --- /dev/null +++ b/frigate/test/http_api/test_http_event.py @@ -0,0 +1,137 @@ +from datetime import datetime + +from fastapi.testclient import TestClient + +from frigate.models import Event, Recordings, ReviewSegment +from frigate.test.http_api.base_http_test import BaseTestHttp + + +class TestHttpApp(BaseTestHttp): + def setUp(self): + super().setUp([Event, Recordings, ReviewSegment]) + self.app = super().create_app() + + #################################################################################################################### + ################################### GET /events Endpoint ######################################################### + #################################################################################################################### + def test_get_event_list_no_events(self): + with TestClient(self.app) as client: + events = client.get("/events").json() + assert len(events) == 0 + + def test_get_event_list_no_match_event_id(self): + id = "123456.random" + with TestClient(self.app) as client: + super().insert_mock_event(id) + events = client.get("/events", params={"event_id": "abc"}).json() + assert len(events) == 0 + + def test_get_event_list_match_event_id(self): + id = "123456.random" + with TestClient(self.app) as client: + super().insert_mock_event(id) + events = client.get("/events", params={"event_id": id}).json() + assert len(events) == 1 + assert events[0]["id"] == id + + def test_get_event_list_match_length(self): + now = int(datetime.now().timestamp()) + + id = "123456.random" + with TestClient(self.app) as client: + super().insert_mock_event(id, now, now + 1) + events = client.get( + "/events", params={"max_length": 1, "min_length": 1} + ).json() + assert len(events) == 1 + assert events[0]["id"] == id + + def test_get_event_list_no_match_max_length(self): + now = int(datetime.now().timestamp()) + + with TestClient(self.app) as client: + id = "123456.random" + super().insert_mock_event(id, now, now + 2) + events = client.get("/events", params={"max_length": 1}).json() + assert len(events) == 0 + + def test_get_event_list_no_match_min_length(self): + now = int(datetime.now().timestamp()) + + with TestClient(self.app) as client: + id = "123456.random" + super().insert_mock_event(id, now, now + 2) + events = client.get("/events", params={"min_length": 3}).json() + assert len(events) == 0 + + def test_get_event_list_limit(self): + id = "123456.random" + id2 = "54321.random" + + with TestClient(self.app) as client: + super().insert_mock_event(id) + events = client.get("/events").json() + assert len(events) == 1 + assert events[0]["id"] == id + + super().insert_mock_event(id2) + events = client.get("/events").json() + assert len(events) == 2 + + events = client.get("/events", params={"limit": 1}).json() + assert len(events) == 1 + assert events[0]["id"] == id + + events = client.get("/events", params={"limit": 3}).json() + assert len(events) == 2 + + def test_get_event_list_no_match_has_clip(self): + now = int(datetime.now().timestamp()) + + with TestClient(self.app) as client: + id = "123456.random" + super().insert_mock_event(id, now, now + 2) + events = client.get("/events", params={"has_clip": 0}).json() + assert len(events) == 0 + + def test_get_event_list_has_clip(self): + with TestClient(self.app) as client: + id = "123456.random" + super().insert_mock_event(id, has_clip=True) + events = client.get("/events", params={"has_clip": 1}).json() + assert len(events) == 1 + assert events[0]["id"] == id + + def test_get_event_list_sort_score(self): + with TestClient(self.app) as client: + id = "123456.random" + id2 = "54321.random" + super().insert_mock_event(id, top_score=37, score=37, data={"score": 50}) + super().insert_mock_event(id2, top_score=47, score=47, data={"score": 20}) + events = client.get("/events", params={"sort": "score_asc"}).json() + assert len(events) == 2 + assert events[0]["id"] == id2 + assert events[1]["id"] == id + + events = client.get("/events", params={"sort": "score_des"}).json() + assert len(events) == 2 + assert events[0]["id"] == id + assert events[1]["id"] == id2 + + def test_get_event_list_sort_start_time(self): + now = int(datetime.now().timestamp()) + + with TestClient(self.app) as client: + id = "123456.random" + id2 = "54321.random" + super().insert_mock_event(id, start_time=now + 3) + super().insert_mock_event(id2, start_time=now) + events = client.get("/events", params={"sort": "date_asc"}).json() + assert len(events) == 2 + assert events[0]["id"] == id2 + assert events[1]["id"] == id + + events = client.get("/events", params={"sort": "date_desc"}).json() + assert len(events) == 2 + assert events[0]["id"] == id + assert events[1]["id"] == id2 diff --git a/frigate/test/http_api/test_http_review.py b/frigate/test/http_api/test_http_review.py index 11bd33495..c8f2b1719 100644 --- a/frigate/test/http_api/test_http_review.py +++ b/frigate/test/http_api/test_http_review.py @@ -569,3 +569,177 @@ class TestHttpReview(BaseTestHttp): recording_ids_in_db_after = self._get_recordings(ids) assert len(review_ids_in_db_after) == 0 assert len(recording_ids_in_db_after) == 0 + + #################################################################################################################### + ################################### GET /review/activity/motion Endpoint ######################################## + #################################################################################################################### + def test_review_activity_motion_no_data_for_time_range(self): + now = datetime.now().timestamp() + + with TestClient(self.app) as client: + params = { + "after": now, + "before": now + 3, + } + response = client.get("/review/activity/motion", params=params) + assert response.status_code == 200 + response_json = response.json() + assert len(response_json) == 0 + + def test_review_activity_motion(self): + now = int(datetime.now().timestamp()) + + with TestClient(self.app) as client: + one_m = int((datetime.now() + timedelta(minutes=1)).timestamp()) + id = "123456.random" + id2 = "123451.random" + super().insert_mock_recording(id, now + 1, now + 2, motion=101) + super().insert_mock_recording(id2, one_m + 1, one_m + 2, motion=200) + params = { + "after": now, + "before": one_m + 3, + "scale": 1, + } + response = client.get("/review/activity/motion", params=params) + assert response.status_code == 200 + response_json = response.json() + assert len(response_json) == 61 + self.assertDictEqual( + {"motion": 50.5, "camera": "front_door", "start_time": now + 1}, + response_json[0], + ) + for item in response_json[1:-1]: + self.assertDictEqual( + {"motion": 0.0, "camera": "", "start_time": item["start_time"]}, + item, + ) + self.assertDictEqual( + {"motion": 100.0, "camera": "front_door", "start_time": one_m + 1}, + response_json[len(response_json) - 1], + ) + + #################################################################################################################### + ################################### GET /review/event/{event_id} Endpoint ####################################### + #################################################################################################################### + def test_review_event_not_found(self): + with TestClient(self.app) as client: + response = client.get("/review/event/123456.random") + assert response.status_code == 404 + response_json = response.json() + self.assertDictEqual( + {"success": False, "message": "Review item not found"}, + response_json, + ) + + def test_review_event_not_found_in_data(self): + now = datetime.now().timestamp() + + with TestClient(self.app) as client: + id = "123456.random" + super().insert_mock_review_segment(id, now + 1, now + 2) + response = client.get(f"/review/event/{id}") + assert response.status_code == 404 + response_json = response.json() + self.assertDictEqual( + {"success": False, "message": "Review item not found"}, + response_json, + ) + + def test_review_get_specific_event(self): + now = datetime.now().timestamp() + + with TestClient(self.app) as client: + event_id = "123456.event.random" + super().insert_mock_event(event_id) + review_id = "123456.review.random" + super().insert_mock_review_segment( + review_id, now + 1, now + 2, data={"detections": {"event_id": event_id}} + ) + response = client.get(f"/review/event/{event_id}") + assert response.status_code == 200 + response_json = response.json() + self.assertDictEqual( + { + "id": review_id, + "camera": "front_door", + "start_time": now + 1, + "end_time": now + 2, + "has_been_reviewed": False, + "severity": SeverityEnum.alert, + "thumb_path": "False", + "data": {"detections": {"event_id": event_id}}, + }, + response_json, + ) + + #################################################################################################################### + ################################### GET /review/{review_id} Endpoint ####################################### + #################################################################################################################### + def test_review_not_found(self): + with TestClient(self.app) as client: + response = client.get("/review/123456.random") + assert response.status_code == 404 + response_json = response.json() + self.assertDictEqual( + {"success": False, "message": "Review item not found"}, + response_json, + ) + + def test_get_review(self): + now = datetime.now().timestamp() + + with TestClient(self.app) as client: + review_id = "123456.review.random" + super().insert_mock_review_segment(review_id, now + 1, now + 2) + response = client.get(f"/review/{review_id}") + assert response.status_code == 200 + response_json = response.json() + self.assertDictEqual( + { + "id": review_id, + "camera": "front_door", + "start_time": now + 1, + "end_time": now + 2, + "has_been_reviewed": False, + "severity": SeverityEnum.alert, + "thumb_path": "False", + "data": {}, + }, + response_json, + ) + + #################################################################################################################### + ################################### DELETE /review/{review_id}/viewed Endpoint ################################## + #################################################################################################################### + def test_delete_review_viewed_review_not_found(self): + with TestClient(self.app) as client: + review_id = "123456.random" + response = client.delete(f"/review/{review_id}/viewed") + assert response.status_code == 404 + response_json = response.json() + self.assertDictEqual( + {"success": False, "message": f"Review {review_id} not found"}, + response_json, + ) + + def test_delete_review_viewed(self): + now = datetime.now().timestamp() + + with TestClient(self.app) as client: + review_id = "123456.review.random" + super().insert_mock_review_segment( + review_id, now + 1, now + 2, has_been_reviewed=True + ) + review_before = ReviewSegment.get(ReviewSegment.id == review_id) + assert review_before.has_been_reviewed == True + + response = client.delete(f"/review/{review_id}/viewed") + assert response.status_code == 200 + response_json = response.json() + self.assertDictEqual( + {"success": True, "message": f"Set Review {review_id} as not viewed"}, + response_json, + ) + + review_after = ReviewSegment.get(ReviewSegment.id == review_id) + assert review_after.has_been_reviewed == False diff --git a/frigate/test/test_gpu_stats.py b/frigate/test/test_gpu_stats.py index 7c1bc4618..fd0df94c4 100644 --- a/frigate/test/test_gpu_stats.py +++ b/frigate/test/test_gpu_stats.py @@ -38,7 +38,7 @@ class TestGpuStats(unittest.TestCase): process.returncode = 124 process.stdout = self.intel_results sp.return_value = process - intel_stats = get_intel_gpu_stats() + intel_stats = get_intel_gpu_stats(False) print(f"the intel stats are {intel_stats}") assert intel_stats == { "gpu": "1.13%", diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index 213794259..8c89e0433 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -2,7 +2,6 @@ import datetime import logging import os import unittest -from unittest.mock import Mock from fastapi.testclient import TestClient from peewee_migrate import Router @@ -13,7 +12,6 @@ from playhouse.sqliteq import SqliteQueueDatabase from frigate.api.fastapi_app import create_fastapi_app from frigate.config import FrigateConfig from frigate.models import Event, Recordings, Timeline -from frigate.stats.emitter import StatsEmitter from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS @@ -111,43 +109,6 @@ class TestHttp(unittest.TestCase): except OSError: pass - def test_get_event_list(self): - app = create_fastapi_app( - FrigateConfig(**self.minimal_config), - self.db, - None, - None, - None, - None, - None, - None, - None, - ) - id = "123456.random" - id2 = "7890.random" - - with TestClient(app) as client: - _insert_mock_event(id) - events = client.get("/events").json() - assert events - assert len(events) == 1 - assert events[0]["id"] == id - _insert_mock_event(id2) - events = client.get("/events").json() - assert events - assert len(events) == 2 - events = client.get( - "/events", - params={"limit": 1}, - ).json() - assert events - assert len(events) == 1 - events = client.get( - "/events", - params={"has_clip": 0}, - ).json() - assert not events - def test_get_good_event(self): app = create_fastapi_app( FrigateConfig(**self.minimal_config), @@ -381,25 +342,6 @@ class TestHttp(unittest.TestCase): assert recording assert recording[0]["id"] == id - def test_stats(self): - stats = Mock(spec=StatsEmitter) - stats.get_latest_stats.return_value = self.test_stats - app = create_fastapi_app( - FrigateConfig(**self.minimal_config), - self.db, - None, - None, - None, - None, - None, - stats, - None, - ) - - with TestClient(app) as client: - full_stats = client.get("/stats").json() - assert full_stats == self.test_stats - def _insert_mock_event( id: str, diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index 3280965da..ea1aeedcb 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -12,6 +12,7 @@ import numpy as np from frigate.config import ( CameraConfig, ModelConfig, + UIConfig, ) from frigate.review.types import SeverityEnum from frigate.util.image import ( @@ -22,6 +23,7 @@ from frigate.util.image import ( is_better_thumbnail, ) from frigate.util.object import box_inside +from frigate.util.velocity import calculate_real_world_speed logger = logging.getLogger(__name__) @@ -31,6 +33,7 @@ class TrackedObject: self, model_config: ModelConfig, camera_config: CameraConfig, + ui_config: UIConfig, frame_cache, obj_data: dict[str, any], ): @@ -42,6 +45,7 @@ class TrackedObject: self.colormap = model_config.colormap self.logos = model_config.all_attribute_logos self.camera_config = camera_config + self.ui_config = ui_config self.frame_cache = frame_cache self.zone_presence: dict[str, int] = {} self.zone_loitering: dict[str, int] = {} @@ -58,6 +62,10 @@ class TrackedObject: self.frame = None self.active = True self.pending_loitering = False + self.speed_history = [] + self.current_estimated_speed = 0 + self.average_estimated_speed = 0 + self.velocity_angle = 0 self.previous = self.to_dict() @property @@ -129,6 +137,8 @@ class TrackedObject: "region": obj_data["region"], "score": obj_data["score"], "attributes": obj_data["attributes"], + "current_estimated_speed": self.current_estimated_speed, + "velocity_angle": self.velocity_angle, } thumb_update = True @@ -136,6 +146,7 @@ class TrackedObject: current_zones = [] bottom_center = (obj_data["centroid"][0], obj_data["box"][3]) in_loitering_zone = False + in_speed_zone = False # check each zone for name, zone in self.camera_config.zones.items(): @@ -144,12 +155,66 @@ class TrackedObject: continue contour = zone.contour zone_score = self.zone_presence.get(name, 0) + 1 + # check if the object is in the zone if cv2.pointPolygonTest(contour, bottom_center, False) >= 0: # if the object passed the filters once, dont apply again if name in self.current_zones or not zone_filtered(self, zone.filters): - # an object is only considered present in a zone if it has a zone inertia of 3+ + # Calculate speed first if this is a speed zone + if ( + zone.distances + and obj_data["frame_time"] == current_frame_time + and self.active + ): + speed_magnitude, self.velocity_angle = ( + calculate_real_world_speed( + zone.contour, + zone.distances, + self.obj_data["estimate_velocity"], + bottom_center, + self.camera_config.detect.fps, + ) + ) + + if self.ui_config.unit_system == "metric": + self.current_estimated_speed = ( + speed_magnitude * 3.6 + ) # m/s to km/h + else: + self.current_estimated_speed = ( + speed_magnitude * 0.681818 + ) # ft/s to mph + + self.speed_history.append(self.current_estimated_speed) + if len(self.speed_history) > 10: + self.speed_history = self.speed_history[-10:] + + self.average_estimated_speed = sum(self.speed_history) / len( + self.speed_history + ) + + # we've exceeded the speed threshold on the zone + # or we don't have a speed threshold set + if ( + zone.speed_threshold is None + or self.average_estimated_speed > zone.speed_threshold + ): + in_speed_zone = True + + logger.debug( + f"Camera: {self.camera_config.name}, tracked object ID: {self.obj_data['id']}, " + f"zone: {name}, pixel velocity: {str(tuple(np.round(self.obj_data['estimate_velocity']).flatten().astype(int)))}, " + f"speed magnitude: {speed_magnitude}, velocity angle: {self.velocity_angle}, " + f"estimated speed: {self.current_estimated_speed:.1f}, " + f"average speed: {self.average_estimated_speed:.1f}, " + f"length: {len(self.speed_history)}" + ) + + # Check zone entry conditions - for speed zones, require both inertia and speed if zone_score >= zone.inertia: + if zone.distances and not in_speed_zone: + continue # Skip zone entry for speed zones until speed threshold met + # if the zone has loitering time, update loitering status if zone.loitering_time > 0: in_loitering_zone = True @@ -174,6 +239,10 @@ class TrackedObject: if 0 < zone_score < zone.inertia: self.zone_presence[name] = zone_score - 1 + # Reset speed if not in speed zone + if zone.distances and name not in current_zones: + self.current_estimated_speed = 0 + # update loitering status self.pending_loitering = in_loitering_zone @@ -255,6 +324,9 @@ class TrackedObject: "current_attributes": self.obj_data["attributes"], "pending_loitering": self.pending_loitering, "max_severity": self.max_severity, + "current_estimated_speed": self.current_estimated_speed, + "average_estimated_speed": self.average_estimated_speed, + "velocity_angle": self.velocity_angle, } if include_thumbnail: @@ -339,7 +411,12 @@ class TrackedObject: box[2], box[3], self.obj_data["label"], - f"{int(self.thumbnail_data['score']*100)}% {int(self.thumbnail_data['area'])}", + f"{int(self.thumbnail_data['score'] * 100)}% {int(self.thumbnail_data['area'])}" + + ( + f" {self.thumbnail_data['current_estimated_speed']:.1f}" + if self.thumbnail_data["current_estimated_speed"] != 0 + else "" + ), thickness=thickness, color=color, ) diff --git a/frigate/types.py b/frigate/types.py index 11ab31238..4d3fe96b3 100644 --- a/frigate/types.py +++ b/frigate/types.py @@ -2,11 +2,13 @@ from enum import Enum from typing import TypedDict from frigate.camera import CameraMetrics +from frigate.data_processing.types import DataProcessorMetrics from frigate.object_detection import ObjectDetectProcess class StatsTrackingTypes(TypedDict): camera_metrics: dict[str, CameraMetrics] + embeddings_metrics: DataProcessorMetrics | None detectors: dict[str, ObjectDetectProcess] started: int latest_frigate_version: str diff --git a/frigate/util/config.py b/frigate/util/config.py index bb050b5b6..5b40fe37b 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -13,7 +13,7 @@ from frigate.util.services import get_video_properties logger = logging.getLogger(__name__) -CURRENT_CONFIG_VERSION = "0.15-1" +CURRENT_CONFIG_VERSION = "0.16-0" DEFAULT_CONFIG_FILE = "/config/config.yml" @@ -84,6 +84,13 @@ def migrate_frigate_config(config_file: str): yaml.dump(new_config, f) previous_version = "0.15-1" + if previous_version < "0.16-0": + logger.info(f"Migrating frigate config from {previous_version} to 0.16-0...") + new_config = migrate_016_0(config) + with open(config_file, "w") as f: + yaml.dump(new_config, f) + previous_version = "0.16-0" + logger.info("Finished frigate config migration...") @@ -289,6 +296,29 @@ def migrate_015_1(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any] return new_config +def migrate_016_0(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]: + """Handle migrating frigate config to 0.16-0""" + new_config = config.copy() + + for name, camera in config.get("cameras", {}).items(): + camera_config: dict[str, dict[str, any]] = camera.copy() + + live_config = camera_config.get("live", {}) + if "stream_name" in live_config: + # Migrate from live -> stream_name to live -> streams -> dict + stream_name = live_config["stream_name"] + live_config["streams"] = {stream_name: stream_name} + + del live_config["stream_name"] + + camera_config["live"] = live_config + + new_config["cameras"][name] = camera_config + + new_config["version"] = "0.16-0" + return new_config + + def get_relative_coordinates( mask: Optional[Union[str, list]], frame_shape: tuple[int, int] ) -> Union[str, list]: @@ -314,7 +344,7 @@ def get_relative_coordinates( continue rel_points.append( - f"{round(x / frame_shape[1], 3)},{round(y / frame_shape[0], 3)}" + f"{round(x / frame_shape[1], 3)},{round(y / frame_shape[0], 3)}" ) relative_masks.append(",".join(rel_points)) @@ -337,7 +367,7 @@ def get_relative_coordinates( return [] rel_points.append( - f"{round(x / frame_shape[1], 3)},{round(y / frame_shape[0], 3)}" + f"{round(x / frame_shape[1], 3)},{round(y / frame_shape[0], 3)}" ) mask = ",".join(rel_points) @@ -347,6 +377,36 @@ def get_relative_coordinates( return mask +def convert_area_to_pixels( + area_value: Union[int, float], frame_shape: tuple[int, int] +) -> int: + """ + Convert area specification to pixels. + + Args: + area_value: Area value (pixels or percentage) + frame_shape: Tuple of (height, width) for the frame + + Returns: + Area in pixels + """ + # If already an integer, assume it's in pixels + if isinstance(area_value, int): + return area_value + + # Check if it's a percentage + if isinstance(area_value, float): + if 0.000001 <= area_value <= 0.99: + frame_area = frame_shape[0] * frame_shape[1] + return max(1, int(frame_area * area_value)) + else: + raise ValueError( + f"Percentage must be between 0.000001 and 0.99, got {area_value}" + ) + + raise TypeError(f"Unexpected type for area: {type(area_value)}") + + class StreamInfoRetriever: def __init__(self) -> None: self.stream_cache: dict[str, tuple[int, int]] = {} diff --git a/frigate/util/downloader.py b/frigate/util/downloader.py index 6685b0bb8..49b05dd05 100644 --- a/frigate/util/downloader.py +++ b/frigate/util/downloader.py @@ -51,12 +51,14 @@ class ModelDownloader: download_path: str, file_names: List[str], download_func: Callable[[str], None], + complete_func: Callable[[], None] | None = None, silent: bool = False, ): self.model_name = model_name self.download_path = download_path self.file_names = file_names self.download_func = download_func + self.complete_func = complete_func self.silent = silent self.requestor = InterProcessRequestor() self.download_thread = None @@ -97,11 +99,14 @@ class ModelDownloader: }, ) + if self.complete_func: + self.complete_func() + self.requestor.stop() self.download_complete.set() @staticmethod - def download_from_url(url: str, save_path: str, silent: bool = False): + def download_from_url(url: str, save_path: str, silent: bool = False) -> Path: temporary_filename = Path(save_path).with_name( os.path.basename(save_path) + ".part" ) @@ -125,6 +130,8 @@ class ModelDownloader: if not silent: logger.info(f"Downloading complete: {url}") + return Path(save_path) + @staticmethod def mark_files_state( requestor: InterProcessRequestor, diff --git a/frigate/util/model.py b/frigate/util/model.py index ce2c9538c..75b545cfb 100644 --- a/frigate/util/model.py +++ b/frigate/util/model.py @@ -4,6 +4,8 @@ import logging import os from typing import Any +import cv2 +import numpy as np import onnxruntime as ort try: @@ -14,6 +16,43 @@ except ImportError: logger = logging.getLogger(__name__) +### Post Processing + + +def post_process_yolov9(predictions: np.ndarray, width, height) -> np.ndarray: + predictions = np.squeeze(predictions).T + scores = np.max(predictions[:, 4:], axis=1) + predictions = predictions[scores > 0.4, :] + scores = scores[scores > 0.4] + class_ids = np.argmax(predictions[:, 4:], axis=1) + + # Rescale box + boxes = predictions[:, :4] + + input_shape = np.array([width, height, width, height]) + boxes = np.divide(boxes, input_shape, dtype=np.float32) + indices = cv2.dnn.NMSBoxes(boxes, scores, score_threshold=0.4, nms_threshold=0.4) + detections = np.zeros((20, 6), np.float32) + for i, (bbox, confidence, class_id) in enumerate( + zip(boxes[indices], scores[indices], class_ids[indices]) + ): + if i == 20: + break + + detections[i] = [ + class_id, + confidence, + bbox[1] - bbox[3] / 2, + bbox[0] - bbox[2] / 2, + bbox[1] + bbox[3] / 2, + bbox[0] + bbox[2] / 2, + ] + + return detections + + +### ONNX Utilities + def get_ort_providers( force_cpu: bool = False, device: str = "AUTO", requires_fp16: bool = False diff --git a/frigate/util/services.py b/frigate/util/services.py index 2fd701298..d7966bd00 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -8,7 +8,8 @@ import re import signal import subprocess as sp import traceback -from typing import Optional +from datetime import datetime +from typing import List, Optional, Tuple import cv2 import psutil @@ -255,7 +256,7 @@ def get_amd_gpu_stats() -> dict[str, str]: return results -def get_intel_gpu_stats() -> dict[str, str]: +def get_intel_gpu_stats(sriov: bool) -> dict[str, str]: """Get stats using intel_gpu_top.""" def get_stats_manually(output: str) -> dict[str, str]: @@ -302,6 +303,9 @@ def get_intel_gpu_stats() -> dict[str, str]: "1", ] + if sriov: + intel_gpu_top_command += ["-d", "drm:/dev/dri/card0"] + p = sp.run( intel_gpu_top_command, encoding="ascii", @@ -632,3 +636,54 @@ async def get_video_properties( result["fourcc"] = fourcc return result + + +def process_logs( + contents: str, + service: Optional[str] = None, + start: Optional[int] = None, + end: Optional[int] = None, +) -> Tuple[int, List[str]]: + log_lines = [] + last_message = None + last_timestamp = None + repeat_count = 0 + + for raw_line in contents.splitlines(): + clean_line = raw_line.strip() + + if len(clean_line) < 10: + continue + + # Handle cases where S6 does not include date in log line + if " " not in clean_line: + clean_line = f"{datetime.now()} {clean_line}" + + # Find the position of the first double space to extract timestamp and message + date_end = clean_line.index(" ") + timestamp = clean_line[:date_end] + message_part = clean_line[date_end:].strip() + + if message_part == last_message: + repeat_count += 1 + continue + else: + if repeat_count > 0: + # Insert a deduplication message formatted the same way as logs + dedup_message = f"{last_timestamp} [LOGGING] Last message repeated {repeat_count} times" + log_lines.append(dedup_message) + repeat_count = 0 + + log_lines.append(clean_line) + last_timestamp = timestamp + + last_message = message_part + + # If there were repeated messages at the end, log the count + if repeat_count > 0: + dedup_message = ( + f"{last_timestamp} [LOGGING] Last message repeated {repeat_count} times" + ) + log_lines.append(dedup_message) + + return len(log_lines), log_lines[start:end] diff --git a/frigate/util/velocity.py b/frigate/util/velocity.py new file mode 100644 index 000000000..207215bfb --- /dev/null +++ b/frigate/util/velocity.py @@ -0,0 +1,127 @@ +import math + +import numpy as np + + +def order_points_clockwise(points): + """ + Ensure points are sorted in clockwise order starting from the top left + + :param points: Array of zone corner points in pixel coordinates + :return: Ordered list of points + """ + top_left = min( + points, key=lambda p: (p[1], p[0]) + ) # Find the top-left point (min y, then x) + + # Remove the top-left point from the list of points + remaining_points = [p for p in points if not np.array_equal(p, top_left)] + + # Sort the remaining points based on the angle relative to the top-left point + def angle_from_top_left(point): + x, y = point[0] - top_left[0], point[1] - top_left[1] + return math.atan2(y, x) + + sorted_points = sorted(remaining_points, key=angle_from_top_left) + + return [top_left] + sorted_points + + +def create_ground_plane(zone_points, distances): + """ + Create a ground plane that accounts for perspective distortion using real-world dimensions for each side of the zone. + + :param zone_points: Array of zone corner points in pixel coordinates + [[x1, y1], [x2, y2], [x3, y3], [x4, y4]] + :param distances: Real-world dimensions ordered by A, B, C, D + :return: Function that calculates real-world distance per pixel at any coordinate + """ + A, B, C, D = zone_points + + # Calculate pixel lengths of each side + AB_px = np.linalg.norm(np.array(B) - np.array(A)) + BC_px = np.linalg.norm(np.array(C) - np.array(B)) + CD_px = np.linalg.norm(np.array(D) - np.array(C)) + DA_px = np.linalg.norm(np.array(A) - np.array(D)) + + AB, BC, CD, DA = map(float, distances) + + AB_scale = AB / AB_px + BC_scale = BC / BC_px + CD_scale = CD / CD_px + DA_scale = DA / DA_px + + def distance_per_pixel(x, y): + """ + Calculate the real-world distance per pixel at a given (x, y) coordinate. + + :param x: X-coordinate in the image + :param y: Y-coordinate in the image + :return: Real-world distance per pixel at the given (x, y) coordinate + """ + # Normalize x and y within the zone + x_norm = (x - A[0]) / (B[0] - A[0]) + y_norm = (y - A[1]) / (D[1] - A[1]) + + # Interpolate scales horizontally and vertically + vertical_scale = AB_scale + (CD_scale - AB_scale) * y_norm + horizontal_scale = DA_scale + (BC_scale - DA_scale) * x_norm + + # Combine horizontal and vertical scales + return (vertical_scale + horizontal_scale) / 2 + + return distance_per_pixel + + +def calculate_real_world_speed( + zone_contour, + distances, + velocity_pixels, + position, + camera_fps, +): + """ + Calculate the real-world speed of a tracked object, accounting for perspective, + directly from the zone string. + + :param zone_contour: Array of absolute zone points + :param distances: List of distances of each side, ordered by A, B, C, D + :param velocity_pixels: List of tuples representing velocity in pixels/frame + :param position: Current position of the object (x, y) in pixels + :param camera_fps: Frames per second of the camera + :return: speed and velocity angle direction + """ + # order the zone_contour points clockwise starting at top left + ordered_zone_contour = order_points_clockwise(zone_contour) + + # find the indices that would sort the original zone_contour to match ordered_zone_contour + sort_indices = [ + np.where((zone_contour == point).all(axis=1))[0][0] + for point in ordered_zone_contour + ] + + # Reorder distances to match the new order of zone_contour + distances = np.array(distances) + ordered_distances = distances[sort_indices] + + ground_plane = create_ground_plane(ordered_zone_contour, ordered_distances) + + if not isinstance(velocity_pixels, np.ndarray): + velocity_pixels = np.array(velocity_pixels) + + avg_velocity_pixels = velocity_pixels.mean(axis=0) + + # get the real-world distance per pixel at the object's current position and calculate real speed + scale = ground_plane(position[0], position[1]) + speed_real = avg_velocity_pixels * scale * camera_fps + + # euclidean speed in real-world units/second + speed_magnitude = np.linalg.norm(speed_real) + + # movement direction + dx, dy = avg_velocity_pixels + angle = math.degrees(math.atan2(dy, dx)) + if angle < 0: + angle += 360 + + return speed_magnitude, angle diff --git a/frigate/video.py b/frigate/video.py index 3632a87e9..cb922500d 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -481,7 +481,7 @@ def detect( detect_config: DetectConfig, object_detector, frame, - model_config, + model_config: ModelConfig, region, objects_to_track, object_filters, diff --git a/notebooks/YOLO_NAS_Pretrained_Export.ipynb b/notebooks/YOLO_NAS_Pretrained_Export.ipynb index a3c303c01..e4e2222da 100644 --- a/notebooks/YOLO_NAS_Pretrained_Export.ipynb +++ b/notebooks/YOLO_NAS_Pretrained_Export.ipynb @@ -11,6 +11,18 @@ "! pip install -q super_gradients==3.7.1" ] }, + { + "cell_type": "code", + "source": [ + "! sed -i 's/sghub.deci.ai/sg-hub-nv.s3.amazonaws.com/' /usr/local/lib/python3.10/dist-packages/super_gradients/training/pretrained_models.py\n", + "! sed -i 's/sghub.deci.ai/sg-hub-nv.s3.amazonaws.com/' /usr/local/lib/python3.10/dist-packages/super_gradients/training/utils/checkpoint_utils.py" + ], + "metadata": { + "id": "NiRCt917KKcL" + }, + "execution_count": null, + "outputs": [] + }, { "cell_type": "code", "execution_count": null, @@ -72,4 +84,4 @@ }, "nbformat": 4, "nbformat_minor": 0 -} +} \ No newline at end of file diff --git a/process_clip.py b/process_clip.py index 54bbf0c1e..7ef9f4c75 100644 --- a/process_clip.py +++ b/process_clip.py @@ -208,7 +208,7 @@ class ProcessClip: box[2], box[3], obj["id"], - f"{int(obj['score']*100)}% {int(obj['area'])}", + f"{int(obj['score'] * 100)}% {int(obj['area'])}", thickness=thickness, color=color, ) @@ -227,7 +227,7 @@ class ProcessClip: ) cv2.imwrite( - f"{os.path.join(debug_path, os.path.basename(self.clip_path))}.{int(frame_time*1000000)}.jpg", + f"{os.path.join(debug_path, os.path.basename(self.clip_path))}.{int(frame_time * 1000000)}.jpg", current_frame, ) @@ -290,7 +290,7 @@ def process(path, label, output, debug_path): 1 for result in results if result[1]["true_positive_objects"] > 0 ) print( - f"Objects were detected in {positive_count}/{len(results)}({positive_count/len(results)*100:.2f}%) clip(s)." + f"Objects were detected in {positive_count}/{len(results)}({positive_count / len(results) * 100:.2f}%) clip(s)." ) if output: diff --git a/web/package-lock.json b/web/package-lock.json index 7ce6345af..119fc79ea 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@cycjimmy/jsmpeg-player": "^6.1.1", "@hookform/resolvers": "^3.9.0", + "@melloware/react-logviewer": "^6.1.2", "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-aspect-ratio": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.2", @@ -53,7 +54,7 @@ "react-day-picker": "^8.10.1", "react-device-detect": "^2.2.3", "react-dom": "^18.3.1", - "react-grid-layout": "^1.4.4", + "react-grid-layout": "^1.5.0", "react-hook-form": "^7.52.1", "react-icons": "^5.2.1", "react-konva": "^18.2.10", @@ -1002,6 +1003,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@melloware/react-logviewer": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@melloware/react-logviewer/-/react-logviewer-6.1.2.tgz", + "integrity": "sha512-WDw3VIGqhoXxDn93HFDicwRhi4+FQyaKiVTB07bWerT82gTgyWV7bOciVV33z25N3WJrz62j5FKVzvFZCu17/A==", + "license": "MPL-2.0", + "dependencies": { + "hotkeys-js": "3.13.9", + "mitt": "3.0.1", + "react-string-replace": "1.1.1", + "virtua": "0.39.3" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.29.1", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.29.1.tgz", @@ -5103,7 +5120,8 @@ "node_modules/fast-equals": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", - "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==" + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.2", @@ -5511,6 +5529,15 @@ "integrity": "sha512-wA66nnYFvQa1o4DO/BFgLNRKnBTVXpNeldGRBJ2Y0SvFtdwvFKCbqa9zhHoZLoxHhZ+jYsj3aIBkWQQCPNOhMw==", "license": "Apache-2.0" }, + "node_modules/hotkeys-js": { + "version": "3.13.9", + "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.13.9.tgz", + "integrity": "sha512-3TRCj9u9KUH6cKo25w4KIdBfdBfNRjfUwrljCLDC2XhmPDG0SjAZFcFZekpUZFmXzfYoGhFDcdx2gX/vUVtztQ==", + "license": "MIT", + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -6273,6 +6300,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mock-socket": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz", @@ -7243,9 +7276,10 @@ } }, "node_modules/react-grid-layout": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.4.4.tgz", - "integrity": "sha512-7+Lg8E8O8HfOH5FrY80GCIR1SHTn2QnAYKh27/5spoz+OHhMmEhU/14gIkRzJOtympDPaXcVRX/nT1FjmeOUmQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.0.tgz", + "integrity": "sha512-WBKX7w/LsTfI99WskSu6nX2nbJAUD7GD6nIXcwYLyPpnslojtmql2oD3I2g5C3AK8hrxIarYT8awhuDIp7iQ5w==", + "license": "MIT", "dependencies": { "clsx": "^2.0.0", "fast-equals": "^4.0.3", @@ -7425,6 +7459,15 @@ "react-dom": ">=16.8" } }, + "node_modules/react-string-replace": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/react-string-replace/-/react-string-replace-1.1.1.tgz", + "integrity": "sha512-26TUbLzLfHQ5jO5N7y3Mx88eeKo0Ml0UjCQuX4BMfOd/JX+enQqlKpL1CZnmjeBRvQE8TR+ds9j1rqx9CxhKHQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", @@ -7583,7 +7626,8 @@ "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", - "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" }, "node_modules/resolve": { "version": "1.22.8", @@ -8766,6 +8810,36 @@ "react-dom": "^16.8 || ^17.0 || ^18.0" } }, + "node_modules/virtua": { + "version": "0.39.3", + "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.39.3.tgz", + "integrity": "sha512-Ep3aiJXSGPm1UUniThr5mGDfG0upAleP7pqQs5mvvCgM1wPhII1ZKa7eNCWAJRLkC+InpXKokKozyaaj/aMYOQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0", + "solid-js": ">=1.0", + "svelte": ">=5.0", + "vue": ">=3.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/vite": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz", diff --git a/web/package.json b/web/package.json index d76e6ad10..d0bdd01d4 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "dependencies": { "@cycjimmy/jsmpeg-player": "^6.1.1", "@hookform/resolvers": "^3.9.0", + "@melloware/react-logviewer": "^6.1.2", "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-aspect-ratio": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.2", @@ -59,7 +60,7 @@ "react-day-picker": "^8.10.1", "react-device-detect": "^2.2.3", "react-dom": "^18.3.1", - "react-grid-layout": "^1.4.4", + "react-grid-layout": "^1.5.0", "react-hook-form": "^7.52.1", "react-icons": "^5.2.1", "react-konva": "^18.2.10", diff --git a/web/src/App.tsx b/web/src/App.tsx index 3bc2e7836..ef0a9497e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19,6 +19,7 @@ const ConfigEditor = lazy(() => import("@/pages/ConfigEditor")); const System = lazy(() => import("@/pages/System")); const Settings = lazy(() => import("@/pages/Settings")); const UIPlayground = lazy(() => import("@/pages/UIPlayground")); +const FaceLibrary = lazy(() => import("@/pages/FaceLibrary")); const Logs = lazy(() => import("@/pages/Logs")); function App() { @@ -51,6 +52,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index 9b8924d1b..3ac7c9fee 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -53,13 +53,26 @@ function useValue(): useValueReturn { const cameraStates: WsState = {}; Object.entries(cameraActivity).forEach(([name, state]) => { - const { record, detect, snapshots, audio, autotracking } = + const { + record, + detect, + snapshots, + audio, + notifications, + notifications_suspended, + autotracking, + } = // @ts-expect-error we know this is correct state["config"]; cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF"; cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF"; cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF"; cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF"; + cameraStates[`${name}/notifications/state`] = notifications + ? "ON" + : "OFF"; + cameraStates[`${name}/notifications/suspended`] = + notifications_suspended || 0; cameraStates[`${name}/ptz_autotracker/state`] = autotracking ? "ON" : "OFF"; @@ -413,3 +426,39 @@ export function useTrackedObjectUpdate(): { payload: string } { } = useWs("tracked_object_update", ""); return useDeepMemo(JSON.parse(payload as string)); } + +export function useNotifications(camera: string): { + payload: ToggleableSetting; + send: (payload: string, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs(`${camera}/notifications/state`, `${camera}/notifications/set`); + return { payload: payload as ToggleableSetting, send }; +} + +export function useNotificationSuspend(camera: string): { + payload: string; + send: (payload: number, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs( + `${camera}/notifications/suspended`, + `${camera}/notifications/suspend`, + ); + return { payload: payload as string, send }; +} + +export function useNotificationTest(): { + payload: string; + send: (payload: string, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs("notification_test", "notification_test"); + return { payload: payload as string, send }; +} diff --git a/web/src/components/camera/AutoUpdatingCameraImage.tsx b/web/src/components/camera/AutoUpdatingCameraImage.tsx index ee0f6eccc..d97a9214a 100644 --- a/web/src/components/camera/AutoUpdatingCameraImage.tsx +++ b/web/src/components/camera/AutoUpdatingCameraImage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import CameraImage from "./CameraImage"; type AutoUpdatingCameraImageProps = { @@ -8,6 +8,7 @@ type AutoUpdatingCameraImageProps = { className?: string; cameraClasses?: string; reloadInterval?: number; + periodicCache?: boolean; }; const MIN_LOAD_TIMEOUT_MS = 200; @@ -19,6 +20,7 @@ export default function AutoUpdatingCameraImage({ className, cameraClasses, reloadInterval = MIN_LOAD_TIMEOUT_MS, + periodicCache = false, }: AutoUpdatingCameraImageProps) { const [key, setKey] = useState(Date.now()); const [fps, setFps] = useState("0"); @@ -42,6 +44,8 @@ export default function AutoUpdatingCameraImage({ }, [reloadInterval]); const handleLoad = useCallback(() => { + setIsCached(true); + if (reloadInterval == -1) { return; } @@ -66,12 +70,28 @@ export default function AutoUpdatingCameraImage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [key, setFps]); + // periodic cache to reduce loading indicator + + const [isCached, setIsCached] = useState(false); + + const cacheKey = useMemo(() => { + let baseParam = ""; + + if (periodicCache && !isCached) { + baseParam = "store=1"; + } else { + baseParam = `cache=${key}`; + } + + return `${baseParam}${searchParams ? `&${searchParams}` : ""}`; + }, [isCached, periodicCache, key, searchParams]); + return (
{showFps ? Displaying at {fps}fps : null} diff --git a/web/src/components/card/SearchThumbnail.tsx b/web/src/components/card/SearchThumbnail.tsx index 7dfa7b583..b7dd64e79 100644 --- a/web/src/components/card/SearchThumbnail.tsx +++ b/web/src/components/card/SearchThumbnail.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useApiHost } from "@/api"; import { getIconForLabel } from "@/utils/iconUtil"; import useSWR from "swr"; @@ -33,6 +33,16 @@ export default function SearchThumbnail({ onClick(searchResult, true, false); }); + const handleOnClick = useCallback( + (e: React.MouseEvent) => { + if (e.metaKey) { + e.stopPropagation(); + onClick(searchResult, true, false); + } + }, + [searchResult, onClick], + ); + const objectLabel = useMemo(() => { if ( !config || @@ -57,6 +67,7 @@ export default function SearchThumbnail({
ReactNode; + onCustomScroll?: ( + scrollTop: number, + scrollHeight: number, + clientHeight: number, + ) => void; +}; + +export type ScrollFollowRenderProps = { + follow: boolean; + onScroll: (args: { + scrollTop: number; + scrollHeight: number; + clientHeight: number; + }) => void; + startFollowing: () => void; + stopFollowing: () => void; + onCustomScroll?: ( + scrollTop: number, + scrollHeight: number, + clientHeight: number, + ) => void; +}; + +const SCROLL_BUFFER = 5; + +export default function EnhancedScrollFollow(props: ScrollFollowProps) { + const followRef = useRef(props.startFollowing || false); + const prevScrollTopRef = useRef(undefined); + + useEffect(() => { + prevScrollTopRef.current = undefined; + }, []); + + const wrappedRender = useCallback( + (renderProps: ScrollFollowRenderProps) => { + const wrappedOnScroll = (args: { + scrollTop: number; + scrollHeight: number; + clientHeight: number; + }) => { + // Check if scrolling up and immediately stop following + if ( + prevScrollTopRef.current !== undefined && + args.scrollTop < prevScrollTopRef.current + ) { + if (followRef.current) { + renderProps.stopFollowing(); + followRef.current = false; + } + } + + const bottomThreshold = + args.scrollHeight - args.clientHeight - SCROLL_BUFFER; + const isNearBottom = args.scrollTop >= bottomThreshold; + + if (isNearBottom && !followRef.current) { + renderProps.startFollowing(); + followRef.current = true; + } else if (!isNearBottom && followRef.current) { + renderProps.stopFollowing(); + followRef.current = false; + } + + prevScrollTopRef.current = args.scrollTop; + renderProps.onScroll(args); + if (props.onCustomScroll) { + props.onCustomScroll( + args.scrollTop, + args.scrollHeight, + args.clientHeight, + ); + } + }; + + return props.render({ + ...renderProps, + onScroll: wrappedOnScroll, + follow: followRef.current, + }); + }, + [props], + ); + + return ; +} diff --git a/web/src/components/dynamic/NewReviewData.tsx b/web/src/components/dynamic/NewReviewData.tsx index cc295d79d..473f187ed 100644 --- a/web/src/components/dynamic/NewReviewData.tsx +++ b/web/src/components/dynamic/NewReviewData.tsx @@ -28,13 +28,13 @@ export default function NewReviewData({ return (
-
+
+ + + setOpenCamera(isOpen ? camera : null) + } + /> + + )} + { + const updatedCameras = checked + ? [...(field.value || []), camera] + : (field.value || []).filter((c) => c !== camera); + form.setValue("cameras", updatedCameras); + }} + /> +
+
))} diff --git a/web/src/components/filter/LogLevelFilter.tsx b/web/src/components/filter/LogSettingsButton.tsx similarity index 64% rename from web/src/components/filter/LogLevelFilter.tsx rename to web/src/components/filter/LogSettingsButton.tsx index 9f08c51b5..e9465bf1d 100644 --- a/web/src/components/filter/LogLevelFilter.tsx +++ b/web/src/components/filter/LogSettingsButton.tsx @@ -1,36 +1,73 @@ import { Button } from "../ui/button"; -import { FaFilter } from "react-icons/fa"; +import { FaCog } from "react-icons/fa"; import { isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -import { LogSeverity } from "@/types/log"; +import { LogSettingsType, LogSeverity } from "@/types/log"; import { Label } from "../ui/label"; import { Switch } from "../ui/switch"; import { DropdownMenuSeparator } from "../ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import FilterSwitch from "./FilterSwitch"; -type LogLevelFilterButtonProps = { +type LogSettingsButtonProps = { selectedLabels?: LogSeverity[]; updateLabelFilter: (labels: LogSeverity[] | undefined) => void; + logSettings?: LogSettingsType; + setLogSettings: (logSettings: LogSettingsType) => void; }; -export function LogLevelFilterButton({ +export function LogSettingsButton({ selectedLabels, updateLabelFilter, -}: LogLevelFilterButtonProps) { + logSettings, + setLogSettings, +}: LogSettingsButtonProps) { const trigger = ( ); const content = ( - +
+
+
+
Filter
+
+ Filter logs by severity. +
+
+ +
+ +
+
+
Loading
+
+
+ When the log pane is scrolled to the bottom, new logs + automatically stream as they are added. +
+ { + setLogSettings({ + disableStreaming: isChecked, + }); + }} + /> +
+
+
+
); if (isMobile) { @@ -63,7 +100,7 @@ export function GeneralFilterContent({ return ( <>
-
+
-
{["debug", "info", "warning", "error"].map((item) => ( -
+
+ {averageEstimatedSpeed && ( +
+
Estimated Speed
+
+ {averageEstimatedSpeed && ( +
+ {averageEstimatedSpeed}{" "} + {config?.ui.unit_system == "imperial" ? "mph" : "kph"}{" "} + {velocityAngle != undefined && ( + + + + )} +
+ )} +
+
+ )}
Camera
@@ -499,6 +560,8 @@ function ObjectDetailsTab({ placeholder="Description of the tracked object" value={desc} onChange={(e) => setDesc(e.target.value)} + onFocus={handleDescriptionFocus} + onBlur={handleDescriptionBlur} /> )} @@ -543,16 +606,16 @@ function ObjectDetailsTab({ )}
)} - {(config?.cameras[search.camera].genai.enabled && search.end_time) || - (!config?.cameras[search.camera].genai.enabled && ( - - ))} + {((config?.cameras[search.camera].genai.enabled && search.end_time) || + !config?.cameras[search.camera].genai.enabled) && ( + + )}
@@ -658,7 +721,8 @@ export function ObjectSnapshotTab({ {search.data.type == "object" && search.plus_id !== "not_enabled" && - search.end_time && ( + search.end_time && + search.label != "on_demand" && (
diff --git a/web/src/components/overlay/dialog/SearchFilterDialog.tsx b/web/src/components/overlay/dialog/SearchFilterDialog.tsx index 65109591b..23deee531 100644 --- a/web/src/components/overlay/dialog/SearchFilterDialog.tsx +++ b/web/src/components/overlay/dialog/SearchFilterDialog.tsx @@ -71,9 +71,11 @@ export default function SearchFilterDialog({ currentFilter && (currentFilter.time_range || (currentFilter.min_score ?? 0) > 0.5 || + (currentFilter.min_speed ?? 1) > 1 || (currentFilter.has_snapshot ?? 0) === 1 || (currentFilter.has_clip ?? 0) === 1 || (currentFilter.max_score ?? 1) < 1 || + (currentFilter.max_speed ?? 150) < 150 || (currentFilter.zones?.length ?? 0) > 0 || (currentFilter.sub_labels?.length ?? 0) > 0), [currentFilter], @@ -124,6 +126,14 @@ export default function SearchFilterDialog({ setCurrentFilter({ ...currentFilter, min_score: min, max_score: max }) } /> + + setCurrentFilter({ ...currentFilter, min_speed: min, max_speed: max }) + } + /> void; +}; +export function SpeedFilterContent({ + config, + minSpeed, + maxSpeed, + setSpeedRange, +}: SpeedFilterContentProps) { + return ( +
+ +
+ Estimated Speed ({config?.ui.unit_system == "metric" ? "kph" : "mph"}) +
+
+ { + const value = e.target.value; + + if (value) { + setSpeedRange(parseInt(value), maxSpeed ?? 1.0); + } + }} + /> + setSpeedRange(min, max)} + /> + { + const value = e.target.value; + + if (value) { + setSpeedRange(minSpeed ?? 1, parseInt(value)); + } + }} + /> +
+
+ ); +} + type SnapshotClipContentProps = { config?: FrigateConfig; hasSnapshot: boolean | undefined; diff --git a/web/src/components/overlay/dialog/TextEntryDialog.tsx b/web/src/components/overlay/dialog/TextEntryDialog.tsx new file mode 100644 index 000000000..1b0655078 --- /dev/null +++ b/web/src/components/overlay/dialog/TextEntryDialog.tsx @@ -0,0 +1,88 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useCallback } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +type TextEntryDialogProps = { + open: boolean; + title: string; + description?: string; + setOpen: (open: boolean) => void; + onSave: (text: string) => void; +}; +export default function TextEntryDialog({ + open, + title, + description, + setOpen, + onSave, +}: TextEntryDialogProps) { + const formSchema = z.object({ + text: z.string(), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + }); + const fileRef = form.register("text"); + + // upload handler + + const onSubmit = useCallback( + (data: z.infer) => { + if (!data["text"]) { + return; + } + + onSave(data["text"]); + }, + [onSave], + ); + + return ( + + + + {title} + {description && {description}} + +
+ + ( + + + + + + )} + /> + + + + + + +
+
+ ); +} diff --git a/web/src/components/overlay/dialog/UploadImageDialog.tsx b/web/src/components/overlay/dialog/UploadImageDialog.tsx new file mode 100644 index 000000000..6a01a7fab --- /dev/null +++ b/web/src/components/overlay/dialog/UploadImageDialog.tsx @@ -0,0 +1,88 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useCallback } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +type UploadImageDialogProps = { + open: boolean; + title: string; + description?: string; + setOpen: (open: boolean) => void; + onSave: (file: File) => void; +}; +export default function UploadImageDialog({ + open, + title, + description, + setOpen, + onSave, +}: UploadImageDialogProps) { + const formSchema = z.object({ + file: z.instanceof(FileList, { message: "Please select an image file." }), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + }); + const fileRef = form.register("file"); + + // upload handler + + const onSubmit = useCallback( + (data: z.infer) => { + if (!data["file"] || Object.keys(data.file).length == 0) { + return; + } + + onSave(data["file"]["0"]); + }, + [onSave], + ); + + return ( + + + + {title} + {description && {description}} + +
+ + ( + + + + + + )} + /> + + + + + + +
+
+ ); +} diff --git a/web/src/components/player/BirdseyeLivePlayer.tsx b/web/src/components/player/BirdseyeLivePlayer.tsx index 2666ac9f7..286f19216 100644 --- a/web/src/components/player/BirdseyeLivePlayer.tsx +++ b/web/src/components/player/BirdseyeLivePlayer.tsx @@ -58,6 +58,7 @@ export default function BirdseyeLivePlayer({ height={birdseyeConfig.height} containerRef={containerRef} playbackEnabled={true} + useWebGL={true} /> ); } else { diff --git a/web/src/components/player/JSMpegPlayer.tsx b/web/src/components/player/JSMpegPlayer.tsx index 401e85869..3753a9e46 100644 --- a/web/src/components/player/JSMpegPlayer.tsx +++ b/web/src/components/player/JSMpegPlayer.tsx @@ -1,6 +1,7 @@ import { baseUrl } from "@/api/baseUrl"; import { useResizeObserver } from "@/hooks/resize-observer"; import { cn } from "@/lib/utils"; +import { PlayerStatsType } from "@/types/live"; // @ts-expect-error we know this doesn't have types import JSMpeg from "@cycjimmy/jsmpeg-player"; import React, { useEffect, useMemo, useRef, useState } from "react"; @@ -12,6 +13,8 @@ type JSMpegPlayerProps = { height: number; containerRef: React.MutableRefObject; playbackEnabled: boolean; + useWebGL: boolean; + setStats?: (stats: PlayerStatsType) => void; onPlaying?: () => void; }; @@ -22,6 +25,8 @@ export default function JSMpegPlayer({ className, containerRef, playbackEnabled, + useWebGL = false, + setStats, onPlaying, }: JSMpegPlayerProps) { const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`; @@ -33,6 +38,9 @@ export default function JSMpegPlayer({ const [hasData, setHasData] = useState(false); const hasDataRef = useRef(hasData); const [dimensionsReady, setDimensionsReady] = useState(false); + const bytesReceivedRef = useRef(0); + const lastTimestampRef = useRef(Date.now()); + const statsIntervalRef = useRef(null); const selectedContainerRef = useMemo( () => (containerRef.current ? containerRef : internalContainerRef), @@ -111,6 +119,8 @@ export default function JSMpegPlayer({ const canvas = canvasRef.current; let videoElement: JSMpeg.VideoElement | null = null; + let frameCount = 0; + setHasData(false); if (videoWrapper && playbackEnabled) { @@ -123,21 +133,68 @@ export default function JSMpegPlayer({ { protocols: [], audio: false, - disableGl: camera != "birdseye", - disableWebAssembly: camera != "birdseye", + disableGl: !useWebGL, + disableWebAssembly: !useWebGL, videoBufferSize: 1024 * 1024 * 4, onVideoDecode: () => { if (!hasDataRef.current) { setHasData(true); onPlayingRef.current?.(); } + frameCount++; }, }, ); + + // Set up WebSocket message handler + if ( + videoElement.player && + videoElement.player.source && + videoElement.player.source.socket + ) { + const socket = videoElement.player.source.socket; + socket.addEventListener("message", (event: MessageEvent) => { + if (event.data instanceof ArrayBuffer) { + bytesReceivedRef.current += event.data.byteLength; + } + }); + } + + // Update stats every second + statsIntervalRef.current = setInterval(() => { + const currentTimestamp = Date.now(); + const timeDiff = (currentTimestamp - lastTimestampRef.current) / 1000; // in seconds + const bitrate = (bytesReceivedRef.current * 8) / timeDiff / 1000; // in kbps + + setStats?.({ + streamType: "jsmpeg", + bandwidth: Math.round(bitrate), + totalFrames: frameCount, + latency: undefined, + droppedFrames: undefined, + decodedFrames: undefined, + droppedFrameRate: undefined, + }); + + bytesReceivedRef.current = 0; + lastTimestampRef.current = currentTimestamp; + }, 1000); + + return () => { + if (statsIntervalRef.current) { + clearInterval(statsIntervalRef.current); + frameCount = 0; + statsIntervalRef.current = null; + } + }; }, 0); return () => { clearTimeout(initPlayer); + if (statsIntervalRef.current) { + clearInterval(statsIntervalRef.current); + statsIntervalRef.current = null; + } if (videoElement) { try { // this causes issues in react strict mode diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 8038812db..4bd751469 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -11,6 +11,7 @@ import { useCameraActivity } from "@/hooks/use-camera-activity"; import { LivePlayerError, LivePlayerMode, + PlayerStatsType, VideoResolutionType, } from "@/types/live"; import { getIconForLabel } from "@/utils/iconUtil"; @@ -20,20 +21,26 @@ import { cn } from "@/lib/utils"; import { TbExclamationCircle } from "react-icons/tb"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { baseUrl } from "@/api/baseUrl"; +import { PlayerStats } from "./PlayerStats"; type LivePlayerProps = { cameraRef?: (ref: HTMLDivElement | null) => void; containerRef?: React.MutableRefObject; className?: string; cameraConfig: CameraConfig; + streamName: string; preferredLiveMode: LivePlayerMode; showStillWithoutActivity?: boolean; + useWebGL: boolean; windowVisible?: boolean; playAudio?: boolean; + volume?: number; + playInBackground: boolean; micEnabled?: boolean; // only webrtc supports mic iOSCompatFullScreen?: boolean; pip?: boolean; autoLive?: boolean; + showStats?: boolean; onClick?: () => void; setFullResolution?: React.Dispatch>; onError?: (error: LivePlayerError) => void; @@ -45,14 +52,19 @@ export default function LivePlayer({ containerRef, className, cameraConfig, + streamName, preferredLiveMode, showStillWithoutActivity = true, + useWebGL = false, windowVisible = true, playAudio = false, + volume, + playInBackground = false, micEnabled = false, iOSCompatFullScreen = false, pip, autoLive = true, + showStats = false, onClick, setFullResolution, onError, @@ -60,6 +72,18 @@ export default function LivePlayer({ }: LivePlayerProps) { const internalContainerRef = useRef(null); + // stats + + const [stats, setStats] = useState({ + streamType: "-", + bandwidth: 0, // in kbps + latency: undefined, // in seconds + totalFrames: 0, + droppedFrames: undefined, + decodedFrames: 0, + droppedFrameRate: 0, // percentage + }); + // camera activity const { activeMotion, activeTracking, objects, offline } = @@ -144,6 +168,25 @@ export default function LivePlayer({ setLiveReady(false); }, [preferredLiveMode]); + const [key, setKey] = useState(0); + + const resetPlayer = () => { + setLiveReady(false); + setKey((prevKey) => prevKey + 1); + }; + + useEffect(() => { + if (streamName) { + resetPlayer(); + } + }, [streamName]); + + useEffect(() => { + if (showStillWithoutActivity && !autoLive) { + setLiveReady(false); + } + }, [showStillWithoutActivity, autoLive]); + const playerIsPlaying = useCallback(() => { setLiveReady(true); }, []); @@ -153,15 +196,19 @@ export default function LivePlayer({ } let player; - if (!autoLive) { + if (!autoLive || !streamName) { player = null; } else if (preferredLiveMode == "webrtc") { player = ( @@ -293,11 +348,12 @@ export default function LivePlayer({ )} >
@@ -330,6 +386,9 @@ export default function LivePlayer({ )}
+ {showStats && ( + + )}
); } diff --git a/web/src/components/player/MsePlayer.tsx b/web/src/components/player/MsePlayer.tsx index 52cf8f99c..554eb5af1 100644 --- a/web/src/components/player/MsePlayer.tsx +++ b/web/src/components/player/MsePlayer.tsx @@ -1,5 +1,9 @@ import { baseUrl } from "@/api/baseUrl"; -import { LivePlayerError, VideoResolutionType } from "@/types/live"; +import { + LivePlayerError, + PlayerStatsType, + VideoResolutionType, +} from "@/types/live"; import { SetStateAction, useCallback, @@ -15,7 +19,11 @@ type MSEPlayerProps = { className?: string; playbackEnabled?: boolean; audioEnabled?: boolean; + volume?: number; + playInBackground?: boolean; pip?: boolean; + getStats?: boolean; + setStats?: (stats: PlayerStatsType) => void; onPlaying?: () => void; setFullResolution?: React.Dispatch>; onError?: (error: LivePlayerError) => void; @@ -26,7 +34,11 @@ function MSEPlayer({ className, playbackEnabled = true, audioEnabled = false, + volume, + playInBackground = false, pip = false, + getStats = false, + setStats, onPlaying, setFullResolution, onError, @@ -57,6 +69,7 @@ function MSEPlayer({ const [connectTS, setConnectTS] = useState(0); const [bufferTimeout, setBufferTimeout] = useState(); const [errorCount, setErrorCount] = useState(0); + const totalBytesLoaded = useRef(0); const videoRef = useRef(null); const wsRef = useRef(null); @@ -316,6 +329,8 @@ function MSEPlayer({ let bufLen = 0; ondataRef.current = (data) => { + totalBytesLoaded.current += data.byteLength; + if (sb?.updating || bufLen > 0) { const b = new Uint8Array(data); buf.set(b, bufLen); @@ -508,12 +523,22 @@ function MSEPlayer({ } }; - document.addEventListener("visibilitychange", listener); + if (!playInBackground) { + document.addEventListener("visibilitychange", listener); + } return () => { - document.removeEventListener("visibilitychange", listener); + if (!playInBackground) { + document.removeEventListener("visibilitychange", listener); + } }; - }, [playbackEnabled, visibilityCheck, onConnect, onDisconnect]); + }, [ + playbackEnabled, + visibilityCheck, + playInBackground, + onConnect, + onDisconnect, + ]); // control pip @@ -525,6 +550,16 @@ function MSEPlayer({ videoRef.current.requestPictureInPicture(); }, [pip, videoRef]); + // control volume + + useEffect(() => { + if (!videoRef.current || volume == undefined) { + return; + } + + videoRef.current.volume = volume; + }, [volume, videoRef]); + // ensure we disconnect for slower connections useEffect(() => { @@ -542,6 +577,68 @@ function MSEPlayer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [playbackEnabled]); + // stats + + useEffect(() => { + const video = videoRef.current; + let lastLoadedBytes = totalBytesLoaded.current; + let lastTimestamp = Date.now(); + + if (!getStats) return; + + const updateStats = () => { + if (video) { + const now = Date.now(); + const bytesLoaded = totalBytesLoaded.current; + const timeElapsed = (now - lastTimestamp) / 1000; // seconds + const bandwidth = (bytesLoaded - lastLoadedBytes) / timeElapsed / 1024; // kbps + + lastLoadedBytes = bytesLoaded; + lastTimestamp = now; + + const latency = + video.seekable.length > 0 + ? Math.max( + 0, + video.seekable.end(video.seekable.length - 1) - + video.currentTime, + ) + : 0; + + const videoQuality = video.getVideoPlaybackQuality(); + const { totalVideoFrames, droppedVideoFrames } = videoQuality; + const droppedFrameRate = totalVideoFrames + ? (droppedVideoFrames / totalVideoFrames) * 100 + : 0; + + setStats?.({ + streamType: "MSE", + bandwidth, + latency, + totalFrames: totalVideoFrames, + droppedFrames: droppedVideoFrames || undefined, + decodedFrames: totalVideoFrames - droppedVideoFrames, + droppedFrameRate, + }); + } + }; + + const interval = setInterval(updateStats, 1000); // Update every second + + return () => { + clearInterval(interval); + setStats?.({ + streamType: "-", + bandwidth: 0, + latency: undefined, + totalFrames: 0, + droppedFrames: undefined, + decodedFrames: 0, + droppedFrameRate: 0, + }); + }; + }, [setStats, getStats]); + return (