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/workflows/ci.yml b/.github/workflows/ci.yml index bb5ee4d2c..2046ed100 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,7 @@ 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: + if: false runs-on: ubuntu-22.04 name: Jetson Jetpack 4 steps: @@ -106,6 +107,7 @@ 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: + if: false runs-on: ubuntu-22.04 name: Jetson Jetpack 5 steps: @@ -162,6 +164,19 @@ 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-22.04 name: ARM Extra Build @@ -187,46 +202,6 @@ jobs: set: | rk.tags=${{ steps.setup.outputs.image-name }}-rk *.cache-from=type=gha - combined_extra_builds: - runs-on: ubuntu-22.04 - name: Combined Extra Builds - needs: - - amd64_build - - arm64_build - steps: - - name: Check out code - uses: actions/checkout@v4 - with: - persist-credentials: false - - name: Set up QEMU and Buildx - id: setup - uses: ./.github/actions/setup - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push Hailo-8l build - uses: docker/bake-action@v6 - with: - source: . - push: true - targets: h8l - files: docker/hailo8l/h8l.hcl - set: | - 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@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 # The majority of users running arm64 are rpi users, so the rpi # build should be the primary arm64 image assemble_default_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/docker/hailo8l/Dockerfile b/docker/hailo8l/Dockerfile deleted file mode 100644 index 959e7692e..000000000 --- a/docker/hailo8l/Dockerfile +++ /dev/null @@ -1,40 +0,0 @@ -# syntax=docker/dockerfile:1.6 - -ARG DEBIAN_FRONTEND=noninteractive - -# Build Python wheels -FROM wheels AS h8l-wheels - -COPY docker/main/requirements-wheels.txt /requirements-wheels.txt -COPY docker/hailo8l/requirements-wheels-h8l.txt /requirements-wheels-h8l.txt - -RUN sed -i "/https:\/\//d" /requirements-wheels.txt - -# Create a directory to store the built wheels -RUN mkdir /h8l-wheels - -# Build the wheels -RUN pip3 wheel --wheel-dir=/h8l-wheels -c /requirements-wheels.txt -r /requirements-wheels-h8l.txt - -FROM wget AS hailort -ARG TARGETARCH -RUN --mount=type=bind,source=docker/hailo8l/install_hailort.sh,target=/deps/install_hailort.sh \ - /deps/install_hailort.sh - -# Use deps as the base image -FROM deps AS h8l-frigate - -# Copy the wheels from the wheels stage -COPY --from=h8l-wheels /h8l-wheels /deps/h8l-wheels -COPY --from=hailort /hailo-wheels /deps/hailo-wheels -COPY --from=hailort /rootfs/ / - -# Install the wheels -RUN pip3 install -U /deps/h8l-wheels/*.whl -RUN pip3 install -U /deps/hailo-wheels/*.whl - -# Copy base files from the rootfs stage -COPY --from=rootfs / / - -# Set workdir -WORKDIR /opt/frigate/ diff --git a/docker/hailo8l/h8l.hcl b/docker/hailo8l/h8l.hcl deleted file mode 100644 index 91f6d13c6..000000000 --- a/docker/hailo8l/h8l.hcl +++ /dev/null @@ -1,34 +0,0 @@ -target wget { - dockerfile = "docker/main/Dockerfile" - platforms = ["linux/arm64","linux/amd64"] - target = "wget" -} - -target wheels { - dockerfile = "docker/main/Dockerfile" - platforms = ["linux/arm64","linux/amd64"] - target = "wheels" -} - -target deps { - dockerfile = "docker/main/Dockerfile" - platforms = ["linux/arm64","linux/amd64"] - target = "deps" -} - -target rootfs { - dockerfile = "docker/main/Dockerfile" - platforms = ["linux/arm64","linux/amd64"] - target = "rootfs" -} - -target h8l { - dockerfile = "docker/hailo8l/Dockerfile" - contexts = { - wget = "target:wget" - wheels = "target:wheels" - deps = "target:deps" - rootfs = "target:rootfs" - } - platforms = ["linux/arm64","linux/amd64"] -} diff --git a/docker/hailo8l/h8l.mk b/docker/hailo8l/h8l.mk deleted file mode 100644 index 318771802..000000000 --- a/docker/hailo8l/h8l.mk +++ /dev/null @@ -1,15 +0,0 @@ -BOARDS += h8l - -local-h8l: version - docker buildx bake --file=docker/hailo8l/h8l.hcl h8l \ - --set h8l.tags=frigate:latest-h8l \ - --load - -build-h8l: version - docker buildx bake --file=docker/hailo8l/h8l.hcl h8l \ - --set h8l.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-h8l - -push-h8l: build-h8l - docker buildx bake --file=docker/hailo8l/h8l.hcl h8l \ - --set h8l.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-h8l \ - --push \ No newline at end of file diff --git a/docker/hailo8l/install_hailort.sh b/docker/hailo8l/install_hailort.sh deleted file mode 100755 index 62eba9611..000000000 --- a/docker/hailo8l/install_hailort.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -set -euxo pipefail - -hailo_version="4.19.0" - -if [[ "${TARGETARCH}" == "amd64" ]]; then - arch="x86_64" -elif [[ "${TARGETARCH}" == "arm64" ]]; then - arch="aarch64" -fi - -wget -qO- "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${TARGETARCH}.tar.gz" | - tar -C / -xzf - - -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" - diff --git a/docker/hailo8l/requirements-wheels-h8l.txt b/docker/hailo8l/requirements-wheels-h8l.txt deleted file mode 100644 index e125f9e8b..000000000 --- a/docker/hailo8l/requirements-wheels-h8l.txt +++ /dev/null @@ -1,12 +0,0 @@ -appdirs==1.4.* -argcomplete==2.0.* -contextlib2==0.6.* -distlib==0.3.* -filelock==3.8.* -future==0.18.* -importlib-metadata==5.1.* -importlib-resources==5.1.* -netaddr==0.8.* -netifaces==0.10.* -verboselogs==1.7.* -virtualenv==20.17.* 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 ef4d82f3a..4c3416789 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 @@ -180,6 +170,9 @@ RUN /build_pysqlite3.sh COPY docker/main/requirements-wheels.txt /requirements-wheels.txt RUN pip3 wheel --wheel-dir=/wheels -r /requirements-wheels.txt +# Install HailoRT & Wheels +RUN --mount=type=bind,source=docker/main/install_hailort.sh,target=/deps/install_hailort.sh \ + /deps/install_hailort.sh # Collect deps in a single layer FROM scratch AS deps-rootfs @@ -190,6 +183,7 @@ COPY --from=libusb-build /usr/local/lib /usr/local/lib COPY --from=tempio /rootfs/ / COPY --from=s6-overlay /rootfs/ / COPY --from=models /rootfs/ / +COPY --from=wheels /rootfs/ / COPY docker/main/rootfs/ / @@ -221,8 +215,8 @@ RUN --mount=type=bind,source=docker/main/install_deps.sh,target=/deps/install_de /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 / / @@ -269,7 +263,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/install_hailort.sh b/docker/main/install_hailort.sh new file mode 100755 index 000000000..e050bd591 --- /dev/null +++ b/docker/main/install_hailort.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -euxo pipefail + +hailo_version="4.20.0" + +if [[ "${TARGETARCH}" == "amd64" ]]; then + arch="x86_64" +elif [[ "${TARGETARCH}" == "arm64" ]]; then + arch="aarch64" +fi + +wget -qO- "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${TARGETARCH}.tar.gz" | tar -C / -xzf - +wget -P /wheels/ "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl" diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 4db88ccd2..bb4ac622b 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,21 @@ openai == 1.51.* # push notifications py-vapid == 1.9.* pywebpush == 2.0.* +# alpr +pyclipper == 1.3.* +shapely == 2.0.* +Levenshtein==0.26.* +prometheus-client == 0.21.* +# HailoRT Wheels +appdirs==1.4.* +argcomplete==2.0.* +contextlib2==0.6.* +distlib==0.3.* +filelock==3.8.* +future==0.18.* +importlib-metadata==5.1.* +importlib-resources==5.1.* +netaddr==0.8.* +netifaces==0.10.* +verboselogs==1.7.* +virtualenv==20.17.* 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/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 75527bf53..8a98da1f2 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; @@ -106,6 +109,14 @@ http { expires off; keepalive_disable safari; + + # vod module returns 502 for non-existent media + # https://github.com/kaltura/nginx-vod-module/issues/468 + error_page 502 =404 /vod-not-found; + } + + location = /vod-not-found { + return 404; } location /stream/ { 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 1373a6faf..e9c9602a8 100644 --- a/docker/rockchip/Dockerfile +++ b/docker/rockchip/Dockerfile @@ -7,18 +7,23 @@ 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 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/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 3068ec8f8..c1f12ee08 100644 --- a/docs/docs/configuration/advanced.md +++ b/docs/docs/configuration/advanced.md @@ -32,7 +32,7 @@ Examples of available modules are: #### Go2RTC Logging -See [the go2rtc docs](for logging configuration) +See [the go2rtc docs](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#module-log) for logging configuration ```yaml go2rtc: 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 212e2f3d0..fb4ce5714 100644 --- a/docs/docs/configuration/camera_specific.md +++ b/docs/docs/configuration/camera_specific.md @@ -22,7 +22,7 @@ Note that mjpeg cameras require encoding the video into h264 for recording, and ```yaml go2rtc: streams: - mjpeg_cam: "ffmpeg:{your_mjpeg_stream_url}#video=h264#hardware" # <- use hardware acceleration to create an h264 stream usable for other components. + mjpeg_cam: "ffmpeg:http://your_mjpeg_stream_url#video=h264#hardware" # <- use hardware acceleration to create an h264 stream usable for other components. cameras: ... @@ -79,14 +79,15 @@ rtsp://USERNAME:PASSWORD@CAMERA-IP/cam/realmonitor?channel=1&subtype=3 # new hig ### 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://USERNAME:PASSWORD@CAMERA-IP/H264/ch1/main/av_stream # <----- Update for your camera 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/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..d2a130c5e --- /dev/null +++ b/docs/docs/configuration/license_plate_recognition.md @@ -0,0 +1,88 @@ +--- +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 (or any custom model that natively detects license plates) 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. + +Users without a model that detects license plates can still run LPR. A small, CPU inference, YOLOv9 license plate detection model will be used instead. You should _not_ define `license_plate` in your list of objects to track. + +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 recognition 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 + +Fine-tune the LPR feature using these optional parameters: + +### Detection + +- **`detection_threshold`**: License plate object detection confidence score required before recognition runs. + - Default: `0.7` + - Note: If you are using a Frigate+ model and you set the `threshold` in your objects config for `license_plate` higher than this value, recognition will never run. It's best to ensure these values match, or this `detection_threshold` is lower than your object config `threshold`. +- **`min_area`**: Defines the minimum size (in pixels) a license plate must be before recognition runs. + - Default: `1000` pixels. + - Depending on the resolution of your cameras, you can increase this value to ignore small or distant plates. + +### Recognition + +- **`recognition_threshold`**: Recognition confidence score required to add the plate to the object as a sub label. + - Default: `0.9`. +- **`min_plate_length`**: Specifies the minimum number of characters a detected license plate must have to be added as a sub-label to an object. + - Use this to filter out short, incomplete, or incorrect detections. +- **`format`**: A regular expression defining the expected format of detected plates. Plates that do not match this format will be discarded. + - `"^[A-Z]{1,3} [A-Z]{1,2} [0-9]{1,4}$"` matches plates like "B AB 1234" or "M X 7" + - `"^[A-Z]{2}[0-9]{2} [A-Z]{3}$"` matches plates like "AB12 XYZ" or "XY68 ABC" + +### Matching + +- **`known_plates`**: List of strings or regular expressions that assign custom a `sub_label` to `car` objects when a recognized plate matches a known value. + - These labels appear in the UI, filters, and notifications. +- **`match_distance`**: Allows for minor variations (missing/incorrect characters) when matching a detected plate to a known plate. + - For example, setting `match_distance: 1` allows a plate `ABCDE` to match `ABCBE` or `ABCD`. + - This parameter will not operate on known plates that are defined as regular expressions. You should define the full string of your plate in `known_plates` in order to use `match_distance`. + +### Examples + +```yaml +lpr: + enabled: True + min_area: 1500 # Ignore plates smaller than 1500 pixels + min_plate_length: 4 # Only recognize plates with 4 or more characters + known_plates: + Wife's Car: + - "ABC-1234" + - "ABC-I234" # Accounts for potential confusion between the number one (1) and capital letter I + Johnny: + - "J*N-*234" # Matches JHN-1234 and JMN-I234, but also note that "*" matches any number of characters + Sally: + - "[S5]LL-1234" # Matches both SLL-1234 and 5LL-1234 +``` + +```yaml +lpr: + enabled: True + min_area: 4000 # Run recognition on larger plates only + recognition_threshold: 0.85 + format: "^[A-Z]{3}-[0-9]{4}$" # Only recognize plates that are three letters, followed by a dash, followed by 4 numbers + match_distance: 1 # Allow one character variation in plate matching + known_plates: + Delivery Van: + - "RJK-5678" + - "UPS-1234" + Employee Parking: + - "EMP-[0-9]{3}[A-Z]" # Matches plates like EMP-123A, EMP-456Z +``` diff --git a/docs/docs/configuration/live.md b/docs/docs/configuration/live.md index 22789181a..76fcf6826 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 @@ -148,3 +165,50 @@ For devices that support two way talk, Frigate can be configured to use the feat - 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 4aa1d7fe4..21ba46c2d 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -35,7 +35,7 @@ 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). +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) @@ -201,15 +201,7 @@ This detector also supports YOLOX. Frigate does not come with any YOLOX models p #### YOLO-NAS -[YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) models are supported, but not included by default. You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb). - -:::warning - -The pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html - -::: - -The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired. +[YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) models are supported, but not included by default. See [the models section](#downloading-yolo-nas-model) for more information on downloading the YOLO-NAS model for use in Frigate. After placing the downloaded onnx model in your config folder, you can use the following configuration: @@ -231,13 +223,43 @@ model: Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. +#### 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: + ov: + type: openvino + device: GPU + +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. + ## NVidia TensorRT Detector Nvidia GPUs may be used for object detection using the TensorRT libraries. Due to the size of the additional libraries, this detector is only provided in images with the `-tensorrt` tag suffix, e.g. `ghcr.io/blakeblackshear/frigate:stable-tensorrt`. This detector is designed to work with Yolo models for object detection. ### Minimum Hardware Support -The TensorRT detector uses the 12.x series of CUDA libraries which have minor version compatibility. The minimum driver version on the host system must be `>=530`. Also the GPU must support a Compute Capability of `5.0` or greater. This generally correlates to a Maxwell-era GPU or newer, check the NVIDIA GPU Compute Capability table linked below. +The TensorRT detector uses the 12.x series of CUDA libraries which have minor version compatibility. The minimum driver version on the host system must be `>=545`. Also the GPU must support a Compute Capability of `5.0` or greater. This generally correlates to a Maxwell-era GPU or newer, check the NVIDIA GPU Compute Capability table linked below. To use the TensorRT detector, make sure your host system has the [nvidia-container-runtime](https://docs.docker.com/config/containers/resource_constraints/#access-an-nvidia-gpu) installed to pass through the GPU to the container and the host system has a compatible driver installed for your GPU. @@ -265,6 +287,8 @@ If your GPU does not support FP16 operations, you can pass the environment varia Specific models can be selected by passing an environment variable to the `docker run` command or in your `docker-compose.yml` file. Use the form `-e YOLO_MODELS=yolov4-416,yolov4-tiny-416` to select one or more model names. The models available are shown below. +
+Available Models ``` yolov3-288 yolov3-416 @@ -293,6 +317,7 @@ yolov7-320 yolov7x-640 yolov7x-320 ``` +
An example `docker-compose.yml` fragment that converts the `yolov4-608` and `yolov7x-640` models for a Pascal card would look something like this: @@ -420,15 +445,7 @@ There is no default model provided, the following formats are supported: #### YOLO-NAS -[YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) models are supported, but not included by default. You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb). - -:::warning - -The pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html - -::: - -The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired. +[YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) models are supported, but not included by default. See [the models section](#downloading-yolo-nas-model) for more information on downloading the YOLO-NAS model for use in Frigate. After placing the downloaded onnx model in your config folder, you can use the following configuration: @@ -450,7 +467,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 @@ -490,15 +507,7 @@ There is no default model provided, the following formats are supported: #### YOLO-NAS -[YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) models are supported, but not included by default. You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb). - -:::warning - -The pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html - -::: - -The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired. +[YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) models are supported, but not included by default. See [the models section](#downloading-yolo-nas-model) for more information on downloading the YOLO-NAS model for use in Frigate. After placing the downloaded onnx model in your config folder, you can use the following configuration: @@ -517,6 +526,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) @@ -582,7 +618,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 @@ -656,3 +692,57 @@ $ 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. + +### Converting your own onnx model to rknn format + +To convert a onnx model to the rknn format using the [rknn-toolkit2](https://github.com/airockchip/rknn-toolkit2/) you have to: + +- 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`. + +This is an example configuration file that you need to adjust to your specific onnx model: + +```yaml +soc: ["rk3562","rk3566", "rk3568", "rk3576", "rk3588"] +quantization: false + +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). + +# Models + +Some model types are not included in Frigate by default. + +## Downloading Models + +Here are some tips for getting different model types + +### Downloading YOLO-NAS Model + +You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb). + +:::warning + +The pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html + +::: + +The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired. 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 ae5549113..21b60f449 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 @@ -331,6 +340,8 @@ objects: review: # Optional: alerts configuration alerts: + # Optional: enables alerts for the camera (default: shown below) + enabled: True # Optional: labels that qualify as an alert (default: shown below) labels: - car @@ -343,6 +354,8 @@ review: - driveway # Optional: detections configuration detections: + # Optional: enables detections for the camera (default: shown below) + enabled: True # Optional: labels that qualify as a detection (default: all labels that are tracked / listened to) labels: - car @@ -400,6 +413,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 +538,33 @@ 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 license plate recognition capability +lpr: + # Optional: Enable license plate recognition (default: shown below) + enabled: False + # Optional: License plate object confidence score required to begin running recognition (default: shown below) + detection_threshold: 0.7 + # Optional: Minimum area of license plate to begin running recognition (default: shown below) + min_area: 1000 + # Optional: Recognition confidence score required to add the plate to the object as a sub label (default: shown below) + recognition_threshold: 0.9 + # Optional: Minimum number of characters a license plate must have to be added to the object as a sub label (default: shown below) + min_plate_length: 4 + # Optional: Regular expression for the expected format of a license plate (default: shown below) + format: None + # Optional: Allow this number of missing/incorrect characters to still cause a detected plate to match a known plate + match_distance: 1 + # Optional: Known plates to track (strings or regular expressions) (default: shown below) + known_plates: {} + # 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 @@ -549,16 +590,18 @@ 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. +# 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. @@ -643,7 +686,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) @@ -794,6 +840,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: @@ -807,11 +856,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..8dd63f0f3 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. Avoid creating your zone near intersections or anywhere that objects would make a turn. If the bounding box changes shape (either because the object made a turn or became partially obscured, for example), speed estimation will 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. See the photo example above. +- 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/installation.md b/docs/docs/frigate/installation.md index 2c139d2bd..e14559697 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -117,7 +117,7 @@ For other installations, follow these steps for installation: #### Setup -To set up Frigate, follow the default installation instructions, but use a Docker image with the `-h8l` suffix, for example: `ghcr.io/blakeblackshear/frigate:stable-h8l` +To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable` Next, grant Docker permissions to access your hardware by adding the following lines to your `docker-compose.yml` file: diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index 194821cbd..4eaf61919 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 } } ``` @@ -312,6 +316,22 @@ Topic with current state of the PTZ autotracker for a camera. Published values a Topic to determine if PTZ autotracker is actively tracking an object. Published values are `ON` and `OFF`. +### `frigate//review_alerts/set` + +Topic to turn review alerts for a camera on or off. Expected values are `ON` and `OFF`. + +### `frigate//review_alerts/state` + +Topic with current state of review alerts for a camera. Published values are `ON` and `OFF`. + +### `frigate//review_detections/set` + +Topic to turn review detections for a camera on or off. Expected values are `ON` and `OFF`. + +### `frigate//review_detections/state` + +Topic with current state of review detections for a camera. Published values are `ON` and `OFF`. + ### `frigate//birdseye/set` Topic to turn Birdseye for a camera on and off. Expected values are `ON` and `OFF`. Birdseye mode @@ -337,3 +357,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/troubleshooting/edgetpu.md b/docs/docs/troubleshooting/edgetpu.md index 8f3cb0db7..2e10f0839 100644 --- a/docs/docs/troubleshooting/edgetpu.md +++ b/docs/docs/troubleshooting/edgetpu.md @@ -54,6 +54,10 @@ 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. +## Attempting to load TPU as pci & Fatal Python error: Illegal instruction + +This is an issue due to outdated gasket driver when being used with new linux kernels. Installing an updated driver from https://github.com/jnicolson/gasket-builder has been reported to fix the issue. + ### 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) 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 75b29d566..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 @@ -154,6 +167,7 @@ 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(): @@ -186,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=( @@ -197,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, @@ -394,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, ): @@ -415,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", @@ -431,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/request/export_rename_body.py b/frigate/api/defs/request/export_rename_body.py new file mode 100644 index 000000000..dc5bc32f9 --- /dev/null +++ b/frigate/api/defs/request/export_rename_body.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, Field + + +class ExportRenameBody(BaseModel): + name: str = Field(title="Friendly name", max_length=256) 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/export.py b/frigate/api/export.py index ba0f8be28..2ccbc4beb 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -12,6 +12,7 @@ from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody +from frigate.api.defs.request.export_rename_body import ExportRenameBody from frigate.api.defs.tags import Tags from frigate.const import EXPORT_DIR from frigate.models import Export, Previews, Recordings @@ -129,8 +130,8 @@ def export_recording( ) -@router.patch("/export/{event_id}/{new_name}") -def export_rename(event_id: str, new_name: str): +@router.patch("/export/{event_id}/rename") +def export_rename(event_id: str, body: ExportRenameBody): try: export: Export = Export.get(Export.id == event_id) except DoesNotExist: @@ -144,7 +145,7 @@ def export_rename(event_id: str, new_name: str): status_code=404, ) - export.name = new_name + export.name = body.name export.save() return JSONResponse( content=( diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index cf8e98536..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, @@ -103,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 b5f3ba703..a9455919b 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 @@ -182,11 +183,16 @@ def latest_frame( frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA) - ret, img = cv2.imencode(f".{extension}", frame, quality_params) + _, img = cv2.imencode(f".{extension}", frame, quality_params) return Response( content=img.tobytes(), media_type=f"image/{mime_type}", - headers={"Content-Type": f"image/{mime_type}", "Cache-Control": "no-store"}, + 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( @@ -199,11 +205,16 @@ def latest_frame( frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA) - ret, img = cv2.imencode(f".{extension}", frame, quality_params) + _, img = cv2.imencode(f".{extension}", frame, quality_params) return Response( content=img.tobytes(), media_type=f"image/{mime_type}", - headers={"Content-Type": f"image/{mime_type}", "Cache-Control": "no-store"}, + headers={ + "Content-Type": f"image/{mime_type}", + "Cache-Control": "no-store" + if not params.store + else "private, max-age=60", + }, ) else: return JSONResponse( @@ -362,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""" @@ -1035,30 +1088,8 @@ def event_clip(request: Request, event_id: str): content={"success": False, "message": "Clip not available"}, status_code=404 ) - file_name = f"{event.camera}-{event.id}.mp4" - clip_path = os.path.join(CLIPS_DIR, file_name) - - if not os.path.isfile(clip_path): - end_ts = ( - datetime.now().timestamp() if event.end_time is None else event.end_time - ) - return recording_clip(request, event.camera, event.start_time, end_ts) - - headers = { - "Content-Description": "File Transfer", - "Cache-Control": "no-cache", - "Content-Type": "video/mp4", - "Content-Length": str(os.path.getsize(clip_path)), - # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers - "X-Accel-Redirect": f"/clips/{file_name}", - } - - return FileResponse( - clip_path, - media_type="video/mp4", - filename=file_name, - headers=headers, - ) + end_ts = datetime.now().timestamp() if event.end_time is None else event.end_time + return recording_clip(request, event.camera, event.start_time, end_ts) @router.get("/events/{event_id}/preview.gif") diff --git a/frigate/api/review.py b/frigate/api/review.py index 9266e1f8c..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) 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..61530d086 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,25 @@ 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, + "review_alerts": self._on_alerts_command, + "review_detections": self._on_detections_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 +121,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 +162,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,9 +170,18 @@ 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, + "alerts": self.config.cameras[camera].review.alerts.enabled, + "detections": self.config.cameras[camera].review.detections.enabled, } self.publish("camera_activity", json.dumps(camera_status)) @@ -191,6 +191,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 +205,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 +227,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 +373,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 +501,115 @@ 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, + ) + + def _on_alerts_command(self, camera_name: str, payload: str) -> None: + """Callback for alerts topic.""" + review_settings = self.config.cameras[camera_name].review + + if payload == "ON": + if not self.config.cameras[camera_name].review.alerts.enabled_in_config: + logger.error( + "Alerts must be enabled in the config to be turned on via MQTT." + ) + return + + if not review_settings.alerts.enabled: + logger.info(f"Turning on alerts for {camera_name}") + review_settings.alerts.enabled = True + elif payload == "OFF": + if review_settings.alerts.enabled: + logger.info(f"Turning off alerts for {camera_name}") + review_settings.alerts.enabled = False + + self.config_updater.publish(f"config/review/{camera_name}", review_settings) + self.publish(f"{camera_name}/review_alerts/state", payload, retain=True) + + def _on_detections_command(self, camera_name: str, payload: str) -> None: + """Callback for detections topic.""" + review_settings = self.config.cameras[camera_name].review + + if payload == "ON": + if not self.config.cameras[camera_name].review.detections.enabled_in_config: + logger.error( + "Detections must be enabled in the config to be turned on via MQTT." + ) + return + + if not review_settings.detections.enabled: + logger.info(f"Turning on detections for {camera_name}") + review_settings.detections.enabled = True + elif payload == "OFF": + if review_settings.detections.enabled: + logger.info(f"Turning off detections for {camera_name}") + review_settings.detections.enabled = False + + self.config_updater.publish(f"config/review/{camera_name}", review_settings) + self.publish(f"{camera_name}/review_detections/state", payload, 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..9e11a0af1 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: @@ -104,6 +107,16 @@ class MqttClient(Communicator): # type: ignore[misc] ), retain=True, ) + self.publish( + f"{camera_name}/review_alerts/state", + "ON" if camera.review.alerts.enabled_in_config else "OFF", + retain=True, + ) + self.publish( + f"{camera_name}/review_detections/state", + "ON" if camera.review.detections.enabled_in_config else "OFF", + retain=True, + ) if self.config.notifications.enabled_in_config: self.publish( @@ -151,7 +164,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 abfd52d19..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 @@ -155,49 +294,21 @@ class WebPushClient(Communicator): # type: ignore[misc] # 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/review.py b/frigate/config/camera/review.py index 549c37db4..d8d26edb9 100644 --- a/frigate/config/camera/review.py +++ b/frigate/config/camera/review.py @@ -13,6 +13,8 @@ DEFAULT_ALERT_OBJECTS = ["person", "car"] class AlertsConfig(FrigateBaseModel): """Configure alerts""" + enabled: bool = Field(default=True, title="Enable alerts.") + labels: list[str] = Field( default=DEFAULT_ALERT_OBJECTS, title="Labels to create alerts for." ) @@ -21,6 +23,10 @@ class AlertsConfig(FrigateBaseModel): title="List of required zones to be entered in order to save the event as an alert.", ) + enabled_in_config: Optional[bool] = Field( + default=None, title="Keep track of original state of alerts." + ) + @field_validator("required_zones", mode="before") @classmethod def validate_required_zones(cls, v): @@ -33,6 +39,8 @@ class AlertsConfig(FrigateBaseModel): class DetectionsConfig(FrigateBaseModel): """Configure detections""" + enabled: bool = Field(default=True, title="Enable detections.") + labels: Optional[list[str]] = Field( default=None, title="Labels to create detections for." ) @@ -41,6 +49,10 @@ class DetectionsConfig(FrigateBaseModel): title="List of required zones to be entered in order to save the event as a detection.", ) + enabled_in_config: Optional[bool] = Field( + default=None, title="Keep track of original state of detections." + ) + @field_validator("required_zones", mode="before") @classmethod def validate_required_zones(cls, v): diff --git a/frigate/config/camera/zone.py b/frigate/config/camera/zone.py index 37fc1b744..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) diff --git a/frigate/config/classification.py b/frigate/config/classification.py new file mode 100644 index 000000000..8a8e95861 --- /dev/null +++ b/frigate/config/classification.py @@ -0,0 +1,95 @@ +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.") + detection_threshold: float = Field( + default=0.7, + title="License plate object confidence score required to begin running recognition.", + gt=0.0, + le=1.0, + ) + min_area: int = Field( + default=1000, + title="Minimum area of license plate to begin running recognition.", + ) + recognition_threshold: float = Field( + default=0.9, + title="Recognition confidence score required to add the plate to the object as a sub label.", + gt=0.0, + le=1.0, + ) + min_plate_length: int = Field( + default=4, + title="Minimum number of characters a license plate must have to be added to the object as a sub label.", + ) + format: Optional[str] = Field( + default=None, + title="Regular expression for the expected format of license plate.", + ) + match_distance: int = Field( + default=1, + title="Allow this number of missing/incorrect characters to still cause a detected plate to match a known plate.", + ge=0, + ) + known_plates: Optional[Dict[str, List[str]]] = Field( + default={}, title="Known plates to track (strings or regular expressions)." + ) diff --git a/frigate/config/config.py b/frigate/config/config.py index 43db89b4f..39ee31411 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,9 +528,18 @@ 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 ) + camera_config.review.alerts.enabled_in_config = ( + camera_config.review.alerts.enabled + ) + camera_config.review.detections.enabled_in_config = ( + camera_config.review.detections.enabled + ) # Add default filters object_keys = camera_config.objects.track @@ -562,15 +597,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 +613,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 +647,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/real_time/license_plate_processor.py b/frigate/data_processing/real_time/license_plate_processor.py new file mode 100644 index 000000000..2d64e5cdb --- /dev/null +++ b/frigate/data_processing/real_time/license_plate_processor.py @@ -0,0 +1,1266 @@ +"""Handle processing images for face detection and recognition.""" + +import datetime +import logging +import math +import re +from typing import List, Optional, Tuple + +import cv2 +import numpy as np +import requests +from Levenshtein import distance +from pyclipper import ET_CLOSEDPOLYGON, JT_ROUND, PyclipperOffset +from shapely.geometry import Polygon + +from frigate.comms.inter_process import InterProcessRequestor +from frigate.config import FrigateConfig +from frigate.const import FRIGATE_LOCALHOST +from frigate.embeddings.functions.onnx import GenericONNXEmbedding, ModelTypeEnum +from frigate.util.image import area + +from ..types import DataProcessorMetrics +from .api import RealTimeProcessorApi + +logger = logging.getLogger(__name__) + +WRITE_DEBUG_IMAGES = False + + +class LicensePlateProcessor(RealTimeProcessorApi): + def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics): + super().__init__(config, metrics) + self.requestor = InterProcessRequestor() + self.lpr_config = 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]] = {} + + 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 + + self.lpr_detection_model = None + self.lpr_classification_model = None + self.lpr_recognition_model = None + + if self.config.lpr.enabled: + self.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.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.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", + ) + self.yolov9_detection_model = GenericONNXEmbedding( + model_name="yolov9_license_plate", + model_file="yolov9-256-license-plates.onnx", + download_urls={ + "yolov9-256-license-plates.onnx": "https://github.com/hawkeye217/yolov9-license-plates/raw/refs/heads/master/models/yolov9-256-license-plates.onnx" + }, + model_size="large", + model_type=ModelTypeEnum.yolov9_lpr_detect, + requestor=self.requestor, + device="CPU", + ) + + 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() + self.yolov9_detection_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) + + if WRITE_DEBUG_IMAGES: + current_time = int(datetime.datetime.now().timestamp()) + cv2.imwrite( + f"debug/frames/license_plate_resized_{current_time}.jpg", + 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: + logger.debug("No points found by OCR detector model") + 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) + + # debug rotated and classification result + if WRITE_DEBUG_IMAGES: + current_time = int(datetime.datetime.now().timestamp()) + for i, img in enumerate(plate_images): + cv2.imwrite( + f"debug/frames/license_plate_rotated_{current_time}_{i + 1}.jpg", + img, + ) + for i, img in enumerate(rotated_images): + cv2.imwrite( + f"debug/frames/license_plate_classified_{current_time}_{i + 1}.jpg", + img, + ) + + # 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"debug/frames/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 min_plate_length characters + # or that don't match the expected format (if defined) + # Sort by area, then by plate length, then by confidence all desc + filtered_data = [] + for plate, conf, area in zip(license_plates, average_confidences, areas): + if len(plate) < self.lpr_config.min_plate_length: + logger.debug( + f"Filtered out '{plate}' due to length ({len(plate)} < {self.lpr_config.min_plate_length})" + ) + continue + + if self.lpr_config.format and not re.fullmatch( + self.lpr_config.format, plate + ): + logger.debug(f"Filtered out '{plate}' due to format mismatch") + continue + + filtered_data.append((plate, conf, area)) + + sorted_data = sorted( + filtered_data, + 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) + logger.debug(f"min side {index}, {min_side}") + + if min_side < self.min_size: + continue + + points = np.array(points) + + score = self._box_score(output, contour) + logger.debug(f"box score {index}, {score}") + 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 5 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]) < 5 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] + # make sure we have high confidence if we need to flip a box, this will be rare in lpr + if "180" in label and score >= 0.9: + 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 + + # Compute mean pixel value of the resized image (per channel) + mean_pixel = np.mean(resized_image, axis=(1, 2), keepdims=True) + padded_image = np.full( + (input_shape[0], input_h, input_w), mean_pixel, 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 + + def __update_metrics(self, duration: float) -> None: + """ + Update inference metrics. + """ + self.metrics.alpr_pps.value = (self.metrics.alpr_pps.value * 9 + duration) / 10 + + def _detect_license_plate(self, input: np.ndarray) -> tuple[int, int, int, int]: + """ + Use a lightweight YOLOv9 model to detect license plates for users without Frigate+ + + Return the dimensions of the detected plate as [x1, y1, x2, y2]. + """ + predictions = self.yolov9_detection_model(input) + + confidence_threshold = self.lpr_config.detection_threshold + + top_score = -1 + top_box = None + + # Loop over predictions + for prediction in predictions: + score = prediction[6] + if score >= confidence_threshold: + bbox = prediction[1:5] + # Scale boxes back to original image size + scale_x = input.shape[1] / 256 + scale_y = input.shape[0] / 256 + bbox[0] *= scale_x + bbox[1] *= scale_y + bbox[2] *= scale_x + bbox[3] *= scale_y + + if score > top_score: + top_score = score + top_box = bbox + + # Return the top scoring bounding box if found + if top_box is not None: + # expand box by 15% to help with OCR + expansion = (top_box[2:] - top_box[:2]) * 0.1 + + # Expand box + expanded_box = np.array( + [ + top_box[0] - expansion[0], # x1 + top_box[1] - expansion[1], # y1 + top_box[2] + expansion[0], # x2 + top_box[3] + expansion[1], # y2 + ] + ).clip(0, [input.shape[1], input.shape[0]] * 2) + + logger.debug(f"Found license plate: {expanded_box.astype(int)}") + return tuple(expanded_box.astype(int)) + else: + return None # No detection above the threshold + + def _should_keep_previous_plate( + self, id, top_plate, top_char_confidences, top_area, avg_confidence + ): + if id not in self.detected_license_plates: + return False + + prev_data = self.detected_license_plates[id] + prev_plate = prev_data["plate"] + prev_char_confidences = prev_data["char_confidences"] + prev_area = prev_data["area"] + prev_avg_confidence = ( + sum(prev_char_confidences) / len(prev_char_confidences) + if prev_char_confidences + else 0 + ) + + # 1. Normalize metrics + # Length score - use relative comparison + # If lengths are equal, score is 0.5 for both + # If one is longer, it gets a higher score up to 1.0 + max_length_diff = 4 # Maximum expected difference in plate lengths + length_diff = len(top_plate) - len(prev_plate) + curr_length_score = 0.5 + ( + length_diff / (2 * max_length_diff) + ) # Normalize to 0-1 + curr_length_score = max(0, min(1, curr_length_score)) # Clamp to 0-1 + prev_length_score = 1 - curr_length_score # Inverse relationship + + # Area score (normalize based on max of current and previous) + max_area = max(top_area, prev_area) + curr_area_score = top_area / max_area + prev_area_score = prev_area / max_area + + # Average confidence score (already normalized 0-1) + curr_conf_score = avg_confidence + prev_conf_score = prev_avg_confidence + + # Character confidence comparison score + min_length = min(len(top_plate), len(prev_plate)) + if min_length > 0: + curr_char_conf = sum(top_char_confidences[:min_length]) / min_length + prev_char_conf = sum(prev_char_confidences[:min_length]) / min_length + else: + curr_char_conf = 0 + prev_char_conf = 0 + + # 2. Define weights + weights = { + "length": 0.4, + "area": 0.3, + "avg_confidence": 0.2, + "char_confidence": 0.1, + } + + # 3. Calculate weighted scores + curr_score = ( + curr_length_score * weights["length"] + + curr_area_score * weights["area"] + + curr_conf_score * weights["avg_confidence"] + + curr_char_conf * weights["char_confidence"] + ) + + prev_score = ( + prev_length_score * weights["length"] + + prev_area_score * weights["area"] + + prev_conf_score * weights["avg_confidence"] + + prev_char_conf * weights["char_confidence"] + ) + + # 4. Log the comparison for debugging + logger.debug( + f"Plate comparison - Current plate: {top_plate} (score: {curr_score:.3f}) vs " + f"Previous plate: {prev_plate} (score: {prev_score:.3f})\n" + f"Metrics - Length: {len(top_plate)} vs {len(prev_plate)} (scores: {curr_length_score:.2f} vs {prev_length_score:.2f}), " + f"Area: {top_area} vs {prev_area}, " + f"Avg Conf: {avg_confidence:.2f} vs {prev_avg_confidence:.2f}" + ) + + # 5. Return True if we should keep the previous plate (i.e., if it scores higher) + return prev_score > curr_score + + def process_frame(self, obj_data: dict[str, any], frame: np.ndarray): + """Look for license plates in image.""" + start = datetime.datetime.now().timestamp() + + 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 + + # 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 + + # 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 + + 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 + + rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) + left, top, right, bottom = car_box + car = rgb[top:bottom, left:right] + + # double the size of the car for better box detection + car = cv2.resize(car, (int(2 * car.shape[1]), int(2 * car.shape[0]))) + + if WRITE_DEBUG_IMAGES: + current_time = int(datetime.datetime.now().timestamp()) + cv2.imwrite( + f"debug/frames/car_frame_{current_time}.jpg", + car, + ) + + yolov9_start = datetime.datetime.now().timestamp() + license_plate = self._detect_license_plate(car) + logger.debug( + f"YOLOv9 LPD inference time: {(datetime.datetime.now().timestamp() - yolov9_start) * 1000:.2f} ms" + ) + + if not license_plate: + logger.debug("Detected no license plates for car object.") + return + + license_plate_area = max( + 0, + (license_plate[2] - license_plate[0]) + * (license_plate[3] - license_plate[1]), + ) + + # check that license plate is valid + # double the value because we've doubled the size of the car + if license_plate_area < self.config.lpr.min_area * 2: + logger.debug("License plate is less than min_area") + return + + license_plate_frame = car[ + license_plate[1] : license_plate[3], license_plate[0] : license_plate[2] + ] + 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") != "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 + + if license_plate.get("score") < self.lpr_config.detection_threshold: + logger.debug( + f"Plate detection score is less than the threshold ({license_plate['score']:0.2f} < {self.lpr_config.detection_threshold})" + ) + return + + 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 + + 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], + ] + + # double the size of the license plate frame for better OCR + license_plate_frame = cv2.resize( + license_plate_frame, + ( + int(2 * license_plate_frame.shape[1]), + int(2 * license_plate_frame.shape[0]), + ), + ) + + if WRITE_DEBUG_IMAGES: + current_time = int(datetime.datetime.now().timestamp()) + cv2.imwrite( + f"debug/frames/license_plate_frame_{current_time}.jpg", + license_plate_frame, + ) + + # run detection, returns results sorted by confidence, best first + license_plates, confidences, areas = self._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 + + 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: + if self._should_keep_previous_plate( + id, top_plate, top_char_confidences, top_area, avg_confidence + ): + logger.debug("Keeping previous plate") + return + + # Check against minimum confidence threshold + if avg_confidence < self.lpr_config.recognition_threshold: + logger.debug( + f"Average confidence {avg_confidence} is less than threshold ({self.lpr_config.recognition_threshold})" + ) + return + + # 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) + or distance(plate, top_plate) <= self.lpr_config.match_distance + 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, + } + + self.__update_metrics(datetime.datetime.now().timestamp() - start) + + def handle_request(self, topic, request_data) -> dict[str, any] | None: + return + + def expire_object(self, object_id: str): + if object_id in self.detected_license_plates: + self.detected_license_plates.pop(object_id) + + +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/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/openvino.py b/frigate/detectors/plugins/openvino.py index 51e48530b..27be6b9bd 100644 --- a/frigate/detectors/plugins/openvino.py +++ b/frigate/detectors/plugins/openvino.py @@ -9,6 +9,7 @@ from typing_extensions import Literal from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum +from frigate.util.model import post_process_yolov9 logger = logging.getLogger(__name__) @@ -22,7 +23,12 @@ class OvDetectorConfig(BaseDetectorConfig): class OvDetector(DetectionApi): type_key = DETECTOR_KEY - supported_models = [ModelTypeEnum.ssd, ModelTypeEnum.yolonas, ModelTypeEnum.yolox] + supported_models = [ + ModelTypeEnum.ssd, + ModelTypeEnum.yolonas, + ModelTypeEnum.yolov9, + ModelTypeEnum.yolox, + ] def __init__(self, detector_config: OvDetectorConfig): self.ov_core = ov.Core() @@ -160,8 +166,7 @@ class OvDetector(DetectionApi): if self.model_invalid: return detections - - if self.ov_model_type == ModelTypeEnum.ssd: + elif self.ov_model_type == ModelTypeEnum.ssd: results = infer_request.get_output_tensor(0).data[0][0] for i, (_, class_id, score, xmin, ymin, xmax, ymax) in enumerate(results): @@ -176,8 +181,7 @@ class OvDetector(DetectionApi): xmax, ] return detections - - if self.ov_model_type == ModelTypeEnum.yolonas: + elif self.ov_model_type == ModelTypeEnum.yolonas: predictions = infer_request.get_output_tensor(0).data for i, prediction in enumerate(predictions): @@ -196,8 +200,10 @@ class OvDetector(DetectionApi): x_max / self.w, ] return detections - - if self.ov_model_type == ModelTypeEnum.yolox: + elif self.ov_model_type == ModelTypeEnum.yolov9: + out_tensor = infer_request.get_output_tensor(0).data + return post_process_yolov9(out_tensor, self.w, self.h) + elif self.ov_model_type == ModelTypeEnum.yolox: out_tensor = infer_request.get_output_tensor() # [x, y, h, w, box_score, class_no_1, ..., class_no_80], results = out_tensor.data 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/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..d8a4a2f4d 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,10 +125,10 @@ 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", ) def embed_thumbnail( @@ -130,6 +140,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 +153,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 +168,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 +187,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 +209,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 +242,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..a8d52922b 100644 --- a/frigate/embeddings/functions/onnx.py +++ b/frigate/embeddings/functions/onnx.py @@ -5,6 +5,7 @@ from enum import Enum from io import BytesIO from typing import Dict, List, Optional, Union +import cv2 import numpy as np import requests from PIL import Image @@ -31,11 +32,18 @@ warnings.filterwarnings( disable_progress_bar() logger = logging.getLogger(__name__) +FACE_EMBEDDING_SIZE = 160 +LPR_EMBEDDING_SIZE = 256 + class ModelTypeEnum(str, Enum): face = "face" vision = "vision" text = "text" + lpr_detect = "lpr_detect" + lpr_classify = "lpr_classify" + lpr_recognize = "lpr_recognize" + yolov9_lpr_detect = "yolov9_lpr_detect" class GenericONNXEmbedding: @@ -47,7 +55,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 +65,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 +95,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 +110,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 +135,25 @@ 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 = [] + elif self.model_type == ModelTypeEnum.yolov9_lpr_detect: + self.feature_extractor = [] + self.runner = ONNXModelRunner( os.path.join(self.download_path, self.model_file), self.device, @@ -172,23 +193,111 @@ 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 + elif self.model_type == ModelTypeEnum.yolov9_lpr_detect: + if isinstance(raw_inputs, list): + raise ValueError( + "License plate embedding does not support batch inputs." + ) + # Get image as numpy array + img = self._process_image(raw_inputs) + height, width, channels = img.shape + + # Resize maintaining aspect ratio + if width > height: + new_height = int(((height / width) * LPR_EMBEDDING_SIZE) // 4 * 4) + img = cv2.resize(img, (LPR_EMBEDDING_SIZE, new_height)) + else: + new_width = int(((width / height) * LPR_EMBEDDING_SIZE) // 4 * 4) + img = cv2.resize(img, (new_width, LPR_EMBEDDING_SIZE)) + + # Get new dimensions after resize + og_h, og_w, channels = img.shape + + # Create black square frame + frame = np.full( + (LPR_EMBEDDING_SIZE, LPR_EMBEDDING_SIZE, channels), + (0, 0, 0), + dtype=np.float32, + ) + + # Center the resized image in the square frame + x_center = (LPR_EMBEDDING_SIZE - og_w) // 2 + y_center = (LPR_EMBEDDING_SIZE - og_h) // 2 + frame[y_center : y_center + og_h, x_center : x_center + og_w] = img + + # Normalize to 0-1 + frame = frame / 255.0 + + # Convert from HWC to CHW format and add batch dimension + frame = np.transpose(frame, (2, 0, 1)) + frame = np.expand_dims(frame, axis=0) + return [{"images": frame}] 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/maintainer.py b/frigate/embeddings/maintainer.py index 341a2e25d..b7623722d 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -21,7 +21,17 @@ 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, + 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.real_time.license_plate_processor import ( + LicensePlateProcessor, +) +from frigate.data_processing.types import DataProcessorMetrics from frigate.events.types import EventTypeEnum from frigate.genai import get_genai_client from frigate.models import Event @@ -43,11 +53,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,10 +72,21 @@ 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)) + + if self.config.lpr.enabled: + self.processors.append(LicensePlateProcessor(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) def run(self) -> None: @@ -84,7 +107,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 +124,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 +140,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 +151,49 @@ 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 - if ( - not camera_config.genai.enabled - or self.genai_client is None - or data["stationary"] - ): - return - if data["id"] not in self.tracked_events: - self.tracked_events[data["id"]] = [] + # no need to process updated objects if face recognition, lpr, genai are disabled + if not camera_config.genai.enabled and len(self.processors) == 0: + return # 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) + + # 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 +201,9 @@ 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 updated_db: try: event: Event = Event.get(Event.id == event_id) @@ -277,7 +317,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: diff --git a/frigate/events/maintainer.py b/frigate/events/maintainer.py index 68e7432ab..d49da5a97 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 @@ -184,7 +187,7 @@ class EventProcessor(threading.Thread): ) # keep these from being set back to false because the event - # may have started while recordings and snapshots were enabled + # may have started while recordings/snapshots/alerts/detections were enabled # this would be an issue for long running events if self.events_in_process[event_data["id"]]["has_clip"]: event_data["has_clip"] = True @@ -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 df0e36ff8..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, @@ -490,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/motion/improved_motion.py b/frigate/motion/improved_motion.py index 297337560..d865cc92d 100644 --- a/frigate/motion/improved_motion.py +++ b/frigate/motion/improved_motion.py @@ -5,6 +5,7 @@ import imutils import numpy as np from scipy.ndimage import gaussian_filter +from frigate.camera import PTZMetrics from frigate.comms.config_updater import ConfigSubscriber from frigate.config import MotionConfig from frigate.motion import MotionDetector @@ -18,6 +19,7 @@ class ImprovedMotionDetector(MotionDetector): frame_shape, config: MotionConfig, fps: int, + ptz_metrics: PTZMetrics = None, name="improved", blur_radius=1, interpolation=cv2.INTER_NEAREST, @@ -48,6 +50,8 @@ class ImprovedMotionDetector(MotionDetector): self.contrast_values[:, 1:2] = 255 self.contrast_values_index = 0 self.config_subscriber = ConfigSubscriber(f"config/motion/{name}") + self.ptz_metrics = ptz_metrics + self.last_stop_time = None def is_calibrating(self): return self.calibrating @@ -64,6 +68,21 @@ class ImprovedMotionDetector(MotionDetector): if not self.config.enabled: return motion_boxes + # if ptz motor is moving from autotracking, quickly return + # a single box that is 80% of the frame + if ( + self.ptz_metrics.autotracker_enabled.value + and not self.ptz_metrics.motor_stopped.is_set() + ): + return [ + ( + int(self.frame_shape[1] * 0.1), + int(self.frame_shape[0] * 0.1), + int(self.frame_shape[1] * 0.9), + int(self.frame_shape[0] * 0.9), + ) + ] + gray = frame[0 : self.frame_shape[0], 0 : self.frame_shape[1]] # resize frame @@ -151,6 +170,25 @@ class ImprovedMotionDetector(MotionDetector): self.motion_frame_size[0] * self.motion_frame_size[1] ) + # check if the motor has just stopped from autotracking + # if so, reassign the average to the current frame so we begin with a new baseline + if ( + # ensure we only do this for cameras with autotracking enabled + self.ptz_metrics.autotracker_enabled.value + and self.ptz_metrics.motor_stopped.is_set() + and ( + self.last_stop_time is None + or self.ptz_metrics.stop_time.value != self.last_stop_time + ) + # value is 0 on startup or when motor is moving + and self.ptz_metrics.stop_time.value != 0 + ): + self.last_stop_time = self.ptz_metrics.stop_time.value + + self.avg_frame = resized_frame.astype(np.float32) + motion_boxes = [] + pct_motion = 0 + # once the motion is less than 5% and the number of contours is < 4, assume its calibrated if pct_motion < 0.05 and len(motion_boxes) <= 4: self.calibrating = False 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 bdc6d8d72..484f4a082 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/ptz/autotrack.py b/frigate/ptz/autotrack.py index fc6284f40..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 @@ -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" ) @@ -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: diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py index 82529e967..1a813c799 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,7 +399,6 @@ 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. @@ -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 @@ -471,17 +465,17 @@ class OnvifController: return self.cams[camera_name]["active"] = True - self.ptz_metrics[camera_name].motor_stopped.clear() 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 +492,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 +499,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,7 +523,6 @@ 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. @@ -548,7 +540,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 +552,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 +582,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 +596,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 +626,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/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/review/maintainer.py b/frigate/review/maintainer.py index c99479a67..158bc3ac4 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -148,7 +148,8 @@ class ReviewSegmentMaintainer(threading.Thread): # create communication for review segments self.requestor = InterProcessRequestor() - self.config_subscriber = ConfigSubscriber("config/record/") + self.record_config_subscriber = ConfigSubscriber("config/record/") + self.review_config_subscriber = ConfigSubscriber("config/review/") self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.all) # manual events @@ -226,6 +227,13 @@ class ReviewSegmentMaintainer(threading.Thread): ) self.active_review_segments[segment.camera] = None + def end_segment(self, camera: str) -> None: + """End the pending segment for a camera.""" + segment = self.active_review_segments.get(camera) + if segment: + prev_data = segment.get_data(False) + self._publish_segment_end(segment, prev_data) + def update_existing_segment( self, segment: PendingReviewSegment, @@ -273,6 +281,7 @@ class ReviewSegmentMaintainer(threading.Thread): & set(camera_config.review.alerts.required_zones) ) ) + and camera_config.review.alerts.enabled ): segment.severity = SeverityEnum.alert should_update = True @@ -369,13 +378,14 @@ class ReviewSegmentMaintainer(threading.Thread): & set(camera_config.review.alerts.required_zones) ) ) + and camera_config.review.alerts.enabled ): severity = SeverityEnum.alert # if object is detection label # and review is not already a detection or alert # and has entered required zones or required zones is not set - # mark this review as alert + # mark this review as detection if ( not severity and ( @@ -390,6 +400,7 @@ class ReviewSegmentMaintainer(threading.Thread): & set(camera_config.review.detections.required_zones) ) ) + and camera_config.review.detections.enabled ): severity = SeverityEnum.detection @@ -430,15 +441,25 @@ class ReviewSegmentMaintainer(threading.Thread): # check if there is an updated config while True: ( - updated_topic, + updated_record_topic, updated_record_config, - ) = self.config_subscriber.check_for_update() + ) = self.record_config_subscriber.check_for_update() - if not updated_topic: + ( + updated_review_topic, + updated_review_config, + ) = self.review_config_subscriber.check_for_update() + + if not updated_record_topic and not updated_review_topic: break - camera_name = updated_topic.rpartition("/")[-1] - self.config.cameras[camera_name].record = updated_record_config + if updated_record_topic: + camera_name = updated_record_topic.rpartition("/")[-1] + self.config.cameras[camera_name].record = updated_record_config + + if updated_review_topic: + camera_name = updated_review_topic.rpartition("/")[-1] + self.config.cameras[camera_name].review = updated_review_config (topic, data) = self.detection_subscriber.check_for_update(timeout=1) @@ -475,12 +496,22 @@ class ReviewSegmentMaintainer(threading.Thread): if not self.config.cameras[camera].record.enabled: if current_segment: - self.update_existing_segment( - current_segment, frame_name, frame_time, [] - ) - + self.end_segment(camera) continue + # Check if the current segment should be processed based on enabled settings + if current_segment: + if ( + current_segment.severity == SeverityEnum.alert + and not self.config.cameras[camera].review.alerts.enabled + ) or ( + current_segment.severity == SeverityEnum.detection + and not self.config.cameras[camera].review.detections.enabled + ): + self.end_segment(camera) + continue + + # If we reach here, the segment can be processed (if it exists) if current_segment is not None: if topic == DetectionTypeEnum.video: self.update_existing_segment( @@ -496,20 +527,24 @@ class ReviewSegmentMaintainer(threading.Thread): current_segment.last_update = frame_time for audio in audio_detections: - if audio in camera_config.review.alerts.labels: + if ( + audio in camera_config.review.alerts.labels + and camera_config.review.alerts.enabled + ): current_segment.audio.add(audio) current_segment.severity = SeverityEnum.alert elif ( camera_config.review.detections.labels is None or audio in camera_config.review.detections.labels - ): + ) and camera_config.review.detections.enabled: current_segment.audio.add(audio) elif topic == DetectionTypeEnum.api: if manual_info["state"] == ManualEventState.complete: current_segment.detections[manual_info["event_id"]] = ( manual_info["label"] ) - current_segment.severity = SeverityEnum.alert + if self.config.cameras[camera].review.alerts.enabled: + current_segment.severity = SeverityEnum.alert current_segment.last_update = manual_info["end_time"] elif manual_info["state"] == ManualEventState.start: self.indefinite_events[camera][manual_info["event_id"]] = ( @@ -518,7 +553,8 @@ class ReviewSegmentMaintainer(threading.Thread): current_segment.detections[manual_info["event_id"]] = ( manual_info["label"] ) - current_segment.severity = SeverityEnum.alert + if self.config.cameras[camera].review.alerts.enabled: + current_segment.severity = SeverityEnum.alert # temporarily make it so this event can not end current_segment.last_update = sys.maxsize @@ -536,12 +572,16 @@ class ReviewSegmentMaintainer(threading.Thread): ) else: if topic == DetectionTypeEnum.video: - self.check_if_new_segment( - camera, - frame_name, - frame_time, - current_tracked_objects, - ) + if ( + self.config.cameras[camera].review.alerts.enabled + or self.config.cameras[camera].review.detections.enabled + ): + self.check_if_new_segment( + camera, + frame_name, + frame_time, + current_tracked_objects, + ) elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0: severity = None @@ -549,13 +589,16 @@ class ReviewSegmentMaintainer(threading.Thread): detections = set() for audio in audio_detections: - if audio in camera_config.review.alerts.labels: + if ( + audio in camera_config.review.alerts.labels + and camera_config.review.alerts.enabled + ): detections.add(audio) severity = SeverityEnum.alert elif ( camera_config.review.detections.labels is None or audio in camera_config.review.detections.labels - ): + ) and camera_config.review.detections.enabled: detections.add(audio) if not severity: @@ -572,28 +615,36 @@ class ReviewSegmentMaintainer(threading.Thread): detections, ) elif topic == DetectionTypeEnum.api: - self.active_review_segments[camera] = PendingReviewSegment( - camera, - frame_time, - SeverityEnum.alert, - {manual_info["event_id"]: manual_info["label"]}, - {}, - [], - set(), - ) - - if manual_info["state"] == ManualEventState.start: - self.indefinite_events[camera][manual_info["event_id"]] = ( - manual_info["label"] + if self.config.cameras[camera].review.alerts.enabled: + self.active_review_segments[camera] = PendingReviewSegment( + camera, + frame_time, + SeverityEnum.alert, + {manual_info["event_id"]: manual_info["label"]}, + {}, + [], + set(), ) - # temporarily make it so this event can not end - self.active_review_segments[camera].last_update = sys.maxsize - elif manual_info["state"] == ManualEventState.complete: - self.active_review_segments[camera].last_update = manual_info[ - "end_time" - ] - self.config_subscriber.stop() + if manual_info["state"] == ManualEventState.start: + self.indefinite_events[camera][manual_info["event_id"]] = ( + manual_info["label"] + ) + # temporarily make it so this event can not end + self.active_review_segments[ + camera + ].last_update = sys.maxsize + elif manual_info["state"] == ManualEventState.complete: + self.active_review_segments[ + camera + ].last_update = manual_info["end_time"] + else: + logger.warning( + f"Manual event API has been called for {camera}, but alerts are disabled. This manual event will not appear as an alert." + ) + + self.record_config_subscriber.stop() + self.review_config_subscriber.stop() self.requestor.stop() self.detection_subscriber.stop() logger.info("Exiting review maintainer...") 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/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/track/norfair_tracker.py b/frigate/track/norfair_tracker.py index 67950bd0c..d168bfe94 100644 --- a/frigate/track/norfair_tracker.py +++ b/frigate/track/norfair_tracker.py @@ -1,7 +1,9 @@ import logging import random import string +from typing import Sequence +import cv2 import numpy as np from norfair import ( Detection, @@ -11,12 +13,19 @@ from norfair import ( draw_boxes, ) from norfair.drawing.drawer import Drawer +from rich import print +from rich.console import Console +from rich.table import Table from frigate.camera import PTZMetrics from frigate.config import CameraConfig from frigate.ptz.autotrack import PtzMotionEstimator from frigate.track import ObjectTracker -from frigate.util.image import intersection_over_union +from frigate.util.image import ( + SharedMemoryFrameManager, + get_histogram, + intersection_over_union, +) from frigate.util.object import average_boxes, median_of_boxes logger = logging.getLogger(__name__) @@ -71,12 +80,36 @@ def frigate_distance(detection: Detection, tracked_object) -> float: return distance(detection.points, tracked_object.estimate) +def histogram_distance(matched_not_init_trackers, unmatched_trackers): + snd_embedding = unmatched_trackers.last_detection.embedding + + if snd_embedding is None: + for detection in reversed(unmatched_trackers.past_detections): + if detection.embedding is not None: + snd_embedding = detection.embedding + break + else: + return 1 + + for detection_fst in matched_not_init_trackers.past_detections: + if detection_fst.embedding is None: + continue + + distance = 1 - cv2.compareHist( + snd_embedding, detection_fst.embedding, cv2.HISTCMP_CORREL + ) + if distance < 0.5: + return distance + return 1 + + class NorfairTracker(ObjectTracker): def __init__( self, config: CameraConfig, ptz_metrics: PTZMetrics, ): + self.frame_manager = SharedMemoryFrameManager() self.tracked_objects = {} self.untracked_object_boxes: list[list[int]] = [] self.disappeared = {} @@ -88,26 +121,137 @@ class NorfairTracker(ObjectTracker): self.ptz_motion_estimator = {} self.camera_name = config.name self.track_id_map = {} - # TODO: could also initialize a tracker per object class if there - # was a good reason to have different distance calculations - self.tracker = Tracker( - distance_function=frigate_distance, - distance_threshold=2.5, - initialization_delay=self.detect_config.min_initialized, - hit_counter_max=self.detect_config.max_disappeared, - # use default filter factory with custom values - # R is the multiplier for the sensor measurement noise matrix, default of 4.0 - # lowering R means that we trust the position of the bounding boxes more - # testing shows that the prediction was being relied on a bit too much - # TODO: could use different kalman filter values along with - # the different tracker per object class - filter_factory=OptimizedKalmanFilterFactory(R=3.4), - ) + + # Define tracker configurations for static camera + self.object_type_configs = { + "car": { + "filter_factory": OptimizedKalmanFilterFactory(R=3.4, Q=0.03), + "distance_function": frigate_distance, + "distance_threshold": 2.5, + }, + } + + # Define autotracking PTZ-specific configurations + self.ptz_object_type_configs = { + "person": { + "filter_factory": OptimizedKalmanFilterFactory( + R=4.5, + Q=0.25, + ), + "distance_function": frigate_distance, + "distance_threshold": 2, + "past_detections_length": 5, + "reid_distance_function": histogram_distance, + "reid_distance_threshold": 0.5, + "reid_hit_counter_max": 10, + }, + } + + # Default tracker configuration + # use default filter factory with custom values + # R is the multiplier for the sensor measurement noise matrix, default of 4.0 + # lowering R means that we trust the position of the bounding boxes more + # testing shows that the prediction was being relied on a bit too much + self.default_tracker_config = { + "filter_factory": OptimizedKalmanFilterFactory(R=3.4), + "distance_function": frigate_distance, + "distance_threshold": 2.5, + } + + self.default_ptz_tracker_config = { + "filter_factory": OptimizedKalmanFilterFactory(R=4, Q=0.2), + "distance_function": frigate_distance, + "distance_threshold": 3, + } + + self.trackers = {} + # Handle static trackers + for obj_type, tracker_config in self.object_type_configs.items(): + if obj_type in self.camera_config.objects.track: + if obj_type not in self.trackers: + self.trackers[obj_type] = {} + self.trackers[obj_type]["static"] = self._create_tracker( + obj_type, tracker_config + ) + + # Handle PTZ trackers + for obj_type, tracker_config in self.ptz_object_type_configs.items(): + if ( + obj_type in self.camera_config.onvif.autotracking.track + and self.camera_config.onvif.autotracking.enabled_in_config + ): + if obj_type not in self.trackers: + self.trackers[obj_type] = {} + self.trackers[obj_type]["ptz"] = self._create_tracker( + obj_type, tracker_config + ) + + # Initialize default trackers + self.default_tracker = { + "static": Tracker( + distance_function=frigate_distance, + distance_threshold=self.default_tracker_config["distance_threshold"], + initialization_delay=self.detect_config.min_initialized, + hit_counter_max=self.detect_config.max_disappeared, + filter_factory=self.default_tracker_config["filter_factory"], + ), + "ptz": Tracker( + distance_function=frigate_distance, + distance_threshold=self.default_ptz_tracker_config[ + "distance_threshold" + ], + initialization_delay=self.detect_config.min_initialized, + hit_counter_max=self.detect_config.max_disappeared, + filter_factory=self.default_ptz_tracker_config["filter_factory"], + ), + } + if self.ptz_metrics.autotracker_enabled.value: self.ptz_motion_estimator = PtzMotionEstimator( self.camera_config, self.ptz_metrics ) + def _create_tracker(self, obj_type, tracker_config): + """Helper function to create a tracker with given configuration.""" + tracker_params = { + "distance_function": tracker_config["distance_function"], + "distance_threshold": tracker_config["distance_threshold"], + "initialization_delay": self.detect_config.min_initialized, + "hit_counter_max": self.detect_config.max_disappeared, + "filter_factory": tracker_config["filter_factory"], + } + + # Add reid parameters if max_frames is None + if ( + self.detect_config.stationary.max_frames.objects.get( + obj_type, self.detect_config.stationary.max_frames.default + ) + is None + ): + reid_keys = [ + "past_detections_length", + "reid_distance_function", + "reid_distance_threshold", + "reid_hit_counter_max", + ] + tracker_params.update( + {key: tracker_config[key] for key in reid_keys if key in tracker_config} + ) + + return Tracker(**tracker_params) + + def get_tracker(self, object_type: str) -> Tracker: + """Get the appropriate tracker based on object type and camera mode.""" + mode = ( + "ptz" + if self.camera_config.onvif.autotracking.enabled_in_config + and object_type in self.camera_config.onvif.autotracking.track + else "static" + ) + if object_type in self.trackers: + return self.trackers[object_type][mode] + return self.default_tracker[mode] + def register(self, track_id, obj): rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) id = f"{obj['frame_time']}-{rand_id}" @@ -116,10 +260,13 @@ class NorfairTracker(ObjectTracker): obj["start_time"] = obj["frame_time"] obj["motionless_count"] = 0 obj["position_changes"] = 0 + + # Get the correct tracker for this object's label + tracker = self.get_tracker(obj["label"]) obj["score_history"] = [ p.data["score"] for p in next( - (o for o in self.tracker.tracked_objects if o.global_id == track_id) + (o for o in tracker.tracked_objects if o.global_id == track_id) ).past_detections ] self.tracked_objects[id] = obj @@ -137,11 +284,25 @@ class NorfairTracker(ObjectTracker): self.stationary_box_history[id] = [] def deregister(self, id, track_id): + obj = self.tracked_objects[id] + del self.tracked_objects[id] del self.disappeared[id] - self.tracker.tracked_objects = [ - o for o in self.tracker.tracked_objects if o.global_id != track_id - ] + + # only manually deregister objects from norfair's list if max_frames is defined + if ( + self.detect_config.stationary.max_frames.objects.get( + obj["label"], self.detect_config.stationary.max_frames.default + ) + is not None + ): + tracker = self.get_tracker(obj["label"]) + tracker.tracked_objects = [ + o + for o in tracker.tracked_objects + if o.global_id != track_id and o.hit_counter < 0 + ] + del self.track_id_map[track_id] # tracks the current position of the object based on the last N bounding boxes @@ -287,9 +448,13 @@ class NorfairTracker(ObjectTracker): def match_and_update( self, frame_name: str, frame_time: float, detections: list[dict[str, any]] ): - norfair_detections = [] - + # Group detections by object type + detections_by_type = {} for obj in detections: + label = obj[0] + if label not in detections_by_type: + detections_by_type[label] = [] + # centroid is used for other things downstream centroid_x = int((obj[2][0] + obj[2][2]) / 2.0) centroid_y = int((obj[2][1] + obj[2][3]) / 2.0) @@ -297,22 +462,32 @@ class NorfairTracker(ObjectTracker): # track based on top,left and bottom,right corners instead of centroid points = np.array([[obj[2][0], obj[2][1]], [obj[2][2], obj[2][3]]]) - norfair_detections.append( - Detection( - points=points, - label=obj[0], - data={ - "label": obj[0], - "score": obj[1], - "box": obj[2], - "area": obj[3], - "ratio": obj[4], - "region": obj[5], - "frame_time": frame_time, - "centroid": (centroid_x, centroid_y), - }, + embedding = None + if self.ptz_metrics.autotracker_enabled.value: + yuv_frame = self.frame_manager.get( + frame_name, self.camera_config.frame_shape_yuv ) + embedding = get_histogram( + yuv_frame, obj[2][0], obj[2][1], obj[2][2], obj[2][3] + ) + + detection = Detection( + points=points, + label=label, + # TODO: stationary objects won't have embeddings + embedding=embedding, + data={ + "label": label, + "score": obj[1], + "box": obj[2], + "area": obj[3], + "ratio": obj[4], + "region": obj[5], + "frame_time": frame_time, + "centroid": (centroid_x, centroid_y), + }, ) + detections_by_type[label].append(detection) coord_transformations = None @@ -327,13 +502,32 @@ class NorfairTracker(ObjectTracker): detections, frame_name, frame_time, self.camera_name ) - tracked_objects = self.tracker.update( - detections=norfair_detections, coord_transformations=coord_transformations + # Update all configured trackers + all_tracked_objects = [] + for label in self.trackers: + tracker = self.get_tracker(label) + tracked_objects = tracker.update( + detections=detections_by_type.get(label, []), + coord_transformations=coord_transformations, + ) + all_tracked_objects.extend(tracked_objects) + + # Collect detections for objects without specific trackers + default_detections = [] + for label, dets in detections_by_type.items(): + if label not in self.trackers: + default_detections.extend(dets) + + # Update default tracker with untracked detections + mode = "ptz" if self.ptz_metrics.autotracker_enabled.value else "static" + tracked_objects = self.default_tracker[mode].update( + detections=default_detections, coord_transformations=coord_transformations ) + all_tracked_objects.extend(tracked_objects) # update or create new tracks active_ids = [] - for t in tracked_objects: + for t in all_tracked_objects: estimate = tuple(t.estimate.flatten().astype(int)) # keep the estimate within the bounds of the image estimate = ( @@ -373,19 +567,55 @@ class NorfairTracker(ObjectTracker): o[2] for o in detections if o[2] not in tracked_object_boxes ] + def print_objects_as_table(self, tracked_objects: Sequence): + """Used for helping in debugging""" + print() + console = Console() + table = Table(show_header=True, header_style="bold magenta") + table.add_column("Id", style="yellow", justify="center") + table.add_column("Age", justify="right") + table.add_column("Hit Counter", justify="right") + table.add_column("Last distance", justify="right") + table.add_column("Init Id", justify="center") + for obj in tracked_objects: + table.add_row( + str(obj.id), + str(obj.age), + str(obj.hit_counter), + f"{obj.last_distance:.4f}" if obj.last_distance is not None else "N/A", + str(obj.initializing_id), + ) + console.print(table) + def debug_draw(self, frame, frame_time): + # Collect all tracked objects from each tracker + all_tracked_objects = [] + + # print a table to the console with norfair tracked object info + if False: + self.print_objects_as_table(self.trackers["person"]["ptz"].tracked_objects) + + # Get tracked objects from type-specific trackers + for object_trackers in self.trackers.values(): + for tracker in object_trackers.values(): + all_tracked_objects.extend(tracker.tracked_objects) + + # Get tracked objects from default trackers + for tracker in self.default_tracker.values(): + all_tracked_objects.extend(tracker.tracked_objects) + active_detections = [ Drawable(id=obj.id, points=obj.last_detection.points, label=obj.label) - for obj in self.tracker.tracked_objects + for obj in all_tracked_objects if obj.last_detection.data["frame_time"] == frame_time ] missing_detections = [ Drawable(id=obj.id, points=obj.last_detection.points, label=obj.label) - for obj in self.tracker.tracked_objects + for obj in all_tracked_objects if obj.last_detection.data["frame_time"] != frame_time ] # draw the estimated bounding box - draw_boxes(frame, self.tracker.tracked_objects, color="green", draw_ids=True) + draw_boxes(frame, all_tracked_objects, color="green", draw_ids=True) # draw the detections that were detected in the current frame draw_boxes(frame, active_detections, color="blue", draw_ids=True) # draw the detections that are missing in the current frame @@ -393,7 +623,7 @@ class NorfairTracker(ObjectTracker): # draw the distance calculation for the last detection # estimate vs detection - for obj in self.tracker.tracked_objects: + for obj in all_tracked_objects: ld = obj.last_detection # bottom right text_anchor = ( diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index 3da2a5e04..ac57083df 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,24 +62,37 @@ 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 def max_severity(self) -> Optional[str]: review_config = self.camera_config.review - if self.obj_data["label"] in review_config.alerts.labels and ( - not review_config.alerts.required_zones - or set(self.entered_zones) & set(review_config.alerts.required_zones) + if ( + self.camera_config.review.alerts.enabled + and self.obj_data["label"] in review_config.alerts.labels + and ( + not review_config.alerts.required_zones + or set(self.entered_zones) & set(review_config.alerts.required_zones) + ) ): return SeverityEnum.alert if ( - not review_config.detections.labels - or self.obj_data["label"] in review_config.detections.labels - ) and ( - not review_config.detections.required_zones - or set(self.entered_zones) & set(review_config.detections.required_zones) + self.camera_config.review.detections.enabled + and ( + not review_config.detections.labels + or self.obj_data["label"] in review_config.detections.labels + ) + and ( + not review_config.detections.required_zones + or set(self.entered_zones) + & set(review_config.detections.required_zones) + ) ): return SeverityEnum.detection @@ -129,6 +146,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 +155,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 +164,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 +248,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 +333,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 +420,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 d456c7557..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]: @@ -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/image.py b/frigate/util/image.py index 24f523f1e..7e4915821 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -949,3 +949,13 @@ def get_image_from_recording( return process.stdout else: return None + + +def get_histogram(image, x_min, y_min, x_max, y_max): + image_bgr = cv2.cvtColor(image, cv2.COLOR_YUV2BGR_I420) + image_bgr = image_bgr[y_min:y_max, x_min:x_max] + + hist = cv2.calcHist( + [image_bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256] + ) + return cv2.normalize(hist, hist).flatten() 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..f82d86648 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -435,7 +435,11 @@ def track_camera( object_filters = config.objects.filters motion_detector = ImprovedMotionDetector( - frame_shape, config.motion, config.detect.fps, name=config.name + frame_shape, + config.motion, + config.detect.fps, + name=config.name, + ptz_metrics=ptz_metrics, ) object_detector = RemoteObjectDetector( name, labelmap, detection_queue, result_connection, model_config, stop_event @@ -481,7 +485,7 @@ def detect( detect_config: DetectConfig, object_detector, frame, - model_config, + model_config: ModelConfig, region, objects_to_track, object_filters, @@ -506,14 +510,7 @@ def detect( height = y_max - y_min area = width * height ratio = width / max(1, height) - det = ( - d[0], - d[1], - (x_min, y_min, x_max, y_max), - area, - ratio, - region, - ) + det = (d[0], d[1], (x_min, y_min, x_max, y_max), area, ratio, region) # apply object filters if is_object_filtered(det, objects_to_track, object_filters): continue 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..a8cedf953 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -53,16 +53,35 @@ 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, + alerts, + detections, + } = // @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"; + cameraStates[`${name}/review_alerts/state`] = alerts ? "ON" : "OFF"; + cameraStates[`${name}/review_detections/state`] = detections + ? "ON" + : "OFF"; }); setWsState((prevState) => ({ @@ -200,6 +219,31 @@ export function useAutotrackingState(camera: string): { return { payload: payload as ToggleableSetting, send }; } +export function useAlertsState(camera: string): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs(`${camera}/review_alerts/state`, `${camera}/review_alerts/set`); + return { payload: payload as ToggleableSetting, send }; +} + +export function useDetectionsState(camera: string): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs( + `${camera}/review_detections/state`, + `${camera}/review_detections/set`, + ); + return { payload: payload as ToggleableSetting, send }; +} + export function usePtzCommand(camera: string): { payload: string; send: (payload: string, retain?: boolean) => void; @@ -413,3 +457,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/dynamic/CameraFeatureToggle.tsx b/web/src/components/dynamic/CameraFeatureToggle.tsx index 4b9dabe95..8a284b82f 100644 --- a/web/src/components/dynamic/CameraFeatureToggle.tsx +++ b/web/src/components/dynamic/CameraFeatureToggle.tsx @@ -40,9 +40,9 @@ export default function CameraFeatureToggle({
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
@@ -673,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/GenericVideoPlayer.tsx b/web/src/components/player/GenericVideoPlayer.tsx index 75f56e96f..c8405b11a 100644 --- a/web/src/components/player/GenericVideoPlayer.tsx +++ b/web/src/components/player/GenericVideoPlayer.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from "react"; +import React, { useState, useRef, useEffect } from "react"; import { useVideoDimensions } from "@/hooks/use-video-dimensions"; import HlsVideoPlayer from "./HlsVideoPlayer"; import ActivityIndicator from "../indicators/activity-indicator"; @@ -15,37 +15,61 @@ export function GenericVideoPlayer({ children, }: GenericVideoPlayerProps) { const [isLoading, setIsLoading] = useState(true); + const [sourceExists, setSourceExists] = useState(true); const videoRef = useRef(null); const containerRef = useRef(null); const { videoDimensions, setVideoResolution } = useVideoDimensions(containerRef); + useEffect(() => { + const checkSourceExists = async (url: string) => { + try { + const response = await fetch(url, { method: "HEAD" }); + // nginx vod module returns 502 for non existent media + // https://github.com/kaltura/nginx-vod-module/issues/468 + setSourceExists(response.status !== 502 && response.status !== 404); + } catch (error) { + setSourceExists(false); + } + }; + + checkSourceExists(source); + }, [source]); + return (
- {isLoading && ( - + {!sourceExists ? ( +
+ Video not available +
+ ) : ( + <> + {isLoading && ( + + )} +
+ { + setIsLoading(false); + onPlaying?.(); + }} + setFullResolution={setVideoResolution} + /> + {!isLoading && children} +
+ )} -
- { - setIsLoading(false); - onPlaying?.(); - }} - setFullResolution={setVideoResolution} - /> - {!isLoading && children} -
); diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 0661fb0c9..6c04bb6dd 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -144,7 +144,7 @@ export default function HlsVideoPlayer({ const [tallCamera, setTallCamera] = useState(false); const [isPlaying, setIsPlaying] = useState(true); - const [muted, setMuted] = useOverlayState("playerMuted", true); + const [muted, setMuted] = usePersistence("hlsPlayerMuted", true); const [volume, setVolume] = useOverlayState("playerVolume", 1.0); const [defaultPlaybackRate] = usePersistence("playbackRate", 1); const [playbackRate, setPlaybackRate] = useOverlayState( @@ -211,7 +211,7 @@ export default function HlsVideoPlayer({ fullscreen: supportsFullscreen, }} setControlsOpen={setControlsOpen} - setMuted={(muted) => setMuted(muted, true)} + setMuted={(muted) => setMuted(muted)} playbackRate={playbackRate ?? 1} hotKeys={hotKeys} onPlayPause={onPlayPause} @@ -280,9 +280,12 @@ export default function HlsVideoPlayer({ } : undefined } - onVolumeChange={() => - setVolume(videoRef.current?.volume ?? 1.0, true) - } + onVolumeChange={() => { + setVolume(videoRef.current?.volume ?? 1.0, true); + if (!frigateControls) { + setMuted(videoRef.current?.muted); + } + }} onPlay={() => { setIsPlaying(true); 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 (
)} diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx index 44b858183..2c63d2e63 100644 --- a/web/src/components/settings/ObjectMaskEditPane.tsx +++ b/web/src/components/settings/ObjectMaskEditPane.tsx @@ -49,6 +49,8 @@ type ObjectMaskEditPaneProps = { setIsLoading: React.Dispatch>; onSave?: () => void; onCancel?: () => void; + snapPoints: boolean; + setSnapPoints: React.Dispatch>; }; export default function ObjectMaskEditPane({ @@ -61,6 +63,8 @@ export default function ObjectMaskEditPane({ setIsLoading, onSave, onCancel, + snapPoints, + setSnapPoints, }: ObjectMaskEditPaneProps) { const { data: config, mutate: updateConfig } = useSWR("config"); @@ -272,6 +276,8 @@ export default function ObjectMaskEditPane({ polygons={polygons} setPolygons={setPolygons} activePolygonIndex={activePolygonIndex} + snapPoints={snapPoints} + setSnapPoints={setSnapPoints} />
)} diff --git a/web/src/components/settings/PolygonCanvas.tsx b/web/src/components/settings/PolygonCanvas.tsx index e6851b63c..9adc2f09e 100644 --- a/web/src/components/settings/PolygonCanvas.tsx +++ b/web/src/components/settings/PolygonCanvas.tsx @@ -6,6 +6,7 @@ import type { KonvaEventObject } from "konva/lib/Node"; import { Polygon, PolygonType } from "@/types/canvas"; import { useApiHost } from "@/api"; import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { snapPointToLines } from "@/utils/canvasUtil"; type PolygonCanvasProps = { containerRef: RefObject; @@ -17,6 +18,8 @@ type PolygonCanvasProps = { activePolygonIndex: number | undefined; hoveredPolygonIndex: number | null; selectedZoneMask: PolygonType[] | undefined; + activeLine?: number; + snapPoints: boolean; }; export function PolygonCanvas({ @@ -29,6 +32,8 @@ export function PolygonCanvas({ activePolygonIndex, hoveredPolygonIndex, selectedZoneMask, + activeLine, + snapPoints, }: PolygonCanvasProps) { const [isLoaded, setIsLoaded] = useState(false); const [image, setImage] = useState(); @@ -154,9 +159,23 @@ export function PolygonCanvas({ intersection?.getClassName() !== "Circle") || (activePolygon.isFinished && intersection?.name() == "unfilled-line") ) { + let newPoint = [mousePos.x, mousePos.y]; + + if (snapPoints) { + // Snap to other polygons' edges + const otherPolygons = polygons.filter( + (_, i) => i !== activePolygonIndex, + ); + const snappedPos = snapPointToLines(newPoint, otherPolygons, 10); + + if (snappedPos) { + newPoint = snappedPos; + } + } + const { updatedPoints, updatedPointsOrder } = addPointToPolygon( activePolygon, - [mousePos.x, mousePos.y], + newPoint, ); updatedPolygons[activePolygonIndex] = { @@ -182,11 +201,24 @@ export function PolygonCanvas({ if (stage) { // we add an unfilled line for adding points when finished const index = e.target.index - (activePolygon.isFinished ? 2 : 1); - const pos = [e.target._lastPos!.x, e.target._lastPos!.y]; - if (pos[0] < 0) pos[0] = 0; - if (pos[1] < 0) pos[1] = 0; - if (pos[0] > stage.width()) pos[0] = stage.width(); - if (pos[1] > stage.height()) pos[1] = stage.height(); + let pos = [e.target._lastPos!.x, e.target._lastPos!.y]; + + if (snapPoints) { + // Snap to other polygons' edges + const otherPolygons = polygons.filter( + (_, i) => i !== activePolygonIndex, + ); + const snappedPos = snapPointToLines(pos, otherPolygons, 10); // 10 is the snap threshold + + if (snappedPos) { + pos = snappedPos; + } + } + + // Constrain to stage boundaries + pos[0] = Math.max(0, Math.min(pos[0], stage.width())); + pos[1] = Math.max(0, Math.min(pos[1], stage.height())); + updatedPolygons[activePolygonIndex] = { ...activePolygon, points: [ @@ -281,12 +313,24 @@ export function PolygonCanvas({ stageRef={stageRef} key={index} points={polygon.points} + distances={polygon.distances} isActive={index === activePolygonIndex} isHovered={index === hoveredPolygonIndex} isFinished={polygon.isFinished} color={polygon.color} handlePointDragMove={handlePointDragMove} handleGroupDragEnd={handleGroupDragEnd} + activeLine={activeLine} + snapPoints={snapPoints} + snapToLines={(point) => + snapPoints + ? snapPointToLines( + point, + polygons.filter((_, i) => i !== index), + 10, + ) + : null + } /> ), )} @@ -298,12 +342,24 @@ export function PolygonCanvas({ stageRef={stageRef} key={activePolygonIndex} points={polygons[activePolygonIndex].points} + distances={polygons[activePolygonIndex].distances} isActive={true} isHovered={activePolygonIndex === hoveredPolygonIndex} isFinished={polygons[activePolygonIndex].isFinished} color={polygons[activePolygonIndex].color} handlePointDragMove={handlePointDragMove} handleGroupDragEnd={handleGroupDragEnd} + activeLine={activeLine} + snapPoints={snapPoints} + snapToLines={(point) => + snapPoints + ? snapPointToLines( + point, + polygons.filter((_, i) => i !== activePolygonIndex), + 10, + ) + : null + } /> )} diff --git a/web/src/components/settings/PolygonDrawer.tsx b/web/src/components/settings/PolygonDrawer.tsx index 966aad2ca..9cc5649a6 100644 --- a/web/src/components/settings/PolygonDrawer.tsx +++ b/web/src/components/settings/PolygonDrawer.tsx @@ -6,7 +6,7 @@ import { useRef, useState, } from "react"; -import { Line, Circle, Group } from "react-konva"; +import { Line, Circle, Group, Text, Rect } from "react-konva"; import { minMax, toRGBColorString, @@ -20,23 +20,31 @@ import { Vector2d } from "konva/lib/types"; type PolygonDrawerProps = { stageRef: RefObject; points: number[][]; + distances: number[]; isActive: boolean; isHovered: boolean; isFinished: boolean; color: number[]; handlePointDragMove: (e: KonvaEventObject) => void; handleGroupDragEnd: (e: KonvaEventObject) => void; + activeLine?: number; + snapToLines: (point: number[]) => number[] | null; + snapPoints: boolean; }; export default function PolygonDrawer({ stageRef, points, + distances, isActive, isHovered, isFinished, color, handlePointDragMove, handleGroupDragEnd, + activeLine, + snapToLines, + snapPoints, }: PolygonDrawerProps) { const vertexRadius = 6; const flattenedPoints = useMemo(() => flattenPoints(points), [points]); @@ -113,6 +121,33 @@ export default function PolygonDrawer({ stageRef.current.container().style.cursor = cursor; }, [stageRef, cursor]); + // Calculate midpoints for distance labels based on sorted points + const midpoints = useMemo(() => { + const midpointsArray = []; + for (let i = 0; i < points.length; i++) { + const p1 = points[i]; + const p2 = points[(i + 1) % points.length]; + const midpointX = (p1[0] + p2[0]) / 2; + const midpointY = (p1[1] + p2[1]) / 2; + midpointsArray.push([midpointX, midpointY]); + } + return midpointsArray; + }, [points]); + + // Determine the points for the active line + const activeLinePoints = useMemo(() => { + if ( + activeLine === undefined || + activeLine < 1 || + activeLine > points.length + ) { + return []; + } + const p1 = points[activeLine - 1]; + const p2 = points[activeLine % points.length]; + return [p1[0], p1[1], p2[0], p2[1]]; + }, [activeLine, points]); + return ( )} + {isActive && activeLinePoints.length > 0 && ( + + )} {points.map((point, index) => { if (!isActive) { return; @@ -179,15 +222,32 @@ export default function PolygonDrawer({ onMouseOver={handleMouseOverPoint} onMouseOut={handleMouseOutPoint} draggable={isActive} - onDragMove={isActive ? handlePointDragMove : undefined} + onDragMove={(e) => { + if (isActive) { + if (snapPoints) { + const snappedPos = snapToLines([e.target.x(), e.target.y()]); + if (snappedPos) { + e.target.position({ x: snappedPos[0], y: snappedPos[1] }); + } + } + handlePointDragMove(e); + } + }} dragBoundFunc={(pos) => { if (stageRef.current) { - return dragBoundFunc( + const boundPos = dragBoundFunc( stageRef.current.width(), stageRef.current.height(), vertexRadius, pos, ); + if (snapPoints) { + const snappedPos = snapToLines([boundPos.x, boundPos.y]); + return snappedPos + ? { x: snappedPos[0], y: snappedPos[1] } + : boundPos; + } + return boundPos; } else { return pos; } @@ -195,6 +255,43 @@ export default function PolygonDrawer({ /> ); })} + {isFinished && ( + + {midpoints.map((midpoint, index) => { + const [x, y] = midpoint; + const distance = distances[index]; + if (distance === undefined) return null; + + const squareSize = 22; + + return ( + + + + + ); + })} + + )} ); } diff --git a/web/src/components/settings/PolygonEditControls.tsx b/web/src/components/settings/PolygonEditControls.tsx index 55017e3bb..e3055b654 100644 --- a/web/src/components/settings/PolygonEditControls.tsx +++ b/web/src/components/settings/PolygonEditControls.tsx @@ -2,17 +2,23 @@ import { Polygon } from "@/types/canvas"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { MdOutlineRestartAlt, MdUndo } from "react-icons/md"; import { Button } from "../ui/button"; +import { TbPolygon, TbPolygonOff } from "react-icons/tb"; +import { cn } from "@/lib/utils"; type PolygonEditControlsProps = { polygons: Polygon[]; setPolygons: React.Dispatch>; activePolygonIndex: number | undefined; + snapPoints: boolean; + setSnapPoints: React.Dispatch>; }; export default function PolygonEditControls({ polygons, setPolygons, activePolygonIndex, + snapPoints, + setSnapPoints, }: PolygonEditControlsProps) { const undo = () => { if (activePolygonIndex === undefined || !polygons) { @@ -97,6 +103,25 @@ export default function PolygonEditControls({ Reset + + + + + + {snapPoints ? "Don't snap points" : "Snap points"} + +
); } diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 54799db72..247ae8991 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -40,6 +40,9 @@ type ZoneEditPaneProps = { setIsLoading: React.Dispatch>; onSave?: () => void; onCancel?: () => void; + setActiveLine: React.Dispatch>; + snapPoints: boolean; + setSnapPoints: React.Dispatch>; }; export default function ZoneEditPane({ @@ -52,6 +55,9 @@ export default function ZoneEditPane({ setIsLoading, onSave, onCancel, + setActiveLine, + snapPoints, + setSnapPoints, }: ZoneEditPaneProps) { const { data: config, mutate: updateConfig } = useSWR("config"); @@ -80,69 +86,144 @@ export default function ZoneEditPane({ } }, [polygon, config]); - const formSchema = z.object({ - name: z - .string() - .min(2, { - message: "Zone name must be at least 2 characters.", - }) - .transform((val: string) => val.trim().replace(/\s+/g, "_")) - .refine( - (value: string) => { - return !cameras.map((cam) => cam.name).includes(value); - }, - { - message: "Zone name must not be the name of a camera.", - }, - ) - .refine( - (value: string) => { - const otherPolygonNames = - polygons - ?.filter((_, index) => index !== activePolygonIndex) - .map((polygon) => polygon.name) || []; + const [lineA, lineB, lineC, lineD] = useMemo(() => { + const distances = + polygon?.camera && + polygon?.name && + config?.cameras[polygon.camera]?.zones[polygon.name]?.distances; - return !otherPolygonNames.includes(value); - }, - { - message: "Zone name already exists on this camera.", - }, - ) - .refine( - (value: string) => { - return !value.includes("."); - }, - { - message: "Zone name must not contain a period.", - }, - ) - .refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), { - message: "Zone name has an illegal character.", + return Array.isArray(distances) + ? distances.map((value) => parseFloat(value) || 0) + : [undefined, undefined, undefined, undefined]; + }, [polygon, config]); + + const formSchema = z + .object({ + name: z + .string() + .min(2, { + message: "Zone name must be at least 2 characters.", + }) + .transform((val: string) => val.trim().replace(/\s+/g, "_")) + .refine( + (value: string) => { + return !cameras.map((cam) => cam.name).includes(value); + }, + { + message: "Zone name must not be the name of a camera.", + }, + ) + .refine( + (value: string) => { + const otherPolygonNames = + polygons + ?.filter((_, index) => index !== activePolygonIndex) + .map((polygon) => polygon.name) || []; + + return !otherPolygonNames.includes(value); + }, + { + message: "Zone name already exists on this camera.", + }, + ) + .refine( + (value: string) => { + return !value.includes("."); + }, + { + message: "Zone name must not contain a period.", + }, + ) + .refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), { + message: "Zone name has an illegal character.", + }), + inertia: z.coerce + .number() + .min(1, { + message: "Inertia must be above 0.", + }) + .or(z.literal("")), + loitering_time: z.coerce + .number() + .min(0, { + message: "Loitering time must be greater than or equal to 0.", + }) + .optional() + .or(z.literal("")), + isFinished: z.boolean().refine(() => polygon?.isFinished === true, { + message: "The polygon drawing must be finished before saving.", }), - inertia: z.coerce - .number() - .min(1, { - message: "Inertia must be above 0.", - }) - .or(z.literal("")), - loitering_time: z.coerce - .number() - .min(0, { - message: "Loitering time must be greater than or equal to 0.", - }) - .optional() - .or(z.literal("")), - isFinished: z.boolean().refine(() => polygon?.isFinished === true, { - message: "The polygon drawing must be finished before saving.", - }), - objects: z.array(z.string()).optional(), - review_alerts: z.boolean().default(false).optional(), - review_detections: z.boolean().default(false).optional(), - }); + objects: z.array(z.string()).optional(), + review_alerts: z.boolean().default(false).optional(), + review_detections: z.boolean().default(false).optional(), + speedEstimation: z.boolean().default(false), + lineA: z.coerce + .number() + .min(0.1, { + message: "Distance must be greater than or equal to 0.1", + }) + .optional() + .or(z.literal("")), + lineB: z.coerce + .number() + .min(0.1, { + message: "Distance must be greater than or equal to 0.1", + }) + .optional() + .or(z.literal("")), + lineC: z.coerce + .number() + .min(0.1, { + message: "Distance must be greater than or equal to 0.1", + }) + .optional() + .or(z.literal("")), + lineD: z.coerce + .number() + .min(0.1, { + message: "Distance must be greater than or equal to 0.1", + }) + .optional() + .or(z.literal("")), + speed_threshold: z.coerce + .number() + .min(0.1, { + message: "Speed threshold must be greater than or equal to 0.1", + }) + .optional() + .or(z.literal("")), + }) + .refine( + (data) => { + if (data.speedEstimation) { + return !!data.lineA && !!data.lineB && !!data.lineC && !!data.lineD; + } + return true; + }, + { + message: "All distance fields must be filled to use speed estimation.", + path: ["speedEstimation"], + }, + ) + .refine( + (data) => { + // Prevent speed estimation when loitering_time is greater than 0 + return !( + data.speedEstimation && + data.loitering_time && + data.loitering_time > 0 + ); + }, + { + message: + "Zones with loitering times greater than 0 should not be used with speed estimation.", + path: ["loitering_time"], + }, + ); const form = useForm>({ resolver: zodResolver(formSchema), - mode: "onChange", + mode: "onBlur", defaultValues: { name: polygon?.name ?? "", inertia: @@ -155,9 +236,31 @@ export default function ZoneEditPane({ config?.cameras[polygon.camera]?.zones[polygon.name]?.loitering_time, isFinished: polygon?.isFinished ?? false, objects: polygon?.objects ?? [], + speedEstimation: !!(lineA || lineB || lineC || lineD), + lineA, + lineB, + lineC, + lineD, + speed_threshold: + polygon?.camera && + polygon?.name && + config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold, }, }); + useEffect(() => { + if ( + form.watch("speedEstimation") && + polygon && + polygon.points.length !== 4 + ) { + toast.error( + "Speed estimation has been disabled for this zone. Zones with speed estimation must have exactly 4 points.", + ); + form.setValue("speedEstimation", false); + } + }, [polygon, form]); + const saveToConfig = useCallback( async ( { @@ -165,6 +268,12 @@ export default function ZoneEditPane({ inertia, loitering_time, objects: form_objects, + speedEstimation, + lineA, + lineB, + lineC, + lineD, + speed_threshold, }: ZoneFormValuesType, // values submitted via the form objects: string[], ) => { @@ -261,9 +370,32 @@ export default function ZoneEditPane({ loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`; } + let distancesQuery = ""; + const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(","); + if (speedEstimation) { + distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances=${distances}`; + } else { + if (distances != "") { + distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances`; + } + } + + let speedThresholdQuery = ""; + if (speed_threshold >= 0 && speedEstimation) { + speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold=${speed_threshold}`; + } else { + if ( + polygon?.camera && + polygon?.name && + config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold + ) { + speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold`; + } + } + axios .put( - `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${objectQueries}${alertQueries}${detectionQueries}`, + `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`, { requires_restart: 0 }, ) .then((res) => { @@ -355,6 +487,8 @@ export default function ZoneEditPane({ polygons={polygons} setPolygons={setPolygons} activePolygonIndex={activePolygonIndex} + snapPoints={snapPoints} + setSnapPoints={setSnapPoints} />
)} @@ -456,6 +590,183 @@ export default function ZoneEditPane({ /> + + ( + +
+ +
+ + Speed Estimation + + { + if ( + checked && + polygons && + activePolygonIndex && + polygons[activePolygonIndex].points.length !== 4 + ) { + toast.error( + "Zones with speed estimation must have exactly 4 points.", + ); + return; + } + const loiteringTime = + form.getValues("loitering_time"); + + if (checked && loiteringTime && loiteringTime > 0) { + toast.error( + "Zones with loitering times greater than 0 should not be used with speed estimation.", + ); + } + field.onChange(checked); + }} + /> +
+
+
+ + Enable speed estimation for objects in this zone. The zone + must have exactly 4 points. + + +
+ )} + /> + + {form.watch("speedEstimation") && + polygons && + activePolygonIndex && + polygons[activePolygonIndex].points.length === 4 && ( + <> + ( + + + Line A distance ( + {config?.ui.unit_system == "imperial" + ? "feet" + : "meters"} + ) + + + setActiveLine(1)} + onBlur={() => setActiveLine(undefined)} + /> + + + )} + /> + ( + + + Line B distance ( + {config?.ui.unit_system == "imperial" + ? "feet" + : "meters"} + ) + + + setActiveLine(2)} + onBlur={() => setActiveLine(undefined)} + /> + + + )} + /> + ( + + + Line C distance ( + {config?.ui.unit_system == "imperial" + ? "feet" + : "meters"} + ) + + + setActiveLine(3)} + onBlur={() => setActiveLine(undefined)} + /> + + + )} + /> + ( + + + Line D distance ( + {config?.ui.unit_system == "imperial" + ? "feet" + : "meters"} + ) + + + setActiveLine(4)} + onBlur={() => setActiveLine(undefined)} + /> + + + )} + /> + + + ( + + + Speed Threshold ( + {config?.ui.unit_system == "imperial" ? "mph" : "kph"}) + + + + + + Specifies a minimum speed for objects to be considered + in this zone. + + + + )} + /> + + )} + { updateLabelFilter(currentLabels); - }, [currentLabels, updateLabelFilter]); + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentLabels]); return ( <> diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx index 9d5a4f70c..27f318edb 100644 --- a/web/src/components/timeline/EventReviewTimeline.tsx +++ b/web/src/components/timeline/EventReviewTimeline.tsx @@ -1,9 +1,21 @@ -import { useEffect, useCallback, useMemo, useRef, RefObject } from "react"; -import EventSegment from "./EventSegment"; +import React, { + useEffect, + useMemo, + useRef, + RefObject, + useCallback, +} from "react"; import { useTimelineUtils } from "@/hooks/use-timeline-utils"; -import { ReviewSegment, ReviewSeverity } from "@/types/review"; +import { + ReviewSegment, + ReviewSeverity, + TimelineZoomDirection, +} from "@/types/review"; import ReviewTimeline from "./ReviewTimeline"; -import scrollIntoView from "scroll-into-view-if-needed"; +import { + VirtualizedEventSegments, + VirtualizedEventSegmentsRef, +} from "./VirtualizedEventSegments"; export type EventReviewTimelineProps = { segmentDuration: number; @@ -27,6 +39,8 @@ export type EventReviewTimelineProps = { timelineRef?: RefObject; contentRef: RefObject; onHandlebarDraggingChange?: (isDragging: boolean) => void; + isZooming: boolean; + zoomDirection: TimelineZoomDirection; dense?: boolean; }; @@ -52,10 +66,13 @@ export function EventReviewTimeline({ timelineRef, contentRef, onHandlebarDraggingChange, + isZooming, + zoomDirection, dense = false, }: EventReviewTimelineProps) { const internalTimelineRef = useRef(null); const selectedTimelineRef = timelineRef || internalTimelineRef; + const virtualizedSegmentsRef = useRef(null); const timelineDuration = useMemo( () => timelineStart - timelineEnd, @@ -73,79 +90,27 @@ export function EventReviewTimeline({ [timelineStart, alignStartDateToTimeline], ); - // Generate segments for the timeline - const generateSegments = useCallback(() => { + // Generate segment times for the timeline + const segmentTimes = useMemo(() => { const segmentCount = Math.ceil(timelineDuration / segmentDuration); - - return Array.from({ length: segmentCount }, (_, index) => { - const segmentTime = timelineStartAligned - index * segmentDuration; - - return ( - - ); - }); - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - segmentDuration, - timestampSpread, - timelineStart, - timelineDuration, - showMinimap, - minimapStartTime, - minimapEndTime, - events, - ]); - - const segments = useMemo( - () => generateSegments(), - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - segmentDuration, - timestampSpread, - timelineStart, - timelineDuration, - showMinimap, - minimapStartTime, - minimapEndTime, - events, - ], - ); + return Array.from( + { length: segmentCount }, + (_, index) => timelineStartAligned - index * segmentDuration, + ); + }, [timelineDuration, segmentDuration, timelineStartAligned]); useEffect(() => { if ( - selectedTimelineRef.current && - segments && visibleTimestamps && - visibleTimestamps?.length > 0 && - !showMinimap + visibleTimestamps.length > 0 && + !showMinimap && + virtualizedSegmentsRef.current ) { const alignedVisibleTimestamps = visibleTimestamps.map( alignStartDateToTimeline, ); - const element = selectedTimelineRef.current?.querySelector( - `[data-segment-id="${Math.max(...alignedVisibleTimestamps)}"]`, - ); - if (element) { - scrollIntoView(element, { - scrollMode: "if-needed", - behavior: "smooth", - }); - } + + scrollToSegment(Math.max(...alignedVisibleTimestamps), true); } // don't scroll when segments update from unreviewed -> reviewed // we know that these deps are correct @@ -155,8 +120,22 @@ export function EventReviewTimeline({ showMinimap, alignStartDateToTimeline, visibleTimestamps, + segmentDuration, ]); + const scrollToSegment = useCallback( + (segmentTime: number, ifNeeded?: boolean, behavior?: ScrollBehavior) => { + if (virtualizedSegmentsRef.current) { + virtualizedSegmentsRef.current.scrollToSegment( + segmentTime, + ifNeeded, + behavior, + ); + } + }, + [], + ); + return ( - {segments} + ); } diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx index 242ca3248..b6bd9d137 100644 --- a/web/src/components/timeline/EventSegment.tsx +++ b/web/src/components/timeline/EventSegment.tsx @@ -8,7 +8,6 @@ import React, { useEffect, useMemo, useRef, - useState, } from "react"; import { HoverCard, @@ -31,6 +30,7 @@ type EventSegmentProps = { severityType: ReviewSeverity; contentRef: RefObject; setHandlebarTime?: React.Dispatch>; + scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void; dense: boolean; }; @@ -45,6 +45,7 @@ export function EventSegment({ severityType, contentRef, setHandlebarTime, + scrollToSegment, dense, }: EventSegmentProps) { const { @@ -95,7 +96,10 @@ export function EventSegment({ }, [getEventThumbnail, segmentTime]); const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]); - const segmentKey = useMemo(() => segmentTime, [segmentTime]); + const segmentKey = useMemo( + () => `${segmentTime}_${segmentDuration}`, + [segmentTime, segmentDuration], + ); const alignedMinimapStartTime = useMemo( () => alignStartDateToTimeline(minimapStartTime ?? 0), @@ -133,10 +137,7 @@ export function EventSegment({ // Check if the first segment is out of view const firstSegment = firstMinimapSegmentRef.current; if (firstSegment && showMinimap && isFirstSegmentInMinimap) { - scrollIntoView(firstSegment, { - scrollMode: "if-needed", - behavior: "smooth", - }); + scrollToSegment(alignedMinimapStartTime); } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps @@ -196,49 +197,10 @@ export function EventSegment({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [startTimestamp]); - const [segmentRendered, setSegmentRendered] = useState(false); - const segmentObserverRef = useRef(null); - const segmentRef = useRef(null); - - useEffect(() => { - const segmentObserver = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting && !segmentRendered) { - setSegmentRendered(true); - } - }, - { threshold: 0 }, - ); - - if (segmentRef.current) { - segmentObserver.observe(segmentRef.current); - } - - segmentObserverRef.current = segmentObserver; - - return () => { - if (segmentObserverRef.current) { - segmentObserverRef.current.disconnect(); - } - }; - }, [segmentRendered]); - - if (!segmentRendered) { - return ( -
- ); - } - return (
handleTouchStart(event, segmentClick)} diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index 157e53749..c8ef5ea75 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -1,9 +1,22 @@ -import { useCallback, useMemo, useRef, RefObject } from "react"; -import MotionSegment from "./MotionSegment"; +import React, { + useCallback, + useMemo, + useRef, + RefObject, + useEffect, +} from "react"; import { useTimelineUtils } from "@/hooks/use-timeline-utils"; -import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review"; +import { + MotionData, + ReviewSegment, + TimelineZoomDirection, +} from "@/types/review"; import ReviewTimeline from "./ReviewTimeline"; import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils"; +import { + VirtualizedMotionSegments, + VirtualizedMotionSegmentsRef, +} from "./VirtualizedMotionSegments"; export type MotionReviewTimelineProps = { segmentDuration: number; @@ -25,11 +38,12 @@ export type MotionReviewTimelineProps = { setExportEndTime?: React.Dispatch>; events: ReviewSegment[]; motion_events: MotionData[]; - severityType: ReviewSeverity; contentRef: RefObject; timelineRef?: RefObject; onHandlebarDraggingChange?: (isDragging: boolean) => void; dense?: boolean; + isZooming: boolean; + zoomDirection: TimelineZoomDirection; }; export function MotionReviewTimeline({ @@ -56,9 +70,12 @@ export function MotionReviewTimeline({ timelineRef, onHandlebarDraggingChange, dense = false, + isZooming, + zoomDirection, }: MotionReviewTimelineProps) { const internalTimelineRef = useRef(null); const selectedTimelineRef = timelineRef || internalTimelineRef; + const virtualizedSegmentsRef = useRef(null); const timelineDuration = useMemo( () => timelineStart - timelineEnd + 4 * segmentDuration, @@ -80,90 +97,75 @@ export function MotionReviewTimeline({ motion_events, ); - // Generate segments for the timeline - const generateSegments = useCallback(() => { + const segmentTimes = useMemo(() => { const segments = []; let segmentTime = timelineStartAligned; - while (segmentTime >= timelineStartAligned - timelineDuration) { - const motionStart = segmentTime; - const motionEnd = motionStart + segmentDuration; + for (let i = 0; i < Math.ceil(timelineDuration / segmentDuration); i++) { + if (!motionOnly) { + segments.push(segmentTime); + } else { + const motionStart = segmentTime; + const motionEnd = motionStart + segmentDuration; + const overlappingReviewItems = events.some( + (item) => + (item.start_time >= motionStart && item.start_time < motionEnd) || + ((item.end_time ?? timelineStart) > motionStart && + (item.end_time ?? timelineStart) <= motionEnd) || + (item.start_time <= motionStart && + (item.end_time ?? timelineStart) >= motionEnd), + ); + const firstHalfMotionValue = getMotionSegmentValue(motionStart); + const secondHalfMotionValue = getMotionSegmentValue( + motionStart + segmentDuration / 2, + ); - const firstHalfMotionValue = getMotionSegmentValue(motionStart); - const secondHalfMotionValue = getMotionSegmentValue( - motionStart + segmentDuration / 2, - ); - - const segmentMotion = - firstHalfMotionValue > 0 || secondHalfMotionValue > 0; - const overlappingReviewItems = events.some( - (item) => - (item.start_time >= motionStart && item.start_time < motionEnd) || - ((item.end_time ?? timelineStart) > motionStart && - (item.end_time ?? timelineStart) <= motionEnd) || - (item.start_time <= motionStart && - (item.end_time ?? timelineStart) >= motionEnd), - ); - - if ((!segmentMotion || overlappingReviewItems) && motionOnly) { - // exclude segment if necessary when in motion only mode - segmentTime -= segmentDuration; - continue; + const segmentMotion = + firstHalfMotionValue > 0 || secondHalfMotionValue > 0; + if (segmentMotion && !overlappingReviewItems) { + segments.push(segmentTime); + } } - - segments.push( - , - ); segmentTime -= segmentDuration; } + return segments; - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - segmentDuration, - timestampSpread, timelineStartAligned, + segmentDuration, timelineDuration, - showMinimap, - minimapStartTime, - minimapEndTime, - events, - motion_events, motionOnly, + getMotionSegmentValue, + events, + timelineStart, ]); - const segments = useMemo( - () => generateSegments(), - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - segmentDuration, - timestampSpread, - timelineStartAligned, - timelineDuration, - showMinimap, - minimapStartTime, - minimapEndTime, - events, - motion_events, - motionOnly, - ], + const scrollToSegment = useCallback( + (segmentTime: number, ifNeeded?: boolean, behavior?: ScrollBehavior) => { + if (virtualizedSegmentsRef.current) { + virtualizedSegmentsRef.current.scrollToSegment( + segmentTime, + ifNeeded, + behavior, + ); + } + }, + [], ); + // keep handlebar centered when zooming + useEffect(() => { + setTimeout(() => { + scrollToSegment( + alignStartDateToTimeline(handlebarTime ?? timelineStart), + true, + "auto", + ); + }, 0); + // we only want to scroll when zooming level changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [segmentDuration]); + return ( - {segments} + ); } diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index 903916b97..fa6fdbd80 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -1,14 +1,7 @@ import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils"; import { ReviewSegment } from "@/types/review"; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import scrollIntoView from "scroll-into-view-if-needed"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { MinimapBounds, Tick, Timestamp } from "./segment-metadata"; import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils"; import { isMobile } from "react-device-detect"; @@ -27,6 +20,7 @@ type MotionSegmentProps = { minimapStartTime?: number; minimapEndTime?: number; setHandlebarTime?: React.Dispatch>; + scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void; dense: boolean; }; @@ -42,6 +36,7 @@ export function MotionSegment({ minimapStartTime, minimapEndTime, setHandlebarTime, + scrollToSegment, dense, }: MotionSegmentProps) { const severityType = "all"; @@ -72,7 +67,10 @@ export function MotionSegment({ ); const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]); - const segmentKey = useMemo(() => segmentTime, [segmentTime]); + const segmentKey = useMemo( + () => `${segmentTime}_${segmentDuration}`, + [segmentTime, segmentDuration], + ); const maxSegmentWidth = useMemo(() => { return isMobile ? 30 : 50; @@ -122,10 +120,7 @@ export function MotionSegment({ // Check if the first segment is out of view const firstSegment = firstMinimapSegmentRef.current; if (firstSegment && showMinimap && isFirstSegmentInMinimap) { - scrollIntoView(firstSegment, { - scrollMode: "if-needed", - behavior: "smooth", - }); + scrollToSegment(alignedMinimapStartTime); } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps @@ -163,44 +158,6 @@ export function MotionSegment({ } }, [segmentTime, setHandlebarTime]); - const [segmentRendered, setSegmentRendered] = useState(false); - const segmentObserverRef = useRef(null); - const segmentRef = useRef(null); - - useEffect(() => { - const segmentObserver = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting && !segmentRendered) { - setSegmentRendered(true); - } - }, - { threshold: 0 }, - ); - - if (segmentRef.current) { - segmentObserver.observe(segmentRef.current); - } - - segmentObserverRef.current = segmentObserver; - - return () => { - if (segmentObserverRef.current) { - segmentObserverRef.current.disconnect(); - } - }; - }, [segmentRendered]); - - if (!segmentRendered) { - return ( -
- ); - } - return ( <> {(((firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0) && @@ -209,8 +166,7 @@ export function MotionSegment({ !motionOnly) && (
>; timelineCollapsed?: boolean; dense: boolean; - children: ReactNode[]; + segments: number[]; + scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void; + isZooming: boolean; + zoomDirection: TimelineZoomDirection; + children: ReactNode; }; export function ReviewTimeline({ @@ -51,6 +57,10 @@ export function ReviewTimeline({ setExportEndTime, timelineCollapsed = false, dense, + segments, + scrollToSegment, + isZooming, + zoomDirection, children, }: ReviewTimelineProps) { const [isDraggingHandlebar, setIsDraggingHandlebar] = useState(false); @@ -116,7 +126,8 @@ export function ReviewTimeline({ setIsDragging: setIsDraggingHandlebar, draggableElementTimeRef: handlebarTimeRef, dense, - timelineSegments: children, + segments, + scrollToSegment, }); const { @@ -140,7 +151,8 @@ export function ReviewTimeline({ draggableElementTimeRef: exportStartTimeRef, setDraggableElementPosition: setExportStartPosition, dense, - timelineSegments: children, + segments, + scrollToSegment, }); const { @@ -164,7 +176,8 @@ export function ReviewTimeline({ draggableElementTimeRef: exportEndTimeRef, setDraggableElementPosition: setExportEndPosition, dense, - timelineSegments: children, + segments, + scrollToSegment, }); const handleHandlebar = useCallback( @@ -316,18 +329,21 @@ export function ReviewTimeline({ return (
{children}
- {children.length > 0 && ( + {children && ( <> {showHandlebar && (
getSeverity(segmentTime, displaySeverityType), - [getSeverity, segmentTime, displaySeverityType], - ); - - const reviewed = useMemo( - () => getReviewed(segmentTime), - [getReviewed, segmentTime], - ); - - const segmentKey = useMemo(() => segmentTime, [segmentTime]); - - const severityColors: { [key: number]: string } = { - 1: reviewed + const severityColors: { [key: string]: string } = { + significant_motion: reviewed ? "bg-severity_significant_motion/50" : "bg-severity_significant_motion", - 2: reviewed ? "bg-severity_detection/50" : "bg-severity_detection", - 3: reviewed ? "bg-severity_alert/50" : "bg-severity_alert", + detection: reviewed ? "bg-severity_detection/50" : "bg-severity_detection", + alert: reviewed ? "bg-severity_alert/50" : "bg-severity_alert", + empty: "bg-transparent", }; + const height = ((endTime - startTime) / totalDuration) * 100; + return ( -
- {severity.map((severityValue: number, index: number) => ( - - {severityValue === displaySeverityType && ( -
-
-
+
+
+
- ))} + >
+
); } diff --git a/web/src/components/timeline/SummaryTimeline.tsx b/web/src/components/timeline/SummaryTimeline.tsx index 7e67b9da6..d17709bdf 100644 --- a/web/src/components/timeline/SummaryTimeline.tsx +++ b/web/src/components/timeline/SummaryTimeline.tsx @@ -1,17 +1,20 @@ -import { - RefObject, - useCallback, - useEffect, - useMemo, +import React, { useRef, useState, + useMemo, + useCallback, + useEffect, } from "react"; import { SummarySegment } from "./SummarySegment"; +import { + ConsolidatedSegmentData, + ReviewSegment, + ReviewSeverity, +} from "@/types/review"; import { useTimelineUtils } from "@/hooks/use-timeline-utils"; -import { ReviewSegment, ReviewSeverity } from "@/types/review"; export type SummaryTimelineProps = { - reviewTimelineRef: RefObject; + reviewTimelineRef: React.RefObject; timelineStart: number; timelineEnd: number; segmentDuration: number; @@ -29,7 +32,6 @@ export function SummaryTimeline({ }: SummaryTimelineProps) { const summaryTimelineRef = useRef(null); const visibleSectionRef = useRef(null); - const [segmentHeight, setSegmentHeight] = useState(0); const [isDragging, setIsDragging] = useState(false); const [scrollStartPosition, setScrollStartPosition] = useState(0); @@ -43,61 +45,89 @@ export function SummaryTimeline({ [timelineEnd, timelineStart, segmentDuration], ); - const { alignStartDateToTimeline } = useTimelineUtils({ - segmentDuration, - timelineDuration: reviewTimelineDuration, - timelineRef: reviewTimelineRef, - }); - - const timelineStartAligned = useMemo( - () => alignStartDateToTimeline(timelineStart) + 2 * segmentDuration, - [timelineStart, alignStartDateToTimeline, segmentDuration], + const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils( + { + segmentDuration, + timelineDuration: reviewTimelineDuration, + timelineRef: reviewTimelineRef, + }, ); - // Generate segments for the timeline - const generateSegments = useCallback(() => { - const segmentCount = Math.ceil(reviewTimelineDuration / segmentDuration); + const consolidatedSegments = useMemo(() => { + const filteredEvents = events.filter( + (event) => event.severity === severityType, + ); - if (segmentHeight) { - return Array.from({ length: segmentCount }, (_, index) => { - const segmentTime = timelineStartAligned - index * segmentDuration; + const sortedEvents = filteredEvents.sort( + (a, b) => a.start_time - b.start_time, + ); - return ( - - ); + const consolidated: ConsolidatedSegmentData[] = []; + + let currentTime = alignEndDateToTimeline(timelineEnd); + const timelineStartAligned = alignStartDateToTimeline(timelineStart); + + sortedEvents.forEach((event) => { + const alignedStartTime = Math.max( + alignStartDateToTimeline(event.start_time), + currentTime, + ); + const alignedEndTime = Math.min( + event.end_time + ? alignEndDateToTimeline(event.end_time) + : alignedStartTime + segmentDuration, + timelineStartAligned, + ); + + if (alignedStartTime < alignedEndTime) { + if (alignedStartTime > currentTime) { + consolidated.push({ + startTime: currentTime, + endTime: alignedStartTime, + severity: "empty", + reviewed: false, + }); + } + + consolidated.push({ + startTime: alignedStartTime, + endTime: alignedEndTime, + severity: event.severity, + reviewed: event.has_been_reviewed, + }); + + currentTime = alignedEndTime; + } + }); + + if (currentTime < timelineStartAligned) { + consolidated.push({ + startTime: currentTime, + endTime: timelineStartAligned, + severity: "empty", + reviewed: false, }); } - }, [ - segmentDuration, - timelineStartAligned, - events, - reviewTimelineDuration, - segmentHeight, - severityType, - ]); - const segments = useMemo( - () => generateSegments(), - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - segmentDuration, - segmentHeight, - timelineStartAligned, - events, - reviewTimelineDuration, - segmentHeight, - generateSegments, - severityType, - ], - ); + return consolidated.length > 0 + ? consolidated + : [ + { + startTime: alignEndDateToTimeline(timelineEnd), + endTime: timelineStartAligned, + severity: "empty" as const, + reviewed: false, + }, + ]; + }, [ + events, + severityType, + timelineStart, + timelineEnd, + alignStartDateToTimeline, + alignEndDateToTimeline, + segmentDuration, + ]); const setVisibleSectionStyles = useCallback(() => { if ( @@ -137,15 +167,6 @@ export function SummaryTimeline({ observer.current = new ResizeObserver(() => { setVisibleSectionStyles(); - if (summaryTimelineRef.current) { - const { clientHeight: summaryTimelineVisibleHeight } = - summaryTimelineRef.current; - - setSegmentHeight( - summaryTimelineVisibleHeight / - (reviewTimelineDuration / segmentDuration), - ); - } }); observer.current.observe(content); @@ -163,18 +184,6 @@ export function SummaryTimeline({ segmentDuration, ]); - useEffect(() => { - if (summaryTimelineRef.current) { - const { clientHeight: summaryTimelineVisibleHeight } = - summaryTimelineRef.current; - - setSegmentHeight( - summaryTimelineVisibleHeight / - (reviewTimelineDuration / segmentDuration), - ); - } - }, [reviewTimelineDuration, summaryTimelineRef, segmentDuration]); - const timelineClick = useCallback( ( e: React.MouseEvent | React.TouchEvent, @@ -344,11 +353,17 @@ export function SummaryTimeline({ >
- {segments} + {consolidatedSegments.map((segment, index) => ( + + ))}
; + segments: number[]; + events: ReviewSegment[]; + segmentDuration: number; + timestampSpread: number; + showMinimap: boolean; + minimapStartTime?: number; + minimapEndTime?: number; + severityType: ReviewSeverity; + contentRef: React.RefObject; + setHandlebarTime?: React.Dispatch>; + dense: boolean; + alignStartDateToTimeline: (timestamp: number) => number; +}; + +export interface VirtualizedEventSegmentsRef { + scrollToSegment: ( + segmentTime: number, + ifNeeded?: boolean, + behavior?: ScrollBehavior, + ) => void; +} + +const SEGMENT_HEIGHT = 8; +const OVERSCAN_COUNT = 20; + +export const VirtualizedEventSegments = forwardRef< + VirtualizedEventSegmentsRef, + VirtualizedEventSegmentsProps +>( + ( + { + timelineRef, + segments, + events, + segmentDuration, + timestampSpread, + showMinimap, + minimapStartTime, + minimapEndTime, + severityType, + contentRef, + setHandlebarTime, + dense, + alignStartDateToTimeline, + }, + ref, + ) => { + const [visibleRange, setVisibleRange] = useState({ start: 0, end: 0 }); + const containerRef = useRef(null); + + const updateVisibleRange = useCallback(() => { + if (timelineRef.current) { + const { scrollTop, clientHeight } = timelineRef.current; + const start = Math.max( + 0, + Math.floor(scrollTop / SEGMENT_HEIGHT) - OVERSCAN_COUNT, + ); + const end = Math.min( + segments.length, + Math.ceil((scrollTop + clientHeight) / SEGMENT_HEIGHT) + + OVERSCAN_COUNT, + ); + setVisibleRange({ start, end }); + } + }, [segments.length, timelineRef]); + + useEffect(() => { + const container = timelineRef.current; + if (container) { + const handleScroll = () => { + window.requestAnimationFrame(updateVisibleRange); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + window.addEventListener("resize", updateVisibleRange); + + updateVisibleRange(); + + return () => { + container.removeEventListener("scroll", handleScroll); + window.removeEventListener("resize", updateVisibleRange); + }; + } + }, [updateVisibleRange, timelineRef]); + + const scrollToSegment = useCallback( + ( + segmentTime: number, + ifNeeded: boolean = true, + behavior: ScrollBehavior = "smooth", + ) => { + const alignedSegmentTime = alignStartDateToTimeline(segmentTime); + const segmentIndex = segments.findIndex( + (time) => time === alignedSegmentTime, + ); + if ( + segmentIndex !== -1 && + containerRef.current && + timelineRef.current + ) { + const timelineHeight = timelineRef.current.clientHeight; + const targetScrollTop = segmentIndex * SEGMENT_HEIGHT; + const centeredScrollTop = + targetScrollTop - timelineHeight / 2 + SEGMENT_HEIGHT / 2; + + const isVisible = + segmentIndex > visibleRange.start + OVERSCAN_COUNT && + segmentIndex < visibleRange.end - OVERSCAN_COUNT; + + if (!ifNeeded || !isVisible) { + timelineRef.current.scrollTo({ + top: Math.max(0, centeredScrollTop), + behavior: behavior, + }); + } + updateVisibleRange(); + } + }, + [ + segments, + alignStartDateToTimeline, + updateVisibleRange, + timelineRef, + visibleRange, + ], + ); + + useImperativeHandle(ref, () => ({ + scrollToSegment, + })); + + const totalHeight = segments.length * SEGMENT_HEIGHT; + const visibleSegments = segments.slice( + visibleRange.start, + visibleRange.end, + ); + + return ( +
+
+ {visibleRange.start > 0 && ( + + ); + }, +); diff --git a/web/src/components/timeline/VirtualizedMotionSegments.tsx b/web/src/components/timeline/VirtualizedMotionSegments.tsx new file mode 100644 index 000000000..3aed75266 --- /dev/null +++ b/web/src/components/timeline/VirtualizedMotionSegments.tsx @@ -0,0 +1,251 @@ +import React, { + useCallback, + useEffect, + useRef, + useState, + forwardRef, + useImperativeHandle, +} from "react"; +import MotionSegment from "./MotionSegment"; +import { ReviewSegment, MotionData } from "@/types/review"; + +type VirtualizedMotionSegmentsProps = { + timelineRef: React.RefObject; + segments: number[]; + events: ReviewSegment[]; + motion_events: MotionData[]; + segmentDuration: number; + timestampSpread: number; + showMinimap: boolean; + minimapStartTime?: number; + minimapEndTime?: number; + contentRef: React.RefObject; + setHandlebarTime?: React.Dispatch>; + dense: boolean; + motionOnly: boolean; + getMotionSegmentValue: (timestamp: number) => number; +}; + +export interface VirtualizedMotionSegmentsRef { + scrollToSegment: ( + segmentTime: number, + ifNeeded?: boolean, + behavior?: ScrollBehavior, + ) => void; +} + +const SEGMENT_HEIGHT = 8; +const OVERSCAN_COUNT = 20; + +export const VirtualizedMotionSegments = forwardRef< + VirtualizedMotionSegmentsRef, + VirtualizedMotionSegmentsProps +>( + ( + { + timelineRef, + segments, + events, + segmentDuration, + timestampSpread, + showMinimap, + minimapStartTime, + minimapEndTime, + setHandlebarTime, + dense, + motionOnly, + getMotionSegmentValue, + }, + ref, + ) => { + const [visibleRange, setVisibleRange] = useState({ start: 0, end: 0 }); + const containerRef = useRef(null); + + const updateVisibleRange = useCallback(() => { + if (timelineRef.current) { + const { scrollTop, clientHeight } = timelineRef.current; + const start = Math.max( + 0, + Math.floor(scrollTop / SEGMENT_HEIGHT) - OVERSCAN_COUNT, + ); + const end = Math.min( + segments.length, + Math.ceil((scrollTop + clientHeight) / SEGMENT_HEIGHT) + + OVERSCAN_COUNT, + ); + setVisibleRange({ start, end }); + } + }, [segments.length, timelineRef]); + + useEffect(() => { + const container = timelineRef.current; + if (container) { + const handleScroll = () => { + window.requestAnimationFrame(updateVisibleRange); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + window.addEventListener("resize", updateVisibleRange); + + updateVisibleRange(); + + return () => { + container.removeEventListener("scroll", handleScroll); + window.removeEventListener("resize", updateVisibleRange); + }; + } + }, [updateVisibleRange, timelineRef]); + + const scrollToSegment = useCallback( + ( + segmentTime: number, + ifNeeded: boolean = true, + behavior: ScrollBehavior = "smooth", + ) => { + const segmentIndex = segments.findIndex((time) => time === segmentTime); + if ( + segmentIndex !== -1 && + containerRef.current && + timelineRef.current + ) { + const timelineHeight = timelineRef.current.clientHeight; + const targetScrollTop = segmentIndex * SEGMENT_HEIGHT; + const centeredScrollTop = + targetScrollTop - timelineHeight / 2 + SEGMENT_HEIGHT / 2; + + const isVisible = + segmentIndex > visibleRange.start + OVERSCAN_COUNT && + segmentIndex < visibleRange.end - OVERSCAN_COUNT; + + if (!ifNeeded || !isVisible) { + timelineRef.current.scrollTo({ + top: Math.max(0, centeredScrollTop), + behavior: behavior, + }); + } + updateVisibleRange(); + } + }, + [segments, timelineRef, visibleRange, updateVisibleRange], + ); + + useImperativeHandle(ref, () => ({ + scrollToSegment, + })); + + const renderSegment = useCallback( + (segmentTime: number, index: number) => { + const motionStart = segmentTime; + const motionEnd = motionStart + segmentDuration; + + const firstHalfMotionValue = getMotionSegmentValue(motionStart); + const secondHalfMotionValue = getMotionSegmentValue( + motionStart + segmentDuration / 2, + ); + + const segmentMotion = + firstHalfMotionValue > 0 || secondHalfMotionValue > 0; + const overlappingReviewItems = events.some( + (item) => + (item.start_time >= motionStart && item.start_time < motionEnd) || + ((item.end_time ?? segmentTime) > motionStart && + (item.end_time ?? segmentTime) <= motionEnd) || + (item.start_time <= motionStart && + (item.end_time ?? segmentTime) >= motionEnd), + ); + + if ((!segmentMotion || overlappingReviewItems) && motionOnly) { + return null; // Skip rendering this segment in motion only mode + } + + return ( +
+ +
+ ); + }, + [ + events, + getMotionSegmentValue, + motionOnly, + segmentDuration, + showMinimap, + minimapStartTime, + minimapEndTime, + setHandlebarTime, + scrollToSegment, + dense, + timestampSpread, + visibleRange.start, + ], + ); + + const totalHeight = segments.length * SEGMENT_HEIGHT; + const visibleSegments = segments.slice( + visibleRange.start, + visibleRange.end, + ); + + return ( +
+
+ {visibleRange.start > 0 && ( + + ); + }, +); + +export default VirtualizedMotionSegments; diff --git a/web/src/components/timeline/segment-metadata.tsx b/web/src/components/timeline/segment-metadata.tsx index 3e3c99393..9d29c73ad 100644 --- a/web/src/components/timeline/segment-metadata.tsx +++ b/web/src/components/timeline/segment-metadata.tsx @@ -22,7 +22,7 @@ type TimestampSegmentProps = { isLastSegmentInMinimap: boolean; timestamp: Date; timestampSpread: number; - segmentKey: number; + segmentKey: string; }; export function MinimapBounds({ diff --git a/web/src/components/ui/slider.tsx b/web/src/components/ui/slider.tsx index 1dde1df67..3cb4165e9 100644 --- a/web/src/components/ui/slider.tsx +++ b/web/src/components/ui/slider.tsx @@ -18,7 +18,7 @@ const Slider = React.forwardRef< - + )); Slider.displayName = SliderPrimitive.Root.displayName; @@ -36,9 +36,9 @@ const VolumeSlider = React.forwardRef< {...props} > - + - + )); VolumeSlider.displayName = SliderPrimitive.Root.displayName; @@ -58,7 +58,7 @@ const NoThumbSlider = React.forwardRef< - + )); NoThumbSlider.displayName = SliderPrimitive.Root.displayName; @@ -78,8 +78,8 @@ const DualThumbSlider = React.forwardRef< - - + + )); DualThumbSlider.displayName = SliderPrimitive.Root.displayName; diff --git a/web/src/context/providers.tsx b/web/src/context/providers.tsx index fe5e931e7..61b4a6426 100644 --- a/web/src/context/providers.tsx +++ b/web/src/context/providers.tsx @@ -5,6 +5,7 @@ import { ApiProvider } from "@/api"; import { IconContext } from "react-icons"; import { TooltipProvider } from "@/components/ui/tooltip"; import { StatusBarMessagesProvider } from "@/context/statusbar-provider"; +import { StreamingSettingsProvider } from "./streaming-settings-provider"; type TProvidersProps = { children: ReactNode; @@ -17,7 +18,11 @@ function providers({ children }: TProvidersProps) { - {children} + + + {children} + + diff --git a/web/src/context/streaming-settings-provider.tsx b/web/src/context/streaming-settings-provider.tsx new file mode 100644 index 000000000..82558722e --- /dev/null +++ b/web/src/context/streaming-settings-provider.tsx @@ -0,0 +1,68 @@ +import { + createContext, + useState, + useEffect, + ReactNode, + useContext, +} from "react"; +import { AllGroupsStreamingSettings } from "@/types/frigateConfig"; +import { usePersistence } from "@/hooks/use-persistence"; + +type StreamingSettingsContextType = { + allGroupsStreamingSettings: AllGroupsStreamingSettings; + setAllGroupsStreamingSettings: (settings: AllGroupsStreamingSettings) => void; + isPersistedStreamingSettingsLoaded: boolean; +}; + +const StreamingSettingsContext = + createContext(null); + +export function StreamingSettingsProvider({ + children, +}: { + children: ReactNode; +}) { + const [allGroupsStreamingSettings, setAllGroupsStreamingSettings] = + useState({}); + + const [ + persistedGroupStreamingSettings, + setPersistedGroupStreamingSettings, + isPersistedStreamingSettingsLoaded, + ] = usePersistence("streaming-settings"); + + useEffect(() => { + if (isPersistedStreamingSettingsLoaded) { + setAllGroupsStreamingSettings(persistedGroupStreamingSettings ?? {}); + } + }, [isPersistedStreamingSettingsLoaded, persistedGroupStreamingSettings]); + + useEffect(() => { + if (Object.keys(allGroupsStreamingSettings).length) { + setPersistedGroupStreamingSettings(allGroupsStreamingSettings); + } + }, [allGroupsStreamingSettings, setPersistedGroupStreamingSettings]); + + return ( + + {children} + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useStreamingSettings() { + const context = useContext(StreamingSettingsContext); + if (!context) { + throw new Error( + "useStreamingSettings must be used within a StreamingSettingsProvider", + ); + } + return context; +} diff --git a/web/src/hooks/resize-observer.ts b/web/src/hooks/resize-observer.ts index 57f55817a..1e174af7e 100644 --- a/web/src/hooks/resize-observer.ts +++ b/web/src/hooks/resize-observer.ts @@ -17,7 +17,15 @@ export function useResizeObserver(...refs: RefType[]) { () => new ResizeObserver((entries) => { window.requestAnimationFrame(() => { - setDimensions(entries.map((entry) => entry.contentRect)); + setDimensions((prevDimensions) => { + const newDimensions = entries.map((entry) => entry.contentRect); + if ( + JSON.stringify(prevDimensions) !== JSON.stringify(newDimensions) + ) { + return newDimensions; + } + return prevDimensions; + }); }); }), [], diff --git a/web/src/hooks/use-camera-live-mode.ts b/web/src/hooks/use-camera-live-mode.ts index edf165951..238ac70cc 100644 --- a/web/src/hooks/use-camera-live-mode.ts +++ b/web/src/hooks/use-camera-live-mode.ts @@ -1,16 +1,29 @@ import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { useCallback, useEffect, useState } from "react"; import useSWR from "swr"; -import { LivePlayerMode } from "@/types/live"; +import { LivePlayerMode, LiveStreamMetadata } from "@/types/live"; export default function useCameraLiveMode( cameras: CameraConfig[], windowVisible: boolean, ) { const { data: config } = useSWR("config"); + const { data: allStreamMetadata } = useSWR<{ + [key: string]: LiveStreamMetadata; + }>(config ? "go2rtc/streams" : null, { revalidateOnFocus: false }); + const [preferredLiveModes, setPreferredLiveModes] = useState<{ [key: string]: LivePlayerMode; }>({}); + const [isRestreamedStates, setIsRestreamedStates] = useState<{ + [key: string]: boolean; + }>({}); + const [supportsAudioOutputStates, setSupportsAudioOutputStates] = useState<{ + [key: string]: { + supportsAudio: boolean; + cameraName: string; + }; + }>({}); useEffect(() => { if (!cameras) return; @@ -18,26 +31,56 @@ export default function useCameraLiveMode( const mseSupported = "MediaSource" in window || "ManagedMediaSource" in window; - const newPreferredLiveModes = cameras.reduce( - (acc, camera) => { - const isRestreamed = - config && - Object.keys(config.go2rtc.streams || {}).includes( - camera.live.stream_name, - ); + const newPreferredLiveModes: { [key: string]: LivePlayerMode } = {}; + const newIsRestreamedStates: { [key: string]: boolean } = {}; + const newSupportsAudioOutputStates: { + [key: string]: { supportsAudio: boolean; cameraName: string }; + } = {}; - if (!mseSupported) { - acc[camera.name] = isRestreamed ? "webrtc" : "jsmpeg"; - } else { - acc[camera.name] = isRestreamed ? "mse" : "jsmpeg"; - } - return acc; - }, - {} as { [key: string]: LivePlayerMode }, - ); + cameras.forEach((camera) => { + const isRestreamed = + config && + Object.keys(config.go2rtc.streams || {}).includes( + Object.values(camera.live.streams)[0], + ); + + newIsRestreamedStates[camera.name] = isRestreamed ?? false; + + if (!mseSupported) { + newPreferredLiveModes[camera.name] = isRestreamed ? "webrtc" : "jsmpeg"; + } else { + newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg"; + } + + // check each stream for audio support + if (isRestreamed) { + Object.values(camera.live.streams).forEach((streamName) => { + const metadata = allStreamMetadata?.[streamName]; + newSupportsAudioOutputStates[streamName] = { + supportsAudio: metadata + ? metadata.producers.find( + (prod) => + prod.medias && + prod.medias.find((media) => + media.includes("audio, recvonly"), + ) !== undefined, + ) !== undefined + : false, + cameraName: camera.name, + }; + }); + } else { + newSupportsAudioOutputStates[camera.name] = { + supportsAudio: false, + cameraName: camera.name, + }; + } + }); setPreferredLiveModes(newPreferredLiveModes); - }, [cameras, config, windowVisible]); + setIsRestreamedStates(newIsRestreamedStates); + setSupportsAudioOutputStates(newSupportsAudioOutputStates); + }, [cameras, config, windowVisible, allStreamMetadata]); const resetPreferredLiveMode = useCallback( (cameraName: string) => { @@ -61,5 +104,11 @@ export default function useCameraLiveMode( [config], ); - return { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode }; + return { + preferredLiveModes, + setPreferredLiveModes, + resetPreferredLiveMode, + isRestreamedStates, + supportsAudioOutputStates, + }; } diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index 73013de58..ddc8851b6 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -1,12 +1,4 @@ -import { - ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import scrollIntoView from "scroll-into-view-if-needed"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTimelineUtils } from "./use-timeline-utils"; import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; @@ -33,7 +25,8 @@ type DraggableElementProps = { setIsDragging: React.Dispatch>; setDraggableElementPosition?: React.Dispatch>; dense: boolean; - timelineSegments: ReactNode[]; + segments: number[]; + scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void; }; function useDraggableElement({ @@ -57,7 +50,8 @@ function useDraggableElement({ setIsDragging, setDraggableElementPosition, dense, - timelineSegments, + segments, + scrollToSegment, }: DraggableElementProps) { const { data: config } = useSWR("config"); @@ -66,7 +60,6 @@ function useDraggableElement({ const [elementScrollIntoView, setElementScrollIntoView] = useState(true); const [scrollEdgeSize, setScrollEdgeSize] = useState(); const [fullTimelineHeight, setFullTimelineHeight] = useState(); - const [segments, setSegments] = useState([]); const { alignStartDateToTimeline, getCumulativeScrollTop, segmentHeight } = useTimelineUtils({ segmentDuration: segmentDuration, @@ -201,11 +194,7 @@ function useDraggableElement({ draggableElementTimeRef.current.textContent = getFormattedTimestamp(segmentStartTime); if (scrollTimeline && !userInteracting) { - scrollIntoView(thumb, { - block: "center", - behavior: "smooth", - scrollMode: "if-needed", - }); + scrollToSegment(segmentStartTime); } } }); @@ -222,6 +211,7 @@ function useDraggableElement({ setDraggableElementPosition, getFormattedTimestamp, userInteracting, + scrollToSegment, ], ); @@ -241,12 +231,6 @@ function useDraggableElement({ [contentRef, draggableElementRef, timelineRef, getClientYPosition], ); - useEffect(() => { - if (timelineRef.current && timelineSegments.length) { - setSegments(Array.from(timelineRef.current.querySelectorAll(".segment"))); - } - }, [timelineRef, timelineCollapsed, timelineSegments]); - useEffect(() => { let animationFrameId: number | null = null; @@ -256,7 +240,7 @@ function useDraggableElement({ showDraggableElement && isDragging && clientYPosition && - segments && + segments.length > 0 && fullTimelineHeight ) { const { scrollTop: scrolled } = timelineRef.current; @@ -295,31 +279,18 @@ function useDraggableElement({ return; } - let targetSegmentId = 0; - let offset = 0; + const start = Math.max(0, Math.floor(scrolled / segmentHeight)); - segments.forEach((segmentElement: HTMLDivElement) => { - const rect = segmentElement.getBoundingClientRect(); - const segmentTop = - rect.top + scrolled - timelineTopAbsolute - segmentHeight; - const segmentBottom = - rect.bottom + scrolled - timelineTopAbsolute - segmentHeight; + const relativePosition = newElementPosition - scrolled; + const segmentIndex = + Math.floor(relativePosition / segmentHeight) + start + 1; - // Check if handlebar position falls within the segment bounds - if ( - newElementPosition >= segmentTop && - newElementPosition <= segmentBottom - ) { - targetSegmentId = parseFloat( - segmentElement.getAttribute("data-segment-id") || "0", - ); - offset = Math.min( - segmentBottom - newElementPosition, - segmentHeight, - ); - return; - } - }); + const targetSegmentTime = segments[segmentIndex]; + if (targetSegmentTime === undefined) return; + + const segmentStart = segmentIndex * segmentHeight - scrolled; + + const offset = Math.min(segmentStart - relativePosition, segmentHeight); if ((draggingAtTopEdge || draggingAtBottomEdge) && scrollEdgeSize) { if (draggingAtTopEdge) { @@ -349,8 +320,8 @@ function useDraggableElement({ } const setTime = alignSetTimeToSegment - ? targetSegmentId - : targetSegmentId + segmentDuration * (offset / segmentHeight); + ? targetSegmentTime + : targetSegmentTime + segmentDuration * (offset / segmentHeight); updateDraggableElementPosition( newElementPosition, @@ -361,7 +332,7 @@ function useDraggableElement({ if (setDraggableElementTime) { setDraggableElementTime( - targetSegmentId + segmentDuration * (offset / segmentHeight), + targetSegmentTime + segmentDuration * (offset / segmentHeight), ); } @@ -397,6 +368,7 @@ function useDraggableElement({ draggingAtTopEdge, draggingAtBottomEdge, showDraggableElement, + segments, ]); useEffect(() => { @@ -408,24 +380,23 @@ function useDraggableElement({ !isDragging && segments.length > 0 ) { - const { scrollTop: scrolled } = timelineRef.current; - const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime); + if (!userInteracting) { + scrollToSegment(alignedSegmentTime); + } - const segmentElement = timelineRef.current.querySelector( - `[data-segment-id="${alignedSegmentTime}"]`, + const segmentIndex = segments.findIndex( + (time) => time === alignedSegmentTime, ); - if (segmentElement) { - const timelineRect = timelineRef.current.getBoundingClientRect(); - const timelineTopAbsolute = timelineRect.top; - const rect = segmentElement.getBoundingClientRect(); - const segmentTop = rect.top + scrolled - timelineTopAbsolute; + if (segmentIndex >= 0) { + const segmentStart = segmentIndex * segmentHeight; + const offset = ((draggableElementTime - alignedSegmentTime) / segmentDuration) * segmentHeight; // subtract half the height of the handlebar cross bar (4px) for pixel perfection - const newElementPosition = segmentTop - offset - 2; + const newElementPosition = segmentStart - offset - 2; updateDraggableElementPosition( newElementPosition, @@ -454,14 +425,27 @@ function useDraggableElement({ segments, ]); + const findNextAvailableSegment = useCallback( + (startTime: number) => { + let searchTime = startTime; + while (searchTime < timelineStartAligned + timelineDuration) { + if (segments.includes(searchTime)) { + return searchTime; + } + searchTime += segmentDuration; + } + return null; + }, + [segments, timelineStartAligned, timelineDuration, segmentDuration], + ); + useEffect(() => { if ( timelineRef.current && segmentsRef.current && draggableElementTime && timelineCollapsed && - timelineSegments && - segments + segments.length > 0 ) { setFullTimelineHeight( Math.min( @@ -469,47 +453,27 @@ function useDraggableElement({ segmentsRef.current.scrollHeight, ), ); + const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime); - let segmentElement = timelineRef.current.querySelector( - `[data-segment-id="${alignedSegmentTime}"]`, - ); - - if (!segmentElement) { + if (segments.includes(alignedSegmentTime)) { + scrollToSegment(alignedSegmentTime); + } else { // segment not found, maybe we collapsed over a collapsible segment - let searchTime = alignedSegmentTime; + const nextAvailableSegment = + findNextAvailableSegment(alignedSegmentTime); - while ( - searchTime < timelineStartAligned && - searchTime < timelineStartAligned + timelineDuration - ) { - searchTime += segmentDuration; - segmentElement = timelineRef.current.querySelector( - `[data-segment-id="${searchTime}"]`, - ); - - if (segmentElement) { - // found, set time - if (setDraggableElementTime) { - setDraggableElementTime(searchTime); - } - return; - } - } - } - if (!segmentElement) { - // segment still not found, just start at the beginning of the timeline or at now() - if (segments?.length) { - const searchTime = parseInt( - segments[0].getAttribute("data-segment-id") || "0", - 10, - ); + if (nextAvailableSegment !== null) { + scrollToSegment(nextAvailableSegment); if (setDraggableElementTime) { - setDraggableElementTime(searchTime); + setDraggableElementTime(nextAvailableSegment); } } else { + // segment still not found, just start at the beginning of the timeline or at now() + const firstAvailableSegment = segments[0] || timelineStartAligned; + scrollToSegment(firstAvailableSegment); if (setDraggableElementTime) { - setDraggableElementTime(timelineStartAligned); + setDraggableElementTime(firstAvailableSegment); } } } diff --git a/web/src/hooks/use-navigation.ts b/web/src/hooks/use-navigation.ts index 06ebd6c1d..daed383d3 100644 --- a/web/src/hooks/use-navigation.ts +++ b/web/src/hooks/use-navigation.ts @@ -1,20 +1,29 @@ import { ENV } from "@/env"; +import { FrigateConfig } from "@/types/frigateConfig"; import { NavData } from "@/types/navigation"; import { useMemo } from "react"; +import { isDesktop } from "react-device-detect"; import { FaCompactDisc, FaVideo } from "react-icons/fa"; import { IoSearch } from "react-icons/io5"; import { LuConstruction } from "react-icons/lu"; import { MdVideoLibrary } from "react-icons/md"; +import { TbFaceId } from "react-icons/tb"; +import useSWR from "swr"; export const ID_LIVE = 1; export const ID_REVIEW = 2; export const ID_EXPLORE = 3; export const ID_EXPORT = 4; export const ID_PLAYGROUND = 5; +export const ID_FACE_LIBRARY = 6; export default function useNavigation( variant: "primary" | "secondary" = "primary", ) { + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + return useMemo( () => [ @@ -54,7 +63,15 @@ export default function useNavigation( url: "/playground", enabled: ENV !== "production", }, + { + id: ID_FACE_LIBRARY, + variant, + icon: TbFaceId, + title: "Face Library", + url: "/faces", + enabled: isDesktop && config?.face_recognition.enabled, + }, ] as NavData[], - [variant], + [config?.face_recognition.enabled, variant], ); } diff --git a/web/src/hooks/use-timeline-zoom.ts b/web/src/hooks/use-timeline-zoom.ts new file mode 100644 index 000000000..bcd996f77 --- /dev/null +++ b/web/src/hooks/use-timeline-zoom.ts @@ -0,0 +1,174 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { TimelineZoomDirection } from "@/types/review"; + +type ZoomSettings = { + segmentDuration: number; + timestampSpread: number; +}; + +type UseTimelineZoomProps = { + zoomSettings: ZoomSettings; + zoomLevels: ZoomSettings[]; + onZoomChange: (newZoomLevel: number) => void; + pinchThresholdPercent?: number; + timelineRef: React.RefObject; + timelineDuration: number; +}; + +export function useTimelineZoom({ + zoomSettings, + zoomLevels, + onZoomChange, + pinchThresholdPercent = 20, + timelineRef, + timelineDuration, +}: UseTimelineZoomProps) { + const [zoomLevel, setZoomLevel] = useState( + zoomLevels.findIndex( + (level) => + level.segmentDuration === zoomSettings.segmentDuration && + level.timestampSpread === zoomSettings.timestampSpread, + ), + ); + const [isZooming, setIsZooming] = useState(false); + const [zoomDirection, setZoomDirection] = + useState(null); + const touchStartDistanceRef = useRef(0); + + const getPinchThreshold = useCallback(() => { + return (window.innerHeight * pinchThresholdPercent) / 100; + }, [pinchThresholdPercent]); + + const wheelDeltaRef = useRef(0); + const isZoomingRef = useRef(false); + const debounceTimeoutRef = useRef(null); + + const handleZoom = useCallback( + (delta: number) => { + setIsZooming(true); + setZoomDirection(delta > 0 ? "out" : "in"); + setZoomLevel((prevLevel) => { + const newLevel = Math.max( + 0, + Math.min(zoomLevels.length - 1, prevLevel - delta), + ); + if (newLevel !== prevLevel && timelineRef.current) { + const { scrollTop, clientHeight, scrollHeight } = timelineRef.current; + + // get time at the center of the viewable timeline + const centerRatio = (scrollTop + clientHeight / 2) / scrollHeight; + const centerTime = centerRatio * timelineDuration; + + // calc the new total height based on the new zoom level + const newTotalHeight = + (timelineDuration / zoomLevels[newLevel].segmentDuration) * 8; + + // calc the new scroll position to keep the center time in view + const newScrollTop = + (centerTime / timelineDuration) * newTotalHeight - clientHeight / 2; + + onZoomChange(newLevel); + + // Apply new scroll position after a short delay to allow for DOM update + setTimeout(() => { + if (timelineRef.current) { + timelineRef.current.scrollTop = newScrollTop; + } + }, 0); + } + return newLevel; + }); + + setTimeout(() => { + setIsZooming(false); + setZoomDirection(null); + }, 500); + }, + [zoomLevels, onZoomChange, timelineRef, timelineDuration], + ); + + const debouncedZoom = useCallback(() => { + if (Math.abs(wheelDeltaRef.current) >= 200) { + handleZoom(wheelDeltaRef.current > 0 ? 1 : -1); + wheelDeltaRef.current = 0; + isZoomingRef.current = false; + } else { + isZoomingRef.current = false; + } + }, [handleZoom]); + + const handleWheel = useCallback( + (event: WheelEvent) => { + if (event.ctrlKey) { + event.preventDefault(); + + if (!isZoomingRef.current) { + wheelDeltaRef.current += event.deltaY; + + if (Math.abs(wheelDeltaRef.current) >= 200) { + isZoomingRef.current = true; + + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + + debounceTimeoutRef.current = setTimeout(() => { + debouncedZoom(); + }, 200); + } + } + } + }, + [debouncedZoom], + ); + + const handleTouchStart = useCallback((event: TouchEvent) => { + if (event.touches.length === 2) { + event.preventDefault(); + const touch1 = event.touches[0]; + const touch2 = event.touches[1]; + const distance = Math.hypot( + touch1.clientX - touch2.clientX, + touch1.clientY - touch2.clientY, + ); + touchStartDistanceRef.current = distance; + } + }, []); + + const handleTouchMove = useCallback( + (event: TouchEvent) => { + if (event.touches.length === 2) { + event.preventDefault(); + const touch1 = event.touches[0]; + const touch2 = event.touches[1]; + const currentDistance = Math.hypot( + touch1.clientX - touch2.clientX, + touch1.clientY - touch2.clientY, + ); + + const distanceDelta = currentDistance - touchStartDistanceRef.current; + const pinchThreshold = getPinchThreshold(); + + if (Math.abs(distanceDelta) > pinchThreshold) { + handleZoom(distanceDelta > 0 ? -1 : 1); + touchStartDistanceRef.current = currentDistance; + } + } + }, + [handleZoom, getPinchThreshold], + ); + + useEffect(() => { + window.addEventListener("wheel", handleWheel, { passive: false }); + window.addEventListener("touchstart", handleTouchStart, { passive: false }); + window.addEventListener("touchmove", handleTouchMove, { passive: false }); + + return () => { + window.removeEventListener("wheel", handleWheel); + window.removeEventListener("touchstart", handleTouchStart); + window.removeEventListener("touchmove", handleTouchMove); + }; + }, [handleWheel, handleTouchStart, handleTouchMove]); + + return { zoomLevel, handleZoom, isZooming, zoomDirection }; +} diff --git a/web/src/index.css b/web/src/index.css index 5c78fe925..9a294ff7c 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -179,3 +179,13 @@ html { border: 3px solid #a00000 !important; opacity: 0.5 !important; } + +.react-grid-layout, +.react-grid-layout .react-grid-item { + transition: none !important; +} + +.react-lazylog, +.react-lazylog-searchbar { + background-color: transparent !important; +} diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 28625bbd8..50db5211e 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -7,6 +7,7 @@ import { usePersistence } from "@/hooks/use-persistence"; import { FrigateConfig } from "@/types/frigateConfig"; import { RecordingStartingPoint } from "@/types/record"; import { + RecordingsSummary, REVIEW_PADDING, ReviewFilter, ReviewSegment, @@ -286,6 +287,16 @@ export default function Events() { updateSummary(); }, [updateSummary]); + // recordings summary + + const { data: recordingsSummary } = useSWR([ + "recordings/summary", + { + timezone: timezone, + cameras: reviewSearchParams["cameras"] ?? null, + }, + ]); + // preview videos const previewTimes = useMemo(() => { const startDate = new Date(selectedTimeRange.after * 1000); @@ -475,6 +486,7 @@ export default function Events() { reviewItems={reviewItems} currentReviewItems={currentItems} reviewSummary={reviewSummary} + recordingsSummary={recordingsSummary} relevantPreviews={allPreviews} timeRange={selectedTimeRange} filter={reviewFilter} diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 90bea004a..c005c43c2 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -112,6 +112,8 @@ export default function Explore() { search_type: searchSearchParams["search_type"], min_score: searchSearchParams["min_score"], max_score: searchSearchParams["max_score"], + min_speed: searchSearchParams["min_speed"], + max_speed: searchSearchParams["max_speed"], has_snapshot: searchSearchParams["has_snapshot"], is_submitted: searchSearchParams["is_submitted"], has_clip: searchSearchParams["has_clip"], @@ -145,6 +147,8 @@ export default function Explore() { search_type: searchSearchParams["search_type"], min_score: searchSearchParams["min_score"], max_score: searchSearchParams["max_score"], + min_speed: searchSearchParams["min_speed"], + max_speed: searchSearchParams["max_speed"], has_snapshot: searchSearchParams["has_snapshot"], is_submitted: searchSearchParams["is_submitted"], has_clip: searchSearchParams["has_clip"], diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index 212b001f0..529bb2e26 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -83,9 +83,11 @@ function Exports() { const onHandleRename = useCallback( (id: string, update: string) => { axios - .patch(`export/${id}/${encodeURIComponent(update)}`) + .patch(`export/${id}/rename`, { + name: update, + }) .then((response) => { - if (response.status == 200) { + if (response.status === 200) { setDeleteClip(undefined); mutate(); } diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx new file mode 100644 index 000000000..4bb6b39ff --- /dev/null +++ b/web/src/pages/FaceLibrary.tsx @@ -0,0 +1,515 @@ +import { baseUrl } from "@/api/baseUrl"; +import AddFaceIcon from "@/components/icons/AddFaceIcon"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; +import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { Toaster } from "@/components/ui/sonner"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import useOptimisticState from "@/hooks/use-optimistic-state"; +import { cn } from "@/lib/utils"; +import { FrigateConfig } from "@/types/frigateConfig"; +import axios from "axios"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { LuImagePlus, LuRefreshCw, LuScanFace, LuTrash2 } from "react-icons/lu"; +import { toast } from "sonner"; +import useSWR from "swr"; + +export default function FaceLibrary() { + const { data: config } = useSWR("config"); + + // title + + useEffect(() => { + document.title = "Face Library - Frigate"; + }, []); + + const [page, setPage] = useState(); + const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); + const tabsRef = useRef(null); + + // face data + + const { data: faceData, mutate: refreshFaces } = useSWR("faces"); + + const faces = useMemo( + () => + faceData ? Object.keys(faceData).filter((face) => face != "train") : [], + [faceData], + ); + const faceImages = useMemo( + () => (pageToggle && faceData ? faceData[pageToggle] : []), + [pageToggle, faceData], + ); + + const trainImages = useMemo( + () => faceData?.["train"] || [], + [faceData], + ); + + useEffect(() => { + if (!pageToggle) { + if (trainImages.length > 0) { + setPageToggle("train"); + } else if (faces) { + setPageToggle(faces[0]); + } + } else if (pageToggle == "train" && trainImages.length == 0) { + setPageToggle(faces[0]); + } + // we need to listen on the value of the faces list + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [trainImages, faces]); + + // upload + + const [upload, setUpload] = useState(false); + const [addFace, setAddFace] = useState(false); + + const onUploadImage = useCallback( + (file: File) => { + const formData = new FormData(); + formData.append("file", file); + axios + .post(`faces/${pageToggle}/register`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }) + .then((resp) => { + if (resp.status == 200) { + setUpload(false); + refreshFaces(); + toast.success("Successfully uploaded image.", { + position: "top-center", + }); + } + }) + .catch((error) => { + if (error.response?.data?.message) { + toast.error( + `Failed to upload image: ${error.response.data.message}`, + { position: "top-center" }, + ); + } else { + toast.error(`Failed to upload image: ${error.message}`, { + position: "top-center", + }); + } + }); + }, + [pageToggle, refreshFaces], + ); + + const onAddName = useCallback( + (name: string) => { + axios + .post(`faces/${name}/create`, { + headers: { + "Content-Type": "multipart/form-data", + }, + }) + .then((resp) => { + if (resp.status == 200) { + setAddFace(false); + refreshFaces(); + toast.success("Successfully add face library.", { + position: "top-center", + }); + } + }) + .catch((error) => { + if (error.response?.data?.message) { + toast.error( + `Failed to set face name: ${error.response.data.message}`, + { position: "top-center" }, + ); + } else { + toast.error(`Failed to set face name: ${error.message}`, { + position: "top-center", + }); + } + }); + }, + [refreshFaces], + ); + + if (!config) { + return ; + } + + return ( +
+ + + + + + +
+ +
+ { + if (value) { + setPageToggle(value); + } + }} + > + {trainImages.length > 0 && ( + <> + +
Train
+
+
|
+ + )} + + {Object.values(faces).map((item) => ( + +
+ {item} ({faceData[item].length}) +
+
+ ))} +
+ +
+
+
+ + +
+
+ {pageToggle && + (pageToggle == "train" ? ( + + ) : ( + + ))} +
+ ); +} + +type TrainingGridProps = { + config: FrigateConfig; + attemptImages: string[]; + faceNames: string[]; + onRefresh: () => void; +}; +function TrainingGrid({ + config, + attemptImages, + faceNames, + onRefresh, +}: TrainingGridProps) { + return ( +
+ {attemptImages.map((image: string) => ( + + ))} +
+ ); +} + +type FaceAttemptProps = { + image: string; + faceNames: string[]; + threshold: number; + onRefresh: () => void; +}; +function FaceAttempt({ + image, + faceNames, + threshold, + onRefresh, +}: FaceAttemptProps) { + const data = useMemo(() => { + const parts = image.split("-"); + + return { + eventId: `${parts[0]}-${parts[1]}`, + name: parts[2], + score: parts[3], + }; + }, [image]); + + const onTrainAttempt = useCallback( + (trainName: string) => { + axios + .post(`/faces/train/${trainName}/classify`, { training_file: image }) + .then((resp) => { + if (resp.status == 200) { + toast.success(`Successfully trained face.`, { + position: "top-center", + }); + onRefresh(); + } + }) + .catch((error) => { + if (error.response?.data?.message) { + toast.error(`Failed to train: ${error.response.data.message}`, { + position: "top-center", + }); + } else { + toast.error(`Failed to train: ${error.message}`, { + position: "top-center", + }); + } + }); + }, + [image, onRefresh], + ); + + const onReprocess = useCallback(() => { + axios + .post(`/faces/reprocess`, { training_file: image }) + .then((resp) => { + if (resp.status == 200) { + toast.success(`Successfully trained face.`, { + position: "top-center", + }); + onRefresh(); + } + }) + .catch((error) => { + if (error.response?.data?.message) { + toast.error(`Failed to train: ${error.response.data.message}`, { + position: "top-center", + }); + } else { + toast.error(`Failed to train: ${error.message}`, { + position: "top-center", + }); + } + }); + }, [image, onRefresh]); + + const onDelete = useCallback(() => { + axios + .post(`/faces/train/delete`, { ids: [image] }) + .then((resp) => { + if (resp.status == 200) { + toast.success(`Successfully deleted face.`, { + position: "top-center", + }); + onRefresh(); + } + }) + .catch((error) => { + if (error.response?.data?.message) { + toast.error(`Failed to delete: ${error.response.data.message}`, { + position: "top-center", + }); + } else { + toast.error(`Failed to delete: ${error.message}`, { + position: "top-center", + }); + } + }); + }, [image, onRefresh]); + + return ( +
+
+ +
+
+
+
+
{data.name}
+
= threshold + ? "text-success" + : "text-danger", + )} + > + {Number.parseFloat(data.score) * 100}% +
+
+
+ + + + + + + + + Train Face as: + {faceNames.map((faceName) => ( + onTrainAttempt(faceName)} + > + {faceName} + + ))} + + + Train Face as Person + + + + onReprocess()} + /> + + Delete Face Attempt + + + + + + Delete Face Attempt + +
+
+
+
+ ); +} + +type FaceGridProps = { + faceImages: string[]; + pageToggle: string; + onRefresh: () => void; +}; +function FaceGrid({ faceImages, pageToggle, onRefresh }: FaceGridProps) { + return ( +
+ {faceImages.map((image: string) => ( + + ))} +
+ ); +} + +type FaceImageProps = { + name: string; + image: string; + onRefresh: () => void; +}; +function FaceImage({ name, image, onRefresh }: FaceImageProps) { + const onDelete = useCallback(() => { + axios + .post(`/faces/${name}/delete`, { ids: [image] }) + .then((resp) => { + if (resp.status == 200) { + toast.success(`Successfully deleted face.`, { + position: "top-center", + }); + onRefresh(); + } + }) + .catch((error) => { + if (error.response?.data?.message) { + toast.error(`Failed to delete: ${error.response.data.message}`, { + position: "top-center", + }); + } else { + toast.error(`Failed to delete: ${error.message}`, { + position: "top-center", + }); + } + }); + }, [name, image, onRefresh]); + + return ( +
+
+ +
+
+
+
+
{name}
+
+
+ + + + + Delete Face Attempt + +
+
+
+
+ ); +} diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index 949fffb8a..a4b67f441 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -1,35 +1,47 @@ import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { LogData, LogLine, LogSeverity, LogType, logTypes } from "@/types/log"; +import { + LogLine, + LogSettingsType, + LogSeverity, + LogType, + logTypes, +} from "@/types/log"; import copy from "copy-to-clipboard"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import axios from "axios"; import LogInfoDialog from "@/components/overlay/LogInfoDialog"; import { LogChip } from "@/components/indicators/Chip"; -import { LogLevelFilterButton } from "@/components/filter/LogLevelFilter"; -import { FaCopy } from "react-icons/fa6"; +import { LogSettingsButton } from "@/components/filter/LogSettingsButton"; +import { FaCopy, FaDownload } from "react-icons/fa"; import { Toaster } from "@/components/ui/sonner"; import { toast } from "sonner"; -import { - isDesktop, - isMobile, - isMobileOnly, - isTablet, -} from "react-device-detect"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { cn } from "@/lib/utils"; -import { MdVerticalAlignBottom } from "react-icons/md"; import { parseLogLines } from "@/utils/logUtil"; -import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import scrollIntoView from "scroll-into-view-if-needed"; -import { FaDownload } from "react-icons/fa"; - -type LogRange = { start: number; end: number }; +import { LazyLog } from "@melloware/react-logviewer"; +import useKeyboardListener from "@/hooks/use-keyboard-listener"; +import EnhancedScrollFollow from "@/components/dynamic/EnhancedScrollFollow"; +import { MdCircle } from "react-icons/md"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { debounce } from "lodash"; function Logs() { const [logService, setLogService] = useState("frigate"); const tabsRef = useRef(null); + const lazyLogWrapperRef = useRef(null); + const [logs, setLogs] = useState([]); + const [filterSeverity, setFilterSeverity] = useState(); + const [selectedLog, setSelectedLog] = useState(); + const lazyLogRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const lastFetchedIndexRef = useRef(-1); useEffect(() => { document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`; @@ -49,92 +61,233 @@ function Logs() { } }, [tabsRef, logService]); - // log data handling + // log settings - const logPageSize = useMemo(() => { - if (isMobileOnly) { - return 15; - } + const [logSettings, setLogSettings] = useState({ + disableStreaming: false, + }); - if (isTablet) { - return 25; - } + // filter - return 40; - }, []); + const filterLines = useCallback( + (lines: string[]) => { + if (!filterSeverity?.length) return lines; - const [logRange, setLogRange] = useState({ start: 0, end: 0 }); - const [logs, setLogs] = useState([]); - const [logLines, setLogLines] = useState([]); + return lines.filter((line) => { + const parsedLine = parseLogLines(logService, [line])[0]; + return filterSeverity.includes(parsedLine.severity); + }); + }, + [filterSeverity, logService], + ); - useEffect(() => { - axios - .get(`logs/${logService}?start=-${logPageSize}`) - .then((resp) => { - if (resp.status == 200) { - const data = resp.data as LogData; - setLogRange({ - start: Math.max(0, data.totalLines - logPageSize), - end: data.totalLines, - }); - setLogs(data.lines); - setLogLines(parseLogLines(logService, data.lines)); + // fetchers + + const fetchLogRange = useCallback( + async (start: number, end: number) => { + try { + const response = await axios.get(`logs/${logService}`, { + params: { start, end }, + }); + if ( + response.status === 200 && + response.data && + Array.isArray(response.data.lines) + ) { + const filteredLines = filterLines(response.data.lines); + return filteredLines; } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "An unknown error occurred"; + toast.error(`Error fetching logs: ${errorMessage}`, { + position: "top-center", + }); + } + return []; + }, + [logService, filterLines], + ); + + const fetchInitialLogs = useCallback(async () => { + setIsLoading(true); + try { + const response = await axios.get(`logs/${logService}`, { + params: { start: filterSeverity ? 0 : -100 }, + }); + if ( + response.status === 200 && + response.data && + Array.isArray(response.data.lines) + ) { + const filteredLines = filterLines(response.data.lines); + setLogs(filteredLines); + lastFetchedIndexRef.current = + response.data.totalLines - filteredLines.length; + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "An unknown error occurred"; + toast.error(`Error fetching logs: ${errorMessage}`, { + position: "top-center", + }); + } finally { + setIsLoading(false); + } + }, [logService, filterLines, filterSeverity]); + + const abortControllerRef = useRef(null); + + const fetchLogsStream = useCallback(() => { + // Cancel any existing stream + abortControllerRef.current?.abort(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + let buffer = ""; + const decoder = new TextDecoder(); + + const processStreamChunk = ( + reader: ReadableStreamDefaultReader, + ): Promise => { + return reader.read().then(({ done, value }) => { + if (done) return; + + // Decode the chunk and add it to our buffer + buffer += decoder.decode(value, { stream: true }); + + // Split on newlines, keeping any partial line in the buffer + const lines = buffer.split("\n"); + + // Keep the last partial line + buffer = lines.pop() || ""; + + // Filter and append complete lines + if (lines.length > 0) { + const filteredLines = filterSeverity?.length + ? lines.filter((line) => { + const parsedLine = parseLogLines(logService, [line])[0]; + return filterSeverity.includes(parsedLine.severity); + }) + : lines; + if (filteredLines.length > 0) { + lazyLogRef.current?.appendLines(filteredLines); + } + } + // Process next chunk + return processStreamChunk(reader); + }); + }; + + fetch(`api/logs/${logService}?stream=true`, { + signal: abortController.signal, + }) + .then((response): Promise => { + if (!response.ok) { + throw new Error( + `Error while fetching log stream, status: ${response.status}`, + ); + } + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("No reader available"); + } + return processStreamChunk(reader); }) - .catch(() => {}); - }, [logPageSize, logService]); + .catch((error) => { + if (error.name !== "AbortError") { + const errorMessage = + error instanceof Error + ? error.message + : "An unknown error occurred"; + toast.error(`Error while streaming logs: ${errorMessage}`); + } + }); + }, [logService, filterSeverity]); useEffect(() => { - if (!logs || logs.length == 0) { - return; - } - - const id = setTimeout(() => { - axios - .get(`logs/${logService}?start=${logRange.end}`) - .then((resp) => { - if (resp.status == 200) { - const data = resp.data as LogData; - - if (data.lines.length > 0) { - setLogRange({ - start: logRange.start, - end: data.totalLines, - }); - setLogs([...logs, ...data.lines]); - setLogLines([ - ...logLines, - ...parseLogLines(logService, data.lines), - ]); - } - } - }) - .catch(() => {}); - }, 5000); + setIsLoading(true); + setLogs([]); + lastFetchedIndexRef.current = -1; + fetchInitialLogs().then(() => { + // Start streaming after initial load + if (!logSettings.disableStreaming) { + fetchLogsStream(); + } + }); return () => { - if (id) { - clearTimeout(id); - } + abortControllerRef.current?.abort(); }; - // we need to listen on the current range of visible items + // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - }, [logLines, logService, logRange]); + }, [logService, filterSeverity]); - // convert to log data + // handlers + + const prependLines = useCallback((newLines: string[]) => { + if (!lazyLogRef.current) return; + + const newLinesArray = newLines.map( + (line) => new Uint8Array(new TextEncoder().encode(line + "\n")), + ); + + lazyLogRef.current.setState((prevState) => ({ + ...prevState, + lines: prevState.lines.unshift(...newLinesArray), + count: prevState.count + newLines.length, + })); + }, []); + + // debounced + const handleScroll = useMemo( + () => + debounce(() => { + const scrollThreshold = + lazyLogRef.current?.listRef.current?.findEndIndex() ?? 10; + const startIndex = + lazyLogRef.current?.listRef.current?.findStartIndex() ?? 0; + const endIndex = + lazyLogRef.current?.listRef.current?.findEndIndex() ?? 0; + const pageSize = endIndex - startIndex; + if ( + scrollThreshold < pageSize + pageSize / 2 && + lastFetchedIndexRef.current > 0 && + !isLoading + ) { + const nextEnd = lastFetchedIndexRef.current; + const nextStart = Math.max(0, nextEnd - (pageSize || 100)); + setIsLoading(true); + + fetchLogRange(nextStart, nextEnd).then((newLines) => { + if (newLines.length > 0) { + prependLines(newLines); + lastFetchedIndexRef.current = nextStart; + + lazyLogRef.current?.listRef.current?.scrollTo( + newLines.length * + lazyLogRef.current?.listRef.current?.getItemSize(1), + ); + } + }); + + setIsLoading(false); + } + }, 50), + [fetchLogRange, isLoading, prependLines], + ); const handleCopyLogs = useCallback(() => { - if (logs) { - copy(logs.join("\n")); - toast.success( - logRange.start == 0 - ? "Copied logs to clipboard" - : "Copied visible logs to clipboard", - ); - } else { - toast.error("Could not copy logs to clipboard"); + if (logs.length) { + fetchInitialLogs() + .then(() => { + copy(logs.join("\n")); + toast.success("Copied logs to clipboard"); + }) + .catch(() => { + toast.error("Could not copy logs to clipboard"); + }); } - }, [logs, logRange]); + }, [logs, fetchInitialLogs]); const handleDownloadLogs = useCallback(() => { axios @@ -157,153 +310,76 @@ function Logs() { .catch(() => {}); }, [logService]); - // scroll to bottom - - const [initialScroll, setInitialScroll] = useState(false); - - const contentRef = useRef(null); - const [endVisible, setEndVisible] = useState(true); - const endObserver = useRef(null); - const endLogRef = useCallback( - (node: HTMLElement | null) => { - if (endObserver.current) endObserver.current.disconnect(); - try { - endObserver.current = new IntersectionObserver((entries) => { - setEndVisible(entries[0].isIntersecting); - }); - if (node) endObserver.current.observe(node); - } catch (e) { - // no op - } + const handleRowClick = useCallback( + (rowInfo: { lineNumber: number; rowIndex: number }) => { + const clickedLine = parseLogLines(logService, [ + logs[rowInfo.rowIndex], + ])[0]; + setSelectedLog(clickedLine); }, - [setEndVisible], - ); - const startObserver = useRef(null); - const startLogRef = useCallback( - (node: HTMLElement | null) => { - if (startObserver.current) startObserver.current.disconnect(); - - if (logs.length == 0 || !initialScroll) { - return; - } - - try { - startObserver.current = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && logRange.start > 0) { - const start = Math.max(0, logRange.start - logPageSize); - - axios - .get(`logs/${logService}?start=${start}&end=${logRange.start}`) - .then((resp) => { - if (resp.status == 200) { - const data = resp.data as LogData; - - if (data.lines.length > 0) { - setLogRange({ - start: start, - end: logRange.end, - }); - setLogs([...data.lines, ...logs]); - setLogLines([ - ...parseLogLines(logService, data.lines), - ...logLines, - ]); - } - } - }) - .catch(() => {}); - contentRef.current?.scrollBy({ - top: 10, - }); - } - }, - { rootMargin: `${10 * (isMobile ? 64 : 48)}px 0px 0px 0px` }, - ); - if (node) startObserver.current.observe(node); - } catch (e) { - // no op - } - }, - // we need to listen on the current range of visible items - // eslint-disable-next-line react-hooks/exhaustive-deps - [logRange, initialScroll], + [logs, logService], ); - useEffect(() => { - if (logLines.length == 0) { - setInitialScroll(false); - return; - } - - if (initialScroll) { - return; - } - - if (!contentRef.current) { - return; - } - - if (contentRef.current.scrollHeight <= contentRef.current.clientHeight) { - setInitialScroll(true); - return; - } - - contentRef.current?.scrollTo({ - top: contentRef.current?.scrollHeight, - behavior: "instant", - }); - setTimeout(() => setInitialScroll(true), 300); - // we need to listen on the current range of visible items - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [logLines, logService]); - - // log filtering - - const [filterSeverity, setFilterSeverity] = useState(); - - // log selection - - const [selectedLog, setSelectedLog] = useState(); - - // interaction + // keyboard listener useKeyboardListener( ["PageDown", "PageUp", "ArrowDown", "ArrowUp"], (key, modifiers) => { - if (!modifiers.down) { + if (!key || !modifiers.down || !lazyLogWrapperRef.current) { return; } - switch (key) { - case "PageDown": - contentRef.current?.scrollBy({ - top: 480, - }); - break; - case "PageUp": - contentRef.current?.scrollBy({ - top: -480, - }); - break; - case "ArrowDown": - contentRef.current?.scrollBy({ - top: 48, - }); - break; - case "ArrowUp": - contentRef.current?.scrollBy({ - top: -48, - }); - break; + const container = + lazyLogWrapperRef.current.querySelector(".react-lazylog"); + + const logLineHeight = container?.querySelector(".log-line")?.clientHeight; + + if (!logLineHeight) { + return; } + + const scrollAmount = key.includes("Page") + ? logLineHeight * 10 + : logLineHeight; + const direction = key.includes("Down") ? 1 : -1; + container?.scrollBy({ top: scrollAmount * direction }); }, ); + // format lines + + const lineBufferRef = useRef(""); + + const formatPart = useCallback( + (text: string) => { + lineBufferRef.current += text; + + if (text.endsWith("\n")) { + const completeLine = lineBufferRef.current.trim(); + lineBufferRef.current = ""; + + if (completeLine) { + const parsedLine = parseLogLines(logService, [completeLine])[0]; + return ( + setFilterSeverity([parsedLine.severity])} + onSelect={() => setSelectedLog(parsedLine)} + /> + ); + } + } + + return null; + }, + [logService, setFilterSeverity, setSelectedLog], + ); + useEffect(() => { const handleCopy = (e: ClipboardEvent) => { e.preventDefault(); - if (!contentRef.current) return; + if (!lazyLogWrapperRef.current) return; const selection = window.getSelection(); if (!selection) return; @@ -371,7 +447,7 @@ function Logs() { e.clipboardData?.setData("text/plain", copyText); }; - const content = contentRef.current; + const content = lazyLogWrapperRef.current; content?.addEventListener("copy", handleCopy); return () => { content?.removeEventListener("copy", handleCopy); @@ -393,11 +469,10 @@ function Logs() { onValueChange={(value: LogType) => { if (value) { setLogs([]); - setLogLines([]); setFilterSeverity(undefined); setLogService(value); } - }} // don't allow the severity to be unselected + }} > {Object.values(logTypes).map((item) => (
Download
-
- {initialScroll && !endVisible && ( - - )} - -
-
-
Type
-
- Timestamp +
+
+
+
+
Type
+
Timestamp
+
-
Tag
-
- Message +
+ Tag +
+
+
Message
-
- {logLines.length > 0 && - [...Array(logRange.end).keys()].map((idx) => { - const logLine = - idx >= logRange.start - ? logLines[idx - logRange.start] - : undefined; - if (logLine) { - const line = logLines[idx - logRange.start]; - if (filterSeverity && !filterSeverity.includes(line.severity)) { - return ( -
- ); - } - - return ( - + {isLoading ? ( + + ) : ( + ( + <> + {follow && !logSettings.disableStreaming && ( +
+ + + + + + Logs are streaming from the server + + +
+ )} + } - className={initialScroll ? "" : "invisible"} - line={line} - onClickSeverity={() => setFilterSeverity([line.severity])} - onSelect={() => setSelectedLog(line)} + loading={isLoading} /> - ); - } - - return ( -
- ); - })} - {logLines.length > 0 &&
} + + )} + /> + )}
- {logLines.length == 0 && ( - - )}
); } type LogLineDataProps = { - startRef?: (node: HTMLDivElement | null) => void; - className: string; + className?: string; line: LogLine; + logService: string; onClickSeverity: () => void; onSelect: () => void; }; + function LogLineData({ - startRef, className, line, + logService, onClickSeverity, onSelect, }: LogLineDataProps) { return (
-
- +
+
+
+ +
+
+ {line.dateStamp} +
+
-
- {line.dateStamp} -
-
+ +
{line.section}
-
+
{line.content}
diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 92d992f8a..fbf31a00a 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -37,6 +37,8 @@ import AuthenticationView from "@/views/settings/AuthenticationView"; import NotificationView from "@/views/settings/NotificationsSettingsView"; import SearchSettingsView from "@/views/settings/SearchSettingsView"; import UiSettingsView from "@/views/settings/UiSettingsView"; +import { useSearchEffect } from "@/hooks/use-overlay-state"; +import { useSearchParams } from "react-router-dom"; const allSettingsViews = [ "UI settings", @@ -57,6 +59,8 @@ export default function Settings() { const { data: config } = useSWR("config"); + const [searchParams] = useSearchParams(); + // available settings views const settingsViews = useMemo(() => { @@ -119,6 +123,23 @@ export default function Settings() { } }, [tabsRef, pageToggle]); + useSearchEffect("page", (page: string) => { + if (allSettingsViews.includes(page as SettingsType)) { + setPage(page as SettingsType); + } + // don't clear url params if we're creating a new object mask + return !searchParams.has("object_mask"); + }); + + useSearchEffect("camera", (camera: string) => { + const cameraNames = cameras.map((c) => c.name); + if (cameraNames.includes(camera)) { + setSelectedCamera(camera); + } + // don't clear url params if we're creating a new object mask + return !searchParams.has("object_mask"); + }); + useEffect(() => { document.title = "Settings - Frigate"; }, []); diff --git a/web/src/pages/System.tsx b/web/src/pages/System.tsx index 23d1b7e6a..491149be2 100644 --- a/web/src/pages/System.tsx +++ b/web/src/pages/System.tsx @@ -1,12 +1,12 @@ import useSWR from "swr"; import { FrigateStats } from "@/types/stats"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import TimeAgo from "@/components/dynamic/TimeAgo"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { isDesktop, isMobile } from "react-device-detect"; import GeneralMetrics from "@/views/system/GeneralMetrics"; import StorageMetrics from "@/views/system/StorageMetrics"; -import { LuActivity, LuHardDrive } from "react-icons/lu"; +import { LuActivity, LuHardDrive, LuSearchCode } from "react-icons/lu"; import { FaVideo } from "react-icons/fa"; import Logo from "@/components/Logo"; import useOptimisticState from "@/hooks/use-optimistic-state"; @@ -14,11 +14,28 @@ import CameraMetrics from "@/views/system/CameraMetrics"; import { useHashState } from "@/hooks/use-overlay-state"; import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { Toaster } from "@/components/ui/sonner"; +import { FrigateConfig } from "@/types/frigateConfig"; +import FeatureMetrics from "@/views/system/FeatureMetrics"; -const metrics = ["general", "storage", "cameras"] as const; -type SystemMetric = (typeof metrics)[number]; +const allMetrics = ["general", "features", "storage", "cameras"] as const; +type SystemMetric = (typeof allMetrics)[number]; function System() { + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + + const metrics = useMemo(() => { + const metrics = [...allMetrics]; + + if (!config?.semantic_search.enabled) { + const index = metrics.indexOf("features"); + metrics.splice(index, 1); + } + + return metrics; + }, [config]); + // stats page const [page, setPage] = useHashState(); @@ -67,6 +84,7 @@ function System() { aria-label={`Select ${item}`} > {item == "general" && } + {item == "features" && } {item == "storage" && } {item == "cameras" && } {isDesktop &&
{item}
} @@ -96,6 +114,12 @@ function System() { setLastUpdated={setLastUpdated} /> )} + {page == "features" && ( + + )} {page == "storage" && } {page == "cameras" && ( [ + { segmentDuration: 60, timestampSpread: 15 }, + { segmentDuration: 30, timestampSpread: 5 }, + { segmentDuration: 10, timestampSpread: 1 }, + ], + [], + ); - function handleZoomIn() { - const nextZoomLevel = Math.min( - possibleZoomLevels.length - 1, - zoomLevel + 1, - ); - setZoomLevel(nextZoomLevel); - setZoomSettings(possibleZoomLevels[nextZoomLevel]); - } + const handleZoomChange = useCallback( + (newZoomLevel: number) => { + setZoomSettings(possibleZoomLevels[newZoomLevel]); + }, + [possibleZoomLevels], + ); - function handleZoomOut() { - const nextZoomLevel = Math.max(0, zoomLevel - 1); - setZoomLevel(nextZoomLevel); - setZoomSettings(possibleZoomLevels[nextZoomLevel]); - } + const { zoomLevel, handleZoom, isZooming, zoomDirection } = useTimelineZoom({ + zoomSettings, + zoomLevels: possibleZoomLevels, + onZoomChange: handleZoomChange, + timelineRef: reviewTimelineRef, + timelineDuration: 4 * 60 * 60, + }); + + const handleZoomIn = () => handleZoom(-1); + const handleZoomOut = () => handleZoom(1); const [isDragging, setIsDragging] = useState(false); @@ -330,6 +336,7 @@ function UIPlayground() {
+ zoom level: {zoomLevel}

-
+
{loading ? ( ) : ( )}
@@ -780,7 +814,7 @@ function DetectionReview({ reviewTimelineRef={reviewTimelineRef} timelineStart={timeRange.before} timelineEnd={timeRange.after} - segmentDuration={segmentDuration} + segmentDuration={zoomSettings.segmentDuration} events={reviewItems?.all ?? []} severityType={severity} /> @@ -1095,7 +1129,6 @@ function MotionReview({ setHandlebarTime={setCurrentTime} events={reviewItems?.all ?? []} motion_events={motionData ?? []} - severityType="significant_motion" contentRef={contentRef} onHandlebarDraggingChange={(scrubbing) => { if (playing && scrubbing) { @@ -1105,6 +1138,8 @@ function MotionReview({ setScrubbing(scrubbing); }} dense={isMobileOnly} + isZooming={false} + zoomDirection={null} /> ) : ( diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index fc2d9bb52..131f44770 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -1,5 +1,6 @@ import { usePersistence } from "@/hooks/use-persistence"; import { + AllGroupsStreamingSettings, BirdseyeConfig, CameraConfig, FrigateConfig, @@ -20,7 +21,12 @@ import { } from "react-grid-layout"; import "react-grid-layout/css/styles.css"; import "react-resizable/css/styles.css"; -import { LivePlayerError, LivePlayerMode } from "@/types/live"; +import { + AudioState, + LivePlayerMode, + StatsState, + VolumeState, +} from "@/types/live"; import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; import { Skeleton } from "@/components/ui/skeleton"; import { useResizeObserver } from "@/hooks/resize-observer"; @@ -42,6 +48,8 @@ import { } from "@/components/ui/tooltip"; import { Toaster } from "@/components/ui/sonner"; import useCameraLiveMode from "@/hooks/use-camera-live-mode"; +import LiveContextMenu from "@/components/menu/LiveContextMenu"; +import { useStreamingSettings } from "@/context/streaming-settings-provider"; type DraggableGridLayoutProps = { cameras: CameraConfig[]; @@ -76,8 +84,26 @@ export default function DraggableGridLayout({ // preferred live modes per camera - const { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode } = - useCameraLiveMode(cameras, windowVisible); + const { + preferredLiveModes, + setPreferredLiveModes, + resetPreferredLiveMode, + isRestreamedStates, + supportsAudioOutputStates, + } = useCameraLiveMode(cameras, windowVisible); + + const [globalAutoLive] = usePersistence("autoLiveView", true); + + const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } = + useStreamingSettings(); + + const currentGroupStreamingSettings = useMemo(() => { + if (cameraGroup && cameraGroup != "default" && allGroupsStreamingSettings) { + return allGroupsStreamingSettings[cameraGroup]; + } + }, [allGroupsStreamingSettings, cameraGroup]); + + // grid layout const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); @@ -342,6 +368,105 @@ export default function DraggableGridLayout({ placeholder.h = layoutItem.h; }; + // audio and stats states + + const [audioStates, setAudioStates] = useState({}); + const [volumeStates, setVolumeStates] = useState({}); + const [statsStates, setStatsStates] = useState(() => { + const initialStates: StatsState = {}; + cameras.forEach((camera) => { + initialStates[camera.name] = false; + }); + return initialStates; + }); + + const toggleStats = (cameraName: string): void => { + setStatsStates((prev) => ({ + ...prev, + [cameraName]: !prev[cameraName], + })); + }; + + useEffect(() => { + if (!allGroupsStreamingSettings) { + return; + } + + const initialAudioStates: AudioState = {}; + const initialVolumeStates: VolumeState = {}; + + Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => { + Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { + initialAudioStates[camera] = cameraSettings.playAudio ?? false; + initialVolumeStates[camera] = cameraSettings.volume ?? 1; + }); + }); + + setAudioStates(initialAudioStates); + setVolumeStates(initialVolumeStates); + }, [allGroupsStreamingSettings]); + + const toggleAudio = (cameraName: string) => { + setAudioStates((prev) => ({ + ...prev, + [cameraName]: !prev[cameraName], + })); + }; + + const onSaveMuting = useCallback( + (playAudio: boolean) => { + if (!cameraGroup || !allGroupsStreamingSettings) { + return; + } + + const existingGroupSettings = + allGroupsStreamingSettings[cameraGroup] || {}; + + const updatedSettings: AllGroupsStreamingSettings = { + ...Object.fromEntries( + Object.entries(allGroupsStreamingSettings || {}).filter( + ([key]) => key !== cameraGroup, + ), + ), + [cameraGroup]: { + ...existingGroupSettings, + ...Object.fromEntries( + Object.entries(existingGroupSettings).map( + ([cameraName, settings]) => [ + cameraName, + { + ...settings, + playAudio: playAudio, + }, + ], + ), + ), + }, + }; + + setAllGroupsStreamingSettings?.(updatedSettings); + }, + [cameraGroup, allGroupsStreamingSettings, setAllGroupsStreamingSettings], + ); + + const muteAll = () => { + const updatedStates: AudioState = {}; + cameras.forEach((camera) => { + updatedStates[camera.name] = false; + }); + setAudioStates(updatedStates); + onSaveMuting(false); + }; + + const unmuteAll = () => { + const updatedStates: AudioState = {}; + cameras.forEach((camera) => { + updatedStates[camera.name] = true; + }); + setAudioStates(updatedStates); + onSaveMuting(true); + }; + return ( <> @@ -364,7 +489,7 @@ export default function DraggableGridLayout({
) : (
{ - !isEditMode && onSelectCamera(camera.name); - }} - onError={(e) => { - setPreferredLiveModes((prevModes) => { - const newModes = { ...prevModes }; - if (e === "mse-decode") { - newModes[camera.name] = "webrtc"; - } else { - newModes[camera.name] = "jsmpeg"; - } - return newModes; - }); - }} - onResetLiveMode={() => resetPreferredLiveMode(camera.name)} + isRestreamed={isRestreamedStates[camera.name]} + supportsAudio={ + supportsAudioOutputStates[streamName].supportsAudio + } + audioState={audioStates[camera.name]} + toggleAudio={() => toggleAudio(camera.name)} + statsState={statsStates[camera.name]} + toggleStats={() => toggleStats(camera.name)} + volumeState={volumeStates[camera.name]} + setVolumeState={(value) => + setVolumeStates({ + [camera.name]: value, + }) + } + muteAll={muteAll} + unmuteAll={unmuteAll} + resetPreferredLiveMode={() => + resetPreferredLiveMode(camera.name) + } > + { + !isEditMode && onSelectCamera(camera.name); + }} + onError={(e) => { + setPreferredLiveModes((prevModes) => { + const newModes = { ...prevModes }; + if (e === "mse-decode") { + newModes[camera.name] = "webrtc"; + } else { + newModes[camera.name] = "jsmpeg"; + } + return newModes; + }); + }} + onResetLiveMode={() => resetPreferredLiveMode(camera.name)} + playAudio={audioStates[camera.name]} + volume={volumeStates[camera.name]} + /> {isEditMode && showCircles && } - + ); })} @@ -596,41 +768,57 @@ const BirdseyeLivePlayerGridItem = React.forwardRef< }, ); -type LivePlayerGridItemProps = { +type GridLiveContextMenuProps = { + className?: string; style?: React.CSSProperties; - className: string; onMouseDown?: React.MouseEventHandler; onMouseUp?: React.MouseEventHandler; onTouchEnd?: React.TouchEventHandler; children?: React.ReactNode; - cameraRef: (node: HTMLElement | null) => void; - windowVisible: boolean; - cameraConfig: CameraConfig; - preferredLiveMode: LivePlayerMode; - onClick: () => void; - onError: (e: LivePlayerError) => void; - onResetLiveMode: () => void; + camera: string; + streamName: string; + cameraGroup: string; + preferredLiveMode: string; + isRestreamed: boolean; + supportsAudio: boolean; + audioState: boolean; + toggleAudio: () => void; + statsState: boolean; + toggleStats: () => void; + volumeState?: number; + setVolumeState: (volumeState: number) => void; + muteAll: () => void; + unmuteAll: () => void; + resetPreferredLiveMode: () => void; }; -const LivePlayerGridItem = React.forwardRef< +const GridLiveContextMenu = React.forwardRef< HTMLDivElement, - LivePlayerGridItemProps + GridLiveContextMenuProps >( ( { - style, className, + style, onMouseDown, onMouseUp, onTouchEnd, children, - cameraRef, - windowVisible, - cameraConfig, + camera, + streamName, + cameraGroup, preferredLiveMode, - onClick, - onError, - onResetLiveMode, + isRestreamed, + supportsAudio, + audioState, + toggleAudio, + statsState, + toggleStats, + volumeState, + setVolumeState, + muteAll, + unmuteAll, + resetPreferredLiveMode, ...props }, ref, @@ -644,18 +832,26 @@ const LivePlayerGridItem = React.forwardRef< onTouchEnd={onTouchEnd} {...props} > - } - /> - {children} + isRestreamed={isRestreamed} + supportsAudio={supportsAudio} + audioState={audioState} + toggleAudio={toggleAudio} + statsState={statsState} + toggleStats={toggleStats} + volumeState={volumeState} + setVolumeState={setVolumeState} + muteAll={muteAll} + unmuteAll={unmuteAll} + resetPreferredLiveMode={resetPreferredLiveMode} + > + {children} +
); }, diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index af3ed0cee..ccf06de7b 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -17,6 +17,11 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Tooltip, TooltipContent, @@ -62,29 +67,52 @@ import { FaMicrophoneSlash, } from "react-icons/fa"; import { GiSpeaker, GiSpeakerOff } from "react-icons/gi"; -import { TbViewfinder, TbViewfinderOff } from "react-icons/tb"; -import { IoMdArrowRoundBack } from "react-icons/io"; import { + TbRecordMail, + TbRecordMailOff, + TbViewfinder, + TbViewfinderOff, +} from "react-icons/tb"; +import { IoIosWarning, IoMdArrowRoundBack } from "react-icons/io"; +import { + LuCheck, LuEar, LuEarOff, + LuExternalLink, LuHistory, + LuInfo, LuPictureInPicture, LuVideo, LuVideoOff, + LuX, } from "react-icons/lu"; import { MdNoPhotography, + MdOutlineRestartAlt, MdPersonOff, MdPersonSearch, MdPhotoCamera, MdZoomIn, MdZoomOut, } from "react-icons/md"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; import useSWR from "swr"; import { cn } from "@/lib/utils"; import { useSessionPersistence } from "@/hooks/use-session-persistence"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, +} from "@/components/ui/select"; +import { usePersistence } from "@/hooks/use-persistence"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import axios from "axios"; +import { toast } from "sonner"; +import { Toaster } from "@/components/ui/sonner"; type LiveCameraViewProps = { config?: FrigateConfig; @@ -109,17 +137,20 @@ export default function LiveCameraView({ // supported features + const [streamName, setStreamName] = usePersistence( + `${camera.name}-stream`, + Object.values(camera.live.streams)[0], + ); + const isRestreamed = useMemo( () => config && - Object.keys(config.go2rtc.streams || {}).includes( - camera.live.stream_name, - ), - [camera, config], + Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""), + [config, streamName], ); const { data: cameraMetadata } = useSWR( - isRestreamed ? `go2rtc/streams/${camera.live.stream_name}` : null, + isRestreamed ? `go2rtc/streams/${streamName}` : null, { revalidateOnFocus: false, }, @@ -209,6 +240,13 @@ export default function LiveCameraView({ const [pip, setPip] = useState(false); const [lowBandwidth, setLowBandwidth] = useState(false); + const [playInBackground, setPlayInBackground] = usePersistence( + `${camera.name}-background-play`, + false, + ); + + const [showStats, setShowStats] = useState(false); + const [fullResolution, setFullResolution] = useState({ width: 0, height: 0, @@ -337,6 +375,7 @@ export default function LiveCameraView({ return ( +
)}
@@ -499,9 +549,13 @@ export default function LiveCameraView({ showStillWithoutActivity={false} cameraConfig={camera} playAudio={audio} + playInBackground={playInBackground ?? false} + showStats={showStats} micEnabled={mic} iOSCompatFullScreen={isIOS} preferredLiveMode={preferredLiveMode} + useWebGL={true} + streamName={streamName ?? ""} pip={pip} containerRef={containerRef} setFullResolution={setFullResolution} @@ -816,12 +870,49 @@ function PtzControlPanel({ ); } +function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) { + const rankMap = { all: 0, motion: 1, active_objects: 2 }; + const getValidMode = (retain?: { mode?: string }): keyof typeof rankMap => { + const mode = retain?.mode; + return mode && mode in rankMap ? (mode as keyof typeof rankMap) : "all"; + }; + + const recordRetainMode = getValidMode(camera.record.retain); + const alertsRetainMode = getValidMode(camera.review.alerts.retain); + + const effectiveRetainMode = + rankMap[alertsRetainMode] < rankMap[recordRetainMode] + ? recordRetainMode + : alertsRetainMode; + + const source = effectiveRetainMode === recordRetainMode ? "camera" : "alerts"; + + return effectiveRetainMode !== "all" ? ( +
+ Your {source} recording retention configuration is set to{" "} + mode: {effectiveRetainMode}, so this on-demand recording will + only keep segments with {effectiveRetainMode.replaceAll("_", " ")}. +
+ ) : null; +} + type FrigateCameraFeaturesProps = { - camera: string; + camera: CameraConfig; recordingEnabled: boolean; audioDetectEnabled: boolean; autotrackingEnabled: boolean; fullscreen: boolean; + streamName: string; + setStreamName?: (value: string | undefined) => void; + preferredLiveMode: string; + playInBackground: boolean; + setPlayInBackground: (value: boolean | undefined) => void; + showStats: boolean; + setShowStats: (value: boolean) => void; + isRestreamed: boolean; + setLowBandwidth: React.Dispatch>; + supportsAudioOutput: boolean; + supports2WayTalk: boolean; }; function FrigateCameraFeatures({ camera, @@ -829,14 +920,124 @@ function FrigateCameraFeatures({ audioDetectEnabled, autotrackingEnabled, fullscreen, + streamName, + setStreamName, + preferredLiveMode, + playInBackground, + setPlayInBackground, + showStats, + setShowStats, + isRestreamed, + setLowBandwidth, + supportsAudioOutput, + supports2WayTalk, }: FrigateCameraFeaturesProps) { - const { payload: detectState, send: sendDetect } = useDetectState(camera); - const { payload: recordState, send: sendRecord } = useRecordingsState(camera); - const { payload: snapshotState, send: sendSnapshot } = - useSnapshotsState(camera); - const { payload: audioState, send: sendAudio } = useAudioState(camera); + const { payload: detectState, send: sendDetect } = useDetectState( + camera.name, + ); + const { payload: recordState, send: sendRecord } = useRecordingsState( + camera.name, + ); + const { payload: snapshotState, send: sendSnapshot } = useSnapshotsState( + camera.name, + ); + const { payload: audioState, send: sendAudio } = useAudioState(camera.name); const { payload: autotrackingState, send: sendAutotracking } = - useAutotrackingState(camera); + useAutotrackingState(camera.name); + + // manual event + + const recordingEventIdRef = useRef(null); + const [isRecording, setIsRecording] = useState(false); + const [activeToastId, setActiveToastId] = useState( + null, + ); + + const createEvent = useCallback(async () => { + try { + const response = await axios.post( + `events/${camera.name}/on_demand/create`, + { + include_recording: true, + duration: null, + }, + ); + + if (response.data.success) { + recordingEventIdRef.current = response.data.event_id; + setIsRecording(true); + const toastId = toast.success( +
+
+ Started manual on-demand recording. +
+ {!camera.record.enabled || camera.record.retain.days == 0 ? ( +
+ Since recording is disabled or restricted in the config for this + camera, only a snapshot will be saved. +
+ ) : ( + + )} +
, + { + position: "top-center", + duration: 10000, + }, + ); + setActiveToastId(toastId); + } + } catch (error) { + toast.error("Failed to start manual on-demand recording.", { + position: "top-center", + }); + } + }, [camera]); + + const endEvent = useCallback(() => { + if (activeToastId) { + toast.dismiss(activeToastId); + } + try { + if (recordingEventIdRef.current) { + axios.put(`events/${recordingEventIdRef.current}/end`, { + end_time: Math.ceil(Date.now() / 1000), + }); + recordingEventIdRef.current = null; + setIsRecording(false); + toast.success("Ended manual on-demand recording.", { + position: "top-center", + }); + } + } catch (error) { + toast.error("Failed to end manual on-demand recording.", { + position: "top-center", + }); + } + }, [activeToastId]); + + const handleEventButtonClick = useCallback(() => { + if (isRecording) { + endEvent(); + } else { + createEvent(); + } + }, [createEvent, endEvent, isRecording]); + + useEffect(() => { + // ensure manual event is stopped when component unmounts + return () => { + if (recordingEventIdRef.current) { + endEvent(); + } + }; + // mount/unmount only + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // navigate for debug view + + const navigate = useNavigate(); // desktop shows icons part of row if (isDesktop || isTablet) { @@ -888,6 +1089,264 @@ function FrigateCameraFeatures({ } /> )} + + + + +
+ +
+
+ +
+ {!isRestreamed && ( +
+ +
+ +
Restreaming is not enabled for this camera.
+ + +
+ + Info +
+
+ + Set up go2rtc for additional live view options and audio + for this camera. +
+ + Read the documentation{" "} + + +
+
+
+
+
+ )} + {isRestreamed && + Object.values(camera.live.streams).length > 0 && ( +
+ + + + {preferredLiveMode != "jsmpeg" && isRestreamed && ( +
+ {supportsAudioOutput ? ( + <> + +
Audio is available for this stream
+ + ) : ( + <> + +
Audio is unavailable for this stream
+ + +
+ + Info +
+
+ + Audio must be output from your camera and + configured in go2rtc for this stream. +
+ + Read the documentation{" "} + + +
+
+
+ + )} +
+ )} + {preferredLiveMode != "jsmpeg" && + isRestreamed && + supportsAudioOutput && ( +
+ {supports2WayTalk ? ( + <> + +
+ Two-way talk is available for this stream +
+ + ) : ( + <> + +
+ Two-way talk is unavailable for this stream +
+ + +
+ + Info +
+
+ + Your device must suppport the feature and + WebRTC must be configured for two-way talk. +
+ + Read the documentation{" "} + + +
+
+
+ + )} +
+ )} + + {preferredLiveMode == "jsmpeg" && isRestreamed && ( +
+
+ + +

+ Live view is in low-bandwidth mode due to buffering + or stream errors. +

+
+ +
+ )} +
+ )} + {isRestreamed && ( +
+
+ + + setPlayInBackground(checked) + } + /> +
+

+ Enable this option to continue streaming when the player is + hidden. +

+
+ )} +
+
+ + setShowStats(checked)} + /> +
+

+ Enable this option to show stream statistics as an overlay on + the camera feed. +

+
+
+
+ Debug View + + navigate(`/settings?page=debug&camera=${camera.name}`) + } + className="ml-2 inline-flex size-5 cursor-pointer" + /> +
+
+
+
+
); } @@ -908,44 +1367,276 @@ function FrigateCameraFeatures({ title={`${camera} Settings`} /> - - sendDetect(detectState == "ON" ? "OFF" : "ON")} - /> - {recordingEnabled && ( + +
- sendRecord(recordState == "ON" ? "OFF" : "ON") + sendDetect(detectState == "ON" ? "OFF" : "ON") } /> - )} - - sendSnapshot(snapshotState == "ON" ? "OFF" : "ON") - } - /> - {audioDetectEnabled && ( + {recordingEnabled && ( + + sendRecord(recordState == "ON" ? "OFF" : "ON") + } + /> + )} sendAudio(audioState == "ON" ? "OFF" : "ON")} - /> - )} - {autotrackingEnabled && ( - - sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") + sendSnapshot(snapshotState == "ON" ? "OFF" : "ON") } /> - )} + {audioDetectEnabled && ( + + sendAudio(audioState == "ON" ? "OFF" : "ON") + } + /> + )} + {autotrackingEnabled && ( + + sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") + } + /> + )} +
+
+ {!isRestreamed && ( +
+ +
+ +
Restreaming is not enabled for this camera.
+ + +
+ + Info +
+
+ + Set up go2rtc for additional live view options and audio for + this camera. +
+ + Read the documentation{" "} + + +
+
+
+
+
+ )} + {isRestreamed && Object.values(camera.live.streams).length > 0 && ( +
+
Stream
+ + {preferredLiveMode != "jsmpeg" && isRestreamed && ( +
+ {supportsAudioOutput ? ( + <> + +
Audio is available for this stream
+ + ) : ( + <> + +
Audio is unavailable for this stream
+ + +
+ + Info +
+
+ + Audio must be output from your camera and configured + in go2rtc for this stream. +
+ + Read the documentation{" "} + + +
+
+
+ + )} +
+ )} + {preferredLiveMode != "jsmpeg" && + isRestreamed && + supportsAudioOutput && ( +
+ {supports2WayTalk ? ( + <> + +
Two-way talk is available for this stream
+ + ) : ( + <> + +
Two-way talk is unavailable for this stream
+ + +
+ + Info +
+
+ + Your device must suppport the feature and WebRTC + must be configured for two-way talk. +
+ + Read the documentation{" "} + + +
+
+
+ + )} +
+ )} + {preferredLiveMode == "jsmpeg" && isRestreamed && ( +
+
+ + +

+ Live view is in low-bandwidth mode due to buffering or + stream errors. +

+
+ +
+ )} +
+ )} +
+
+ On-Demand Recording +
+ +

+ Start a manual event based on this camera's recording retention + settings. +

+
+ {isRestreamed && ( + <> +
+ { + setPlayInBackground(checked); + }} + /> +

+ Enable this option to continue streaming when the player is + hidden. +

+
+
+ { + setShowStats(checked); + }} + /> +

+ Enable this option to show stream statistics as an overlay on + the camera feed. +

+
+ + )} +
+
+ Debug View + + navigate(`/settings?page=debug&camera=${camera.name}`) + } + className="ml-2 inline-flex size-5 cursor-pointer" + /> +
+
+
); diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 7642d5a0d..89a2aeef2 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -14,7 +14,11 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { usePersistence } from "@/hooks/use-persistence"; -import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; +import { + AllGroupsStreamingSettings, + CameraConfig, + FrigateConfig, +} from "@/types/frigateConfig"; import { ReviewSegment } from "@/types/review"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -28,10 +32,17 @@ import DraggableGridLayout from "./DraggableGridLayout"; import { IoClose } from "react-icons/io5"; import { LuLayoutDashboard } from "react-icons/lu"; import { cn } from "@/lib/utils"; -import { LivePlayerError } from "@/types/live"; +import { + AudioState, + LivePlayerError, + StatsState, + VolumeState, +} from "@/types/live"; import { FaCompress, FaExpand } from "react-icons/fa"; import useCameraLiveMode from "@/hooks/use-camera-live-mode"; import { useResizeObserver } from "@/hooks/resize-observer"; +import LiveContextMenu from "@/components/menu/LiveContextMenu"; +import { useStreamingSettings } from "@/context/streaming-settings-provider"; type LiveDashboardViewProps = { cameras: CameraConfig[]; @@ -129,8 +140,6 @@ export default function LiveDashboardView({ // camera live views - const [autoLiveView] = usePersistence("autoLiveView", true); - const [{ height: containerHeight }] = useResizeObserver(containerRef); const hasScrollbar = useMemo(() => { @@ -184,8 +193,24 @@ export default function LiveDashboardView({ }; }, []); - const { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode } = - useCameraLiveMode(cameras, windowVisible); + const { + preferredLiveModes, + setPreferredLiveModes, + resetPreferredLiveMode, + isRestreamedStates, + supportsAudioOutputStates, + } = useCameraLiveMode(cameras, windowVisible); + + const [globalAutoLive] = usePersistence("autoLiveView", true); + + const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } = + useStreamingSettings(); + + const currentGroupStreamingSettings = useMemo(() => { + if (cameraGroup && cameraGroup != "default" && allGroupsStreamingSettings) { + return allGroupsStreamingSettings[cameraGroup]; + } + }, [allGroupsStreamingSettings, cameraGroup]); const cameraRef = useCallback( (node: HTMLElement | null) => { @@ -221,9 +246,106 @@ export default function LiveDashboardView({ [setPreferredLiveModes], ); + // audio states + + const [audioStates, setAudioStates] = useState({}); + const [volumeStates, setVolumeStates] = useState({}); + const [statsStates, setStatsStates] = useState({}); + + const toggleStats = (cameraName: string): void => { + setStatsStates((prev) => ({ + ...prev, + [cameraName]: !prev[cameraName], + })); + }; + + useEffect(() => { + if (!allGroupsStreamingSettings) { + return; + } + + const initialAudioStates: AudioState = {}; + const initialVolumeStates: VolumeState = {}; + + Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => { + Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { + initialAudioStates[camera] = cameraSettings.playAudio ?? false; + initialVolumeStates[camera] = cameraSettings.volume ?? 1; + }); + }); + + setAudioStates(initialAudioStates); + setVolumeStates(initialVolumeStates); + }, [allGroupsStreamingSettings]); + + const toggleAudio = (cameraName: string): void => { + setAudioStates((prev) => ({ + ...prev, + [cameraName]: !prev[cameraName], + })); + }; + + const onSaveMuting = useCallback( + (playAudio: boolean) => { + if ( + !cameraGroup || + !allGroupsStreamingSettings || + cameraGroup == "default" + ) { + return; + } + + const existingGroupSettings = + allGroupsStreamingSettings[cameraGroup] || {}; + + const updatedSettings: AllGroupsStreamingSettings = { + ...Object.fromEntries( + Object.entries(allGroupsStreamingSettings || {}).filter( + ([key]) => key !== cameraGroup, + ), + ), + [cameraGroup]: { + ...existingGroupSettings, + ...Object.fromEntries( + Object.entries(existingGroupSettings).map( + ([cameraName, settings]) => [ + cameraName, + { + ...settings, + playAudio: playAudio, + }, + ], + ), + ), + }, + }; + + setAllGroupsStreamingSettings?.(updatedSettings); + }, + [cameraGroup, allGroupsStreamingSettings, setAllGroupsStreamingSettings], + ); + + const muteAll = (): void => { + const updatedStates: Record = {}; + visibleCameras.forEach((cameraName) => { + updatedStates[cameraName] = false; + }); + setAudioStates(updatedStates); + onSaveMuting(false); + }; + + const unmuteAll = (): void => { + const updatedStates: Record = {}; + visibleCameras.forEach((cameraName) => { + updatedStates[cameraName] = true; + }); + setAudioStates(updatedStates); + onSaveMuting(true); + }; + return (
{isMobile && ( @@ -345,21 +467,69 @@ export default function LiveDashboardView({ } else { grow = "aspect-video"; } + const streamName = + currentGroupStreamingSettings?.[camera.name]?.streamName || + Object.values(camera.live.streams)?.[0]; + const autoLive = + currentGroupStreamingSettings?.[camera.name]?.streamType !== + "no-streaming"; + const showStillWithoutActivity = + currentGroupStreamingSettings?.[camera.name]?.streamType !== + "continuous"; + const useWebGL = + currentGroupStreamingSettings?.[camera.name] + ?.compatibilityMode || false; return ( - onSelectCamera(camera.name)} - onError={(e) => handleError(camera.name, e)} - onResetLiveMode={() => resetPreferredLiveMode(camera.name)} - /> + isRestreamed={isRestreamedStates[camera.name]} + supportsAudio={ + supportsAudioOutputStates[streamName]?.supportsAudio ?? + false + } + audioState={audioStates[camera.name]} + toggleAudio={() => toggleAudio(camera.name)} + statsState={statsStates[camera.name]} + toggleStats={() => toggleStats(camera.name)} + volumeState={volumeStates[camera.name] ?? 1} + setVolumeState={(value) => + setVolumeStates({ + [camera.name]: value, + }) + } + muteAll={muteAll} + unmuteAll={unmuteAll} + resetPreferredLiveMode={() => + resetPreferredLiveMode(camera.name) + } + > + onSelectCamera(camera.name)} + onError={(e) => handleError(camera.name, e)} + onResetLiveMode={() => resetPreferredLiveMode(camera.name)} + playAudio={audioStates[camera.name] ?? false} + volume={volumeStates[camera.name]} + /> + ); })}
diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 374201f7c..c5e528736 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -15,6 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { MotionData, + RecordingsSummary, REVIEW_PADDING, ReviewFilter, ReviewSegment, @@ -46,8 +47,8 @@ import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; import { useResizeObserver } from "@/hooks/resize-observer"; import { cn } from "@/lib/utils"; import { useFullscreen } from "@/hooks/use-fullscreen"; - -const SEGMENT_DURATION = 30; +import { useTimezone } from "@/hooks/use-date-utils"; +import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; type RecordingViewProps = { startCamera: string; @@ -75,6 +76,18 @@ export function RecordingView({ const navigate = useNavigate(); const contentRef = useRef(null); + // recordings summary + + const timezone = useTimezone(config); + + const { data: recordingsSummary } = useSWR([ + "recordings/summary", + { + timezone: timezone, + cameras: allCameras ?? null, + }, + ]); + // controller state const [mainCamera, setMainCamera] = useState(startCamera); @@ -431,6 +444,7 @@ export function RecordingView({ ; + timelineRef?: MutableRefObject; mainCamera: string; timelineType: TimelineType; timeRange: TimeRange; @@ -646,6 +662,7 @@ type TimelineProps = { }; function Timeline({ contentRef, + timelineRef, mainCamera, timelineType, timeRange, @@ -657,12 +674,48 @@ function Timeline({ setScrubbing, setExportRange, }: TimelineProps) { - const { data: motionData } = useSWR([ + const internalTimelineRef = useRef(null); + const selectedTimelineRef = timelineRef || internalTimelineRef; + + // timeline interaction + + const [zoomSettings, setZoomSettings] = useState({ + segmentDuration: 30, + timestampSpread: 15, + }); + + const possibleZoomLevels = useMemo( + () => [ + { segmentDuration: 30, timestampSpread: 15 }, + { segmentDuration: 15, timestampSpread: 5 }, + { segmentDuration: 5, timestampSpread: 1 }, + ], + [], + ); + + const handleZoomChange = useCallback( + (newZoomLevel: number) => { + setZoomSettings(possibleZoomLevels[newZoomLevel]); + }, + [possibleZoomLevels], + ); + + const { isZooming, zoomDirection } = useTimelineZoom({ + zoomSettings, + zoomLevels: possibleZoomLevels, + onZoomChange: handleZoomChange, + timelineRef: selectedTimelineRef, + timelineDuration: timeRange.after - timeRange.before, + }); + + // motion data + + const { data: motionData, isLoading } = useSWR([ "review/activity/motion", { before: timeRange.before, after: timeRange.after, - scale: SEGMENT_DURATION / 2, + scale: Math.round(zoomSettings.segmentDuration / 2), cameras: mainCamera, }, ]); @@ -695,10 +748,11 @@ function Timeline({
{timelineType == "timeline" ? ( - motionData ? ( + !isLoading ? ( setScrubbing(scrubbing)} + isZooming={isZooming} + zoomDirection={zoomDirection} /> ) : ( diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index e3995d7d9..adbc96413 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -158,6 +158,8 @@ export default function SearchView({ after: [formatDateToLocaleString(-5)], min_score: ["50"], max_score: ["100"], + min_speed: ["1"], + max_speed: ["150"], has_clip: ["yes", "no"], has_snapshot: ["yes", "no"], ...(config?.plus?.enabled && diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx index 30c6229e1..fa9d0ba58 100644 --- a/web/src/views/settings/CameraSettingsView.tsx +++ b/web/src/views/settings/CameraSettingsView.tsx @@ -27,6 +27,9 @@ import { LuExternalLink } from "react-icons/lu"; import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { MdCircle } from "react-icons/md"; import { cn } from "@/lib/utils"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { useAlertsState, useDetectionsState } from "@/api/ws"; type CameraSettingsViewProps = { selectedCamera: string; @@ -105,6 +108,11 @@ export default function CameraSettingsView({ const watchedAlertsZones = form.watch("alerts_zones"); const watchedDetectionsZones = form.watch("detections_zones"); + const { payload: alertsState, send: sendAlerts } = + useAlertsState(selectedCamera); + const { payload: detectionsState, send: sendDetections } = + useDetectionsState(selectedCamera); + const handleCheckedChange = useCallback( (isChecked: boolean) => { if (!isChecked) { @@ -244,6 +252,47 @@ export default function CameraSettingsView({ + + Review + + +
+
+ { + sendAlerts(isChecked ? "ON" : "OFF"); + }} + /> +
+ +
+
+
+
+ { + sendDetections(isChecked ? "ON" : "OFF"); + }} + /> +
+ +
+
+
+ Enable/disable alerts and detections for this camera. When + disabled, no new review items will be generated. +
+
+
+ + + Review Classification diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index ab2646b5f..27e495766 100644 --- a/web/src/views/settings/MasksAndZonesView.tsx +++ b/web/src/views/settings/MasksAndZonesView.tsx @@ -37,6 +37,7 @@ import PolygonItem from "@/components/settings/PolygonItem"; import { Link } from "react-router-dom"; import { isDesktop } from "react-device-detect"; import { StatusBarMessagesContext } from "@/context/statusbar-provider"; +import { useSearchEffect } from "@/hooks/use-overlay-state"; type MasksAndZoneViewProps = { selectedCamera: string; @@ -61,6 +62,8 @@ export default function MasksAndZonesView({ ); const containerRef = useRef(null); const [editPane, setEditPane] = useState(undefined); + const [activeLine, setActiveLine] = useState(); + const [snapPoints, setSnapPoints] = useState(false); const { addMessage } = useContext(StatusBarMessagesContext)!; @@ -141,7 +144,7 @@ export default function MasksAndZonesView({ } }, [scaledHeight, aspectRatio]); - const handleNewPolygon = (type: PolygonType) => { + const handleNewPolygon = (type: PolygonType, coordinates?: number[][]) => { if (!cameraConfig) { return; } @@ -160,8 +163,9 @@ export default function MasksAndZonesView({ setEditingPolygons([ ...(allPolygons || []), { - points: [], - isFinished: false, + points: coordinates ?? [], + distances: [], + isFinished: coordinates ? true : false, type, typeIndex: 9999, name: "", @@ -238,6 +242,8 @@ export default function MasksAndZonesView({ scaledWidth, scaledHeight, ), + distances: + zoneData.distances?.map((distance) => parseFloat(distance)) ?? [], isFinished: true, color: zoneData.color, }), @@ -267,6 +273,7 @@ export default function MasksAndZonesView({ scaledWidth, scaledHeight, ), + distances: [], isFinished: true, color: [0, 0, 255], })); @@ -290,6 +297,7 @@ export default function MasksAndZonesView({ scaledWidth, scaledHeight, ), + distances: [], isFinished: true, color: [128, 128, 128], })); @@ -316,6 +324,7 @@ export default function MasksAndZonesView({ scaledWidth, scaledHeight, ), + distances: [], isFinished: true, color: [128, 128, 128], }; @@ -366,6 +375,48 @@ export default function MasksAndZonesView({ } }, [selectedCamera]); + useSearchEffect("object_mask", (coordinates: string) => { + if (!scaledWidth || !scaledHeight || isLoading) { + return false; + } + // convert box points string to points array + const points = coordinates.split(",").map((p) => parseFloat(p)); + + const [x1, y1, w, h] = points; + + // bottom center + const centerX = x1 + w / 2; + const bottomY = y1 + h; + + const centerXAbs = centerX * scaledWidth; + const bottomYAbs = bottomY * scaledHeight; + + // padding and clamp + const minPadding = 0.1 * w * scaledWidth; + const maxPadding = 0.3 * w * scaledWidth; + const padding = Math.min( + Math.max(minPadding, 0.15 * w * scaledWidth), + maxPadding, + ); + + const top = Math.max(0, bottomYAbs - padding); + const bottom = Math.min(scaledHeight, bottomYAbs + padding); + const left = Math.max(0, centerXAbs - padding); + const right = Math.min(scaledWidth, centerXAbs + padding); + + const paddedBox = [ + [left, top], + [right, top], + [right, bottom], + [left, bottom], + ]; + + setEditPane("object_mask"); + setActivePolygonIndex(undefined); + handleNewPolygon("object_mask", paddedBox); + return true; + }); + useEffect(() => { document.title = "Mask and Zone Editor - Frigate"; }, []); @@ -391,6 +442,9 @@ export default function MasksAndZonesView({ setIsLoading={setIsLoading} onCancel={handleCancel} onSave={handleSave} + setActiveLine={setActiveLine} + snapPoints={snapPoints} + setSnapPoints={setSnapPoints} /> )} {editPane == "motion_mask" && ( @@ -404,6 +458,8 @@ export default function MasksAndZonesView({ setIsLoading={setIsLoading} onCancel={handleCancel} onSave={handleSave} + snapPoints={snapPoints} + setSnapPoints={setSnapPoints} /> )} {editPane == "object_mask" && ( @@ -417,6 +473,8 @@ export default function MasksAndZonesView({ setIsLoading={setIsLoading} onCancel={handleCancel} onSave={handleSave} + snapPoints={snapPoints} + setSnapPoints={setSnapPoints} /> )} {editPane === undefined && ( @@ -653,6 +711,8 @@ export default function MasksAndZonesView({ activePolygonIndex={activePolygonIndex} hoveredPolygonIndex={hoveredPolygonIndex} selectedZoneMask={selectedZoneMask} + activeLine={activeLine} + snapPoints={true} /> ) : ( diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index 751817245..3918949d0 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -14,24 +14,38 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Toaster } from "@/components/ui/sonner"; -import { Switch } from "@/components/ui/switch"; import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import { FrigateConfig } from "@/types/frigateConfig"; import { zodResolver } from "@hookform/resolvers/zod"; import axios from "axios"; -import { useCallback, useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; -import { LuExternalLink } from "react-icons/lu"; +import { LuCheck, LuExternalLink, LuX } from "react-icons/lu"; import { Link } from "react-router-dom"; import { toast } from "sonner"; import useSWR from "swr"; import { z } from "zod"; +import { + useNotifications, + useNotificationSuspend, + useNotificationTest, +} from "@/api/ws"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import FilterSwitch from "@/components/filter/FilterSwitch"; const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js"; type NotificationSettingsValueType = { - enabled: boolean; + allEnabled: boolean; email?: string; + cameras: string[]; }; type NotificationsSettingsViewProps = { @@ -47,9 +61,52 @@ export default function NotificationView({ }, ); + const allCameras = useMemo(() => { + if (!config) { + return []; + } + + return Object.values(config.cameras).sort( + (aConf, bConf) => aConf.ui.order - bConf.ui.order, + ); + }, [config]); + + const notificationCameras = useMemo(() => { + if (!config) { + return []; + } + + return Object.values(config.cameras) + .filter( + (conf) => + conf.enabled && + conf.notifications && + conf.notifications.enabled_in_config, + ) + .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); + }, [config]); + + const { send: sendTestNotification } = useNotificationTest(); + // status bar const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; + const [changedValue, setChangedValue] = useState(false); + + useEffect(() => { + if (changedValue) { + addMessage( + "notification_settings", + `Unsaved notification settings`, + undefined, + `notification_settings`, + ); + } else { + removeMessage("notification_settings", `notification_settings`); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [changedValue]); // notification key handling @@ -87,7 +144,7 @@ export default function NotificationView({ setRegistration(null); }); toast.success( - "Successfully registered for notifications. Restart to start receiving notifications.", + "Successfully registered for notifications. Restarting Frigate is required before any notifications (including a test notification) can be sent.", { position: "top-center", }, @@ -122,28 +179,44 @@ export default function NotificationView({ const [isLoading, setIsLoading] = useState(false); const formSchema = z.object({ - enabled: z.boolean(), + allEnabled: z.boolean(), email: z.string(), + cameras: z.array(z.string()), }); const form = useForm>({ resolver: zodResolver(formSchema), mode: "onChange", defaultValues: { - enabled: config?.notifications.enabled, + allEnabled: config?.notifications.enabled, email: config?.notifications.email, + cameras: config?.notifications.enabled + ? [] + : notificationCameras.map((c) => c.name), }, }); + const watchCameras = form.watch("cameras"); + + useEffect(() => { + if (watchCameras.length > 0) { + form.setValue("allEnabled", false); + } + }, [watchCameras, allCameras, form]); + const onCancel = useCallback(() => { if (!config) { return; } setUnsavedChanges(false); + setChangedValue(false); form.reset({ - enabled: config.notifications.enabled, + allEnabled: config.notifications.enabled, email: config.notifications.email || "", + cameras: config?.notifications.enabled + ? [] + : notificationCameras.map((c) => c.name), }); // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps @@ -151,11 +224,27 @@ export default function NotificationView({ const saveToConfig = useCallback( async ( - { enabled, email }: NotificationSettingsValueType, // values submitted via the form + { allEnabled, email, cameras }: NotificationSettingsValueType, // values submitted via the form ) => { + const allCameraNames = allCameras.map((cam) => cam.name); + + const enabledCameraQueries = cameras + .map((cam) => `&cameras.${cam}.notifications.enabled=True`) + .join(""); + + const disabledCameraQueries = allCameraNames + .filter((cam) => !cameras.includes(cam)) + .map( + (cam) => + `&cameras.${cam}.notifications.enabled=${allEnabled ? "True" : "False"}`, + ) + .join(""); + + const allCameraQueries = enabledCameraQueries + disabledCameraQueries; + axios .put( - `config/set?notifications.enabled=${enabled}¬ifications.email=${email}`, + `config/set?notifications.enabled=${allEnabled ? "True" : "False"}¬ifications.email=${email}${allCameraQueries}`, { requires_restart: 0, }, @@ -182,7 +271,7 @@ export default function NotificationView({ setIsLoading(false); }); }, - [updateConfig, setIsLoading], + [updateConfig, setIsLoading, allCameras], ); function onSubmit(values: z.infer) { @@ -195,149 +284,249 @@ export default function NotificationView({
- - Notification Settings - +
+
+ + Notification Settings + -
-
-

- Frigate can natively send push notifications to your device when - it is running in the browser or installed as a PWA. -

-
- - Read the Documentation{" "} - - +
+
+

+ Frigate can natively send push notifications to your device + when it is running in the browser or installed as a PWA. +

+
+ + Read the Documentation{" "} + + +
+
+ +
+ + ( + + Email + + + + + Entering a valid email is required, as this is used by + the push server in case problems occur. + + + + )} + /> + + ( + + {allCameras && allCameras?.length > 0 ? ( + <> +
+ + Cameras + +
+
+ ( + { + setChangedValue(true); + if (checked) { + form.setValue("cameras", []); + } + field.onChange(checked); + }} + /> + )} + /> + {allCameras?.map((camera) => ( + { + setChangedValue(true); + let newCameras; + if (checked) { + newCameras = [ + ...field.value, + camera.name, + ]; + } else { + newCameras = field.value?.filter( + (value) => value !== camera.name, + ); + } + field.onChange(newCameras); + form.setValue("allEnabled", false); + }} + /> + ))} +
+ + ) : ( +
+ No cameras available. +
+ )} + + + + Select the cameras to enable notifications for. + +
+ )} + /> + +
+ + +
+ +
-
-
- - ( - - -
- - { - return field.onChange(checked); - }} - /> -
-
-
- )} - /> - ( - - Email - - - - - Entering a valid email is required, as this is used by the - push server in case problems occur. - - - - )} - /> -
- - -
- - +
+
+
+ + + Device-Specific Settings + + + }} + > + {`${registration != null ? "Unregister" : "Register"} this device`} + + {registration != null && registration.active && ( + + )} +
+
+ {notificationCameras.length > 0 && ( +
+
+ + + Global Settings + +
+
+

+ Temporarily suspend notifications for specific cameras + on all registered devices. +

+
+
+ +
+
+
+ {notificationCameras.map((item) => ( + + ))} +
+
+
+
+
+ )}
@@ -345,3 +534,110 @@ export default function NotificationView({ ); } + +type CameraNotificationSwitchProps = { + config?: FrigateConfig; + camera: string; +}; + +export function CameraNotificationSwitch({ + config, + camera, +}: CameraNotificationSwitchProps) { + const { payload: notificationState, send: sendNotification } = + useNotifications(camera); + const { payload: notificationSuspendUntil, send: sendNotificationSuspend } = + useNotificationSuspend(camera); + const [isSuspended, setIsSuspended] = useState(false); + + useEffect(() => { + if (notificationSuspendUntil) { + setIsSuspended( + notificationSuspendUntil !== "0" || notificationState === "OFF", + ); + } + }, [notificationSuspendUntil, notificationState]); + + const handleSuspend = (duration: string) => { + setIsSuspended(true); + if (duration == "off") { + sendNotification("OFF"); + } else { + sendNotificationSuspend(parseInt(duration)); + } + }; + + const handleCancelSuspension = () => { + sendNotification("ON"); + sendNotificationSuspend(0); + }; + + const formatSuspendedUntil = (timestamp: string) => { + if (timestamp === "0") return "Frigate restarts."; + + return formatUnixTimestampToDateTime(parseInt(timestamp), { + time_style: "medium", + date_style: "medium", + timezone: config?.ui.timezone, + strftime_fmt: `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`, + }); + }; + + return ( +
+
+
+ {!isSuspended ? ( + + ) : ( + + )} +
+ + + {!isSuspended ? ( +
+ Notifications Active +
+ ) : ( +
+ Notifications suspended until{" "} + {formatSuspendedUntil(notificationSuspendUntil)} +
+ )} +
+
+
+ + {!isSuspended ? ( + + ) : ( + + )} +
+ ); +} diff --git a/web/src/views/settings/ObjectSettingsView.tsx b/web/src/views/settings/ObjectSettingsView.tsx index 7b8a08d2e..ea1083ec1 100644 --- a/web/src/views/settings/ObjectSettingsView.tsx +++ b/web/src/views/settings/ObjectSettingsView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; @@ -23,6 +23,9 @@ import { getIconForLabel } from "@/utils/iconUtil"; import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { LuExternalLink, LuInfo } from "react-icons/lu"; import { Link } from "react-router-dom"; +import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer"; +import { Separator } from "@/components/ui/separator"; +import { isDesktop } from "react-device-detect"; type ObjectSettingsViewProps = { selectedCamera?: string; @@ -37,6 +40,8 @@ export default function ObjectSettingsView({ }: ObjectSettingsViewProps) { const { data: config } = useSWR("config"); + const containerRef = useRef(null); + const DEBUG_OPTIONS = [ { param: "bbox", @@ -130,6 +135,12 @@ export default function ObjectSettingsView({ [options, setOptions], ); + const [debugDraw, setDebugDraw] = useState(false); + + useEffect(() => { + setDebugDraw(false); + }, [selectedCamera]); + const cameraConfig = useMemo(() => { if (config && selectedCamera) { return config.cameras[selectedCamera]; @@ -234,7 +245,7 @@ export default function ObjectSettingsView({ Info
- + {info} @@ -256,18 +267,74 @@ export default function ObjectSettingsView({
))}
+ {isDesktop && ( + <> + +
+
+
+ + + + +
+ + Info +
+
+ + Enable this option to draw a rectangle on the + camera image to show its area and ratio. These + values can then be used to set object shape filter + parameters in your config. +
+ + Read the documentation{" "} + + +
+
+
+
+
+ Draw a rectangle on the image to view area and ratio + details +
+
+ { + setDebugDraw(isChecked); + }} + /> +
+ + )}
- {ObjectList(memoizedObjects)} +
{cameraConfig ? (
-
+
+ {debugDraw && ( + + )}
) : ( @@ -284,7 +358,12 @@ export default function ObjectSettingsView({ ); } -function ObjectList(objects?: ObjectType[]) { +type ObjectListProps = { + cameraConfig: CameraConfig; + objects?: ObjectType[]; +}; + +function ObjectList({ cameraConfig, objects }: ObjectListProps) { const { data: config } = useSWR("config"); const colormap = useMemo(() => { @@ -326,7 +405,7 @@ function ObjectList(objects?: ObjectType[]) { {capitalizeFirstLetter(obj.label.replaceAll("_", " "))}
-
+

@@ -351,7 +430,25 @@ function ObjectList(objects?: ObjectType[]) {

Area

- {obj.area ? obj.area.toString() : "-"} + {obj.area ? ( + <> +
+ px: {obj.area.toString()} +
+
+ %:{" "} + {( + obj.area / + (cameraConfig.detect.width * + cameraConfig.detect.height) + ) + .toFixed(4) + .toString()} +
+ + ) : ( + "-" + )}
diff --git a/web/src/views/settings/UiSettingsView.tsx b/web/src/views/settings/UiSettingsView.tsx index 6386a3cad..e3b5c8c7a 100644 --- a/web/src/views/settings/UiSettingsView.tsx +++ b/web/src/views/settings/UiSettingsView.tsx @@ -46,6 +46,25 @@ export default function UiSettingsView() { }); }, [config]); + const clearStreamingSettings = useCallback(async () => { + if (!config) { + return []; + } + + await delData(`streaming-settings`) + .then(() => { + toast.success(`Cleared streaming settings for all camera groups.`, { + position: "top-center", + }); + }) + .catch((error) => { + toast.error( + `Failed to clear camera groups streaming settings: ${error.response.data.message}`, + { position: "top-center" }, + ); + }); + }, [config]); + useEffect(() => { document.title = "General Settings - Frigate"; }, []); @@ -84,11 +103,15 @@ export default function UiSettingsView() { Automatic Live View
-
+

Automatically switch to a camera's live view when activity is detected. Disabling this option causes static camera images on - the Live dashboard to only update once per minute. + the your dashboards to only update once per minute.{" "} + + This is a global setting but can be overridden on each + camera in camera groups only. +

@@ -103,7 +126,7 @@ export default function UiSettingsView() { Play Alert Videos
-
+

By default, recent alerts on the Live dashboard play as small looping videos. Disable this option to only show a static @@ -114,10 +137,10 @@ export default function UiSettingsView() {

-
+
Stored Layouts
-
+

The layout of cameras in a camera group can be dragged/resized. The positions are stored in your browser's @@ -133,6 +156,24 @@ export default function UiSettingsView() {

+
+
+
Camera Group Streaming Settings
+
+

+ Streaming settings for each camera group are stored in your + browser's local storage. +

+
+
+ +
+ diff --git a/web/src/views/system/FeatureMetrics.tsx b/web/src/views/system/FeatureMetrics.tsx new file mode 100644 index 000000000..787d2367b --- /dev/null +++ b/web/src/views/system/FeatureMetrics.tsx @@ -0,0 +1,122 @@ +import useSWR from "swr"; +import { FrigateStats } from "@/types/stats"; +import { useEffect, useMemo, useState } from "react"; +import { useFrigateStats } from "@/api/ws"; +import { EmbeddingThreshold } from "@/types/graph"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ThresholdBarGraph } from "@/components/graph/SystemGraph"; +import { cn } from "@/lib/utils"; + +type FeatureMetricsProps = { + lastUpdated: number; + setLastUpdated: (last: number) => void; +}; +export default function FeatureMetrics({ + lastUpdated, + setLastUpdated, +}: FeatureMetricsProps) { + // stats + + const { data: initialStats } = useSWR( + ["stats/history", { keys: "embeddings,service" }], + { + revalidateOnFocus: false, + }, + ); + + const [statsHistory, setStatsHistory] = useState([]); + const updatedStats = useFrigateStats(); + + useEffect(() => { + if (initialStats == undefined || initialStats.length == 0) { + return; + } + + if (statsHistory.length == 0) { + setStatsHistory(initialStats); + return; + } + + if (!updatedStats) { + return; + } + + if (updatedStats.service.last_updated > lastUpdated) { + setStatsHistory([...statsHistory.slice(1), updatedStats]); + setLastUpdated(Date.now() / 1000); + } + }, [initialStats, updatedStats, statsHistory, lastUpdated, setLastUpdated]); + + // timestamps + + const updateTimes = useMemo( + () => statsHistory.map((stats) => stats.service.last_updated), + [statsHistory], + ); + + // features stats + + const embeddingInferenceTimeSeries = useMemo(() => { + if (!statsHistory) { + return []; + } + + const series: { + [key: string]: { name: string; data: { x: number; y: number }[] }; + } = {}; + + statsHistory.forEach((stats, statsIdx) => { + if (!stats?.embeddings) { + return; + } + + Object.entries(stats.embeddings).forEach(([rawKey, stat]) => { + const key = rawKey.replaceAll("_", " "); + + if (!(key in series)) { + series[key] = { name: key, data: [] }; + } + + series[key].data.push({ x: statsIdx + 1, y: stat }); + }); + }); + return Object.values(series); + }, [statsHistory]); + + return ( + <> +
+
+ Features +
+
+ {statsHistory.length != 0 ? ( + <> + {embeddingInferenceTimeSeries.map((series) => ( +
+
{series.name}
+ +
+ ))} + + ) : ( + + )} +
+
+ + ); +} diff --git a/web/src/views/system/StorageMetrics.tsx b/web/src/views/system/StorageMetrics.tsx index 746adf45a..ea2f1b7b2 100644 --- a/web/src/views/system/StorageMetrics.tsx +++ b/web/src/views/system/StorageMetrics.tsx @@ -9,6 +9,10 @@ import { } from "@/components/ui/popover"; import useSWR from "swr"; import { LuAlertCircle } from "react-icons/lu"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { useTimezone } from "@/hooks/use-date-utils"; +import { RecordingsSummary } from "@/types/review"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; type CameraStorage = { [key: string]: { @@ -26,6 +30,11 @@ export default function StorageMetrics({ }: StorageMetricsProps) { const { data: cameraStorage } = useSWR("recordings/storage"); const { data: stats } = useSWR("stats"); + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + + const timezone = useTimezone(config); const totalStorage = useMemo(() => { if (!cameraStorage || !stats) { @@ -44,7 +53,23 @@ export default function StorageMetrics({ return totalStorage; }, [cameraStorage, stats, setLastUpdated]); - if (!cameraStorage || !stats || !totalStorage) { + // recordings summary + + const { data: recordingsSummary } = useSWR([ + "recordings/summary", + { + timezone: timezone, + }, + ]); + + const earliestDate = useMemo(() => { + const keys = Object.keys(recordingsSummary || {}); + return keys.length + ? new Date(keys[keys.length - 1]).getTime() / 1000 + : null; + }, [recordingsSummary]); + + if (!cameraStorage || !stats || !totalStorage || !config) { return; } @@ -81,6 +106,16 @@ export default function StorageMetrics({ used={totalStorage.used} total={totalStorage.total} /> + {earliestDate && ( +
+ Earliest recording available:{" "} + {formatUnixTimestampToDateTime(earliestDate, { + timezone: timezone, + strftime_fmt: + config.ui.time_format == "24hour" ? "%d %b %Y" : "%B %d, %Y", + })} +
+ )}
/tmp/cache
diff --git a/web/tailwind.config.js b/web/tailwind.config.js index b7371a193..4a341a3df 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -32,6 +32,8 @@ module.exports = { scale2: "scale2 3s ease-in-out infinite", scale3: "scale3 3s ease-in-out infinite", scale4: "scale4 3s ease-in-out infinite", + "timeline-zoom-in": "timeline-zoom-in 0.3s ease-out", + "timeline-zoom-out": "timeline-zoom-out 0.3s ease-out", }, aspectRatio: { wide: "32 / 9", @@ -140,6 +142,16 @@ module.exports = { "40%, 60%": { transform: "scale(1.4)" }, "30%, 70%": { transform: "scale(1)" }, }, + "timeline-zoom-in": { + "0%": { transform: "translateY(0)", opacity: "1" }, + "50%": { transform: "translateY(0%)", opacity: "0.5" }, + "100%": { transform: "translateY(0)", opacity: "1" }, + }, + "timeline-zoom-out": { + "0%": { transform: "translateY(0)", opacity: "1" }, + "50%": { transform: "translateY(0%)", opacity: "0.5" }, + "100%": { transform: "translateY(0)", opacity: "1" }, + }, }, screens: { xs: "480px",