mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-05 04:57:42 +03:00
Merge branch 'dev' of https://github.com/blakeblackshear/frigate into motion_improvements
This commit is contained in:
commit
5789cff757
@ -44,6 +44,7 @@ codeproject
|
|||||||
colormap
|
colormap
|
||||||
colorspace
|
colorspace
|
||||||
comms
|
comms
|
||||||
|
cooldown
|
||||||
coro
|
coro
|
||||||
ctypeslib
|
ctypeslib
|
||||||
CUDA
|
CUDA
|
||||||
|
|||||||
6
.github/pull_request_template.md
vendored
6
.github/pull_request_template.md
vendored
@ -1,5 +1,11 @@
|
|||||||
## Proposed change
|
## Proposed change
|
||||||
<!--
|
<!--
|
||||||
|
Thank you!
|
||||||
|
|
||||||
|
If you're introducing a new feature or significantly refactoring existing functionality,
|
||||||
|
we encourage you to start a discussion first. This helps ensure your idea aligns with
|
||||||
|
Frigate's development goals.
|
||||||
|
|
||||||
Describe what this pull request does and how it will benefit users of Frigate.
|
Describe what this pull request does and how it will benefit users of Frigate.
|
||||||
Please describe in detail any considerations, breaking changes, etc. that are
|
Please describe in detail any considerations, breaking changes, etc. that are
|
||||||
made in this pull request.
|
made in this pull request.
|
||||||
|
|||||||
59
.github/workflows/ci.yml
vendored
59
.github/workflows/ci.yml
vendored
@ -76,36 +76,6 @@ jobs:
|
|||||||
rpi.tags=${{ steps.setup.outputs.image-name }}-rpi
|
rpi.tags=${{ steps.setup.outputs.image-name }}-rpi
|
||||||
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64
|
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64
|
||||||
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64,mode=max
|
*.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:
|
|
||||||
- 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 TensorRT (Jetson, Jetpack 4)
|
|
||||||
env:
|
|
||||||
ARCH: arm64
|
|
||||||
BASE_IMAGE: timongentzsch/l4t-ubuntu20-opencv:latest
|
|
||||||
SLIM_BASE: timongentzsch/l4t-ubuntu20-opencv:latest
|
|
||||||
TRT_BASE: timongentzsch/l4t-ubuntu20-opencv:latest
|
|
||||||
uses: docker/bake-action@v6
|
|
||||||
with:
|
|
||||||
source: .
|
|
||||||
push: true
|
|
||||||
targets: tensorrt
|
|
||||||
files: docker/tensorrt/trt.hcl
|
|
||||||
set: |
|
|
||||||
tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt-jp4
|
|
||||||
*.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:
|
jetson_jp5_build:
|
||||||
if: false
|
if: false
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
@ -136,6 +106,35 @@ jobs:
|
|||||||
tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt-jp5
|
tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt-jp5
|
||||||
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp5
|
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp5
|
||||||
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp5,mode=max
|
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp5,mode=max
|
||||||
|
jetson_jp6_build:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
name: Jetson Jetpack 6
|
||||||
|
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 TensorRT (Jetson, Jetpack 6)
|
||||||
|
env:
|
||||||
|
ARCH: arm64
|
||||||
|
BASE_IMAGE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu
|
||||||
|
SLIM_BASE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu
|
||||||
|
TRT_BASE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu
|
||||||
|
uses: docker/bake-action@v6
|
||||||
|
with:
|
||||||
|
source: .
|
||||||
|
push: true
|
||||||
|
targets: tensorrt
|
||||||
|
files: docker/tensorrt/trt.hcl
|
||||||
|
set: |
|
||||||
|
tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt-jp6
|
||||||
|
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp6
|
||||||
|
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp6,mode=max
|
||||||
amd64_extra_builds:
|
amd64_extra_builds:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
name: AMD64 Extra Build
|
name: AMD64 Extra Build
|
||||||
|
|||||||
1
.github/workflows/pull_request.yml
vendored
1
.github/workflows/pull_request.yml
vendored
@ -4,6 +4,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
|
- ".github/**"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DEFAULT_PYTHON: 3.11
|
DEFAULT_PYTHON: 3.11
|
||||||
|
|||||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@ -39,14 +39,14 @@ jobs:
|
|||||||
STABLE_TAG=${BASE}:stable
|
STABLE_TAG=${BASE}:stable
|
||||||
PULL_TAG=${BASE}:${BUILD_TAG}
|
PULL_TAG=${BASE}:${BUILD_TAG}
|
||||||
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${VERSION_TAG}
|
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${VERSION_TAG}
|
||||||
for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk h8l rocm; do
|
for variant in standard-arm64 tensorrt tensorrt-jp5 tensorrt-jp6 rk h8l rocm; do
|
||||||
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${VERSION_TAG}-${variant}
|
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${VERSION_TAG}-${variant}
|
||||||
done
|
done
|
||||||
|
|
||||||
# stable tag
|
# stable tag
|
||||||
if [[ "${BUILD_TYPE}" == "stable" ]]; then
|
if [[ "${BUILD_TYPE}" == "stable" ]]; then
|
||||||
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${STABLE_TAG}
|
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${STABLE_TAG}
|
||||||
for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk h8l rocm; do
|
for variant in standard-arm64 tensorrt tensorrt-jp5 tensorrt-jp6 rk h8l rocm; do
|
||||||
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${STABLE_TAG}-${variant}
|
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${STABLE_TAG}-${variant}
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -3,14 +3,27 @@
|
|||||||
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Globally set pip break-system-packages option to avoid having to specify it every time
|
||||||
|
ARG PIP_BREAK_SYSTEM_PACKAGES=1
|
||||||
|
|
||||||
ARG BASE_IMAGE=debian:12
|
ARG BASE_IMAGE=debian:12
|
||||||
ARG SLIM_BASE=debian:12-slim
|
ARG SLIM_BASE=debian:12-slim
|
||||||
|
|
||||||
|
# A hook that allows us to inject commands right after the base images
|
||||||
|
ARG BASE_HOOK=
|
||||||
|
|
||||||
FROM ${BASE_IMAGE} AS base
|
FROM ${BASE_IMAGE} AS base
|
||||||
|
ARG PIP_BREAK_SYSTEM_PACKAGES
|
||||||
|
|
||||||
|
RUN ${BASE_HOOK}
|
||||||
|
|
||||||
FROM --platform=${BUILDPLATFORM} debian:12 AS base_host
|
FROM --platform=${BUILDPLATFORM} debian:12 AS base_host
|
||||||
|
ARG PIP_BREAK_SYSTEM_PACKAGES
|
||||||
|
|
||||||
FROM ${SLIM_BASE} AS slim-base
|
FROM ${SLIM_BASE} AS slim-base
|
||||||
|
ARG PIP_BREAK_SYSTEM_PACKAGES
|
||||||
|
|
||||||
|
RUN ${BASE_HOOK}
|
||||||
|
|
||||||
FROM slim-base AS wget
|
FROM slim-base AS wget
|
||||||
ARG DEBIAN_FRONTEND
|
ARG DEBIAN_FRONTEND
|
||||||
@ -66,8 +79,8 @@ COPY docker/main/requirements-ov.txt /requirements-ov.txt
|
|||||||
RUN apt-get -qq update \
|
RUN apt-get -qq update \
|
||||||
&& apt-get -qq install -y wget python3 python3-dev python3-distutils gcc pkg-config libhdf5-dev \
|
&& 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 \
|
&& wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
||||||
&& python3 get-pip.py "pip" --break-system-packages \
|
&& python3 get-pip.py "pip" \
|
||||||
&& pip install --break-system-packages -r /requirements-ov.txt
|
&& pip install -r /requirements-ov.txt
|
||||||
|
|
||||||
# Get OpenVino Model
|
# Get OpenVino Model
|
||||||
RUN --mount=type=bind,source=docker/main/build_ov_model.py,target=/build_ov_model.py \
|
RUN --mount=type=bind,source=docker/main/build_ov_model.py,target=/build_ov_model.py \
|
||||||
@ -142,8 +155,8 @@ RUN apt-get -qq update \
|
|||||||
apt-transport-https wget \
|
apt-transport-https wget \
|
||||||
&& apt-get -qq update \
|
&& apt-get -qq update \
|
||||||
&& apt-get -qq install -y \
|
&& apt-get -qq install -y \
|
||||||
python3 \
|
python3.11 \
|
||||||
python3-dev \
|
python3.11-dev \
|
||||||
# opencv dependencies
|
# opencv dependencies
|
||||||
build-essential cmake git pkg-config libgtk-3-dev \
|
build-essential cmake git pkg-config libgtk-3-dev \
|
||||||
libavcodec-dev libavformat-dev libswscale-dev libv4l-dev \
|
libavcodec-dev libavformat-dev libswscale-dev libv4l-dev \
|
||||||
@ -157,11 +170,13 @@ RUN apt-get -qq update \
|
|||||||
gcc gfortran libopenblas-dev liblapack-dev && \
|
gcc gfortran libopenblas-dev liblapack-dev && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
|
||||||
|
|
||||||
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
||||||
&& python3 get-pip.py "pip" --break-system-packages
|
&& python3 get-pip.py "pip"
|
||||||
|
|
||||||
COPY docker/main/requirements.txt /requirements.txt
|
COPY docker/main/requirements.txt /requirements.txt
|
||||||
RUN pip3 install -r /requirements.txt --break-system-packages
|
RUN pip3 install -r /requirements.txt
|
||||||
|
|
||||||
# Build pysqlite3 from source
|
# Build pysqlite3 from source
|
||||||
COPY docker/main/build_pysqlite3.sh /build_pysqlite3.sh
|
COPY docker/main/build_pysqlite3.sh /build_pysqlite3.sh
|
||||||
@ -215,8 +230,8 @@ RUN --mount=type=bind,source=docker/main/install_deps.sh,target=/deps/install_de
|
|||||||
/deps/install_deps.sh
|
/deps/install_deps.sh
|
||||||
|
|
||||||
RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \
|
RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \
|
||||||
python3 -m pip install --upgrade pip --break-system-packages && \
|
python3 -m pip install --upgrade pip && \
|
||||||
pip3 install -U /deps/wheels/*.whl --break-system-packages
|
pip3 install -U /deps/wheels/*.whl
|
||||||
|
|
||||||
COPY --from=deps-rootfs / /
|
COPY --from=deps-rootfs / /
|
||||||
|
|
||||||
@ -263,7 +278,7 @@ RUN apt-get update \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN --mount=type=bind,source=./docker/main/requirements-dev.txt,target=/workspace/frigate/requirements-dev.txt \
|
RUN --mount=type=bind,source=./docker/main/requirements-dev.txt,target=/workspace/frigate/requirements-dev.txt \
|
||||||
pip3 install -r requirements-dev.txt --break-system-packages
|
pip3 install -r requirements-dev.txt
|
||||||
|
|
||||||
HEALTHCHECK NONE
|
HEALTHCHECK NONE
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ apt-get -qq install --no-install-recommends -y \
|
|||||||
lbzip2 \
|
lbzip2 \
|
||||||
procps vainfo \
|
procps vainfo \
|
||||||
unzip locales tzdata libxml2 xz-utils \
|
unzip locales tzdata libxml2 xz-utils \
|
||||||
python3 \
|
python3.11 \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
curl \
|
curl \
|
||||||
lsof \
|
lsof \
|
||||||
@ -21,25 +21,16 @@ apt-get -qq install --no-install-recommends -y \
|
|||||||
libglib2.0-0 \
|
libglib2.0-0 \
|
||||||
libusb-1.0.0
|
libusb-1.0.0
|
||||||
|
|
||||||
|
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
|
||||||
|
|
||||||
mkdir -p -m 600 /root/.gnupg
|
mkdir -p -m 600 /root/.gnupg
|
||||||
|
|
||||||
# install coral runtime
|
# 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"
|
wget -q -O /tmp/libedgetpu1-max.deb "https://github.com/feranick/libedgetpu/releases/download/16.0TF2.17.1-1/libedgetpu1-max_16.0tf2.17.1-1.bookworm_${TARGETARCH}.deb"
|
||||||
unset DEBIAN_FRONTEND
|
unset DEBIAN_FRONTEND
|
||||||
yes | dpkg -i /tmp/libedgetpu1-max.deb && export DEBIAN_FRONTEND=noninteractive
|
yes | dpkg -i /tmp/libedgetpu1-max.deb && export DEBIAN_FRONTEND=noninteractive
|
||||||
rm /tmp/libedgetpu1-max.deb
|
rm /tmp/libedgetpu1-max.deb
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
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
|
# btbn-ffmpeg -> amd64
|
||||||
if [[ "${TARGETARCH}" == "amd64" ]]; then
|
if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||||
mkdir -p /usr/lib/ffmpeg/5.0
|
mkdir -p /usr/lib/ffmpeg/5.0
|
||||||
|
|||||||
@ -68,3 +68,7 @@ netaddr==0.8.*
|
|||||||
netifaces==0.10.*
|
netifaces==0.10.*
|
||||||
verboselogs==1.7.*
|
verboselogs==1.7.*
|
||||||
virtualenv==20.17.*
|
virtualenv==20.17.*
|
||||||
|
prometheus-client == 0.21.*
|
||||||
|
# TFLite
|
||||||
|
tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_x86_64.whl; platform_machine == 'x86_64'
|
||||||
|
tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_aarch64.whl; platform_machine == 'aarch64'
|
||||||
|
|||||||
@ -8,7 +8,6 @@ COPY docker/main/requirements-wheels.txt /requirements-wheels.txt
|
|||||||
COPY docker/rockchip/requirements-wheels-rk.txt /requirements-wheels-rk.txt
|
COPY docker/rockchip/requirements-wheels-rk.txt /requirements-wheels-rk.txt
|
||||||
RUN sed -i "/https:\/\//d" /requirements-wheels.txt
|
RUN sed -i "/https:\/\//d" /requirements-wheels.txt
|
||||||
RUN sed -i "/onnxruntime/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 pip3 wheel --wheel-dir=/rk-wheels -c /requirements-wheels.txt -r /requirements-wheels-rk.txt
|
||||||
RUN rm -rf /rk-wheels/opencv_python-*
|
RUN rm -rf /rk-wheels/opencv_python-*
|
||||||
|
|
||||||
@ -16,7 +15,7 @@ FROM deps AS rk-frigate
|
|||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
RUN --mount=type=bind,from=rk-wheels,source=/rk-wheels,target=/deps/rk-wheels \
|
RUN --mount=type=bind,from=rk-wheels,source=/rk-wheels,target=/deps/rk-wheels \
|
||||||
pip3 install --no-deps -U /deps/rk-wheels/*.whl --break-system-packages
|
pip3 install --no-deps -U /deps/rk-wheels/*.whl
|
||||||
|
|
||||||
WORKDIR /opt/frigate/
|
WORKDIR /opt/frigate/
|
||||||
COPY --from=rootfs / /
|
COPY --from=rootfs / /
|
||||||
|
|||||||
@ -17,7 +17,7 @@ FROM tensorrt-base AS frigate-tensorrt
|
|||||||
ENV TRT_VER=8.6.1
|
ENV TRT_VER=8.6.1
|
||||||
RUN python3 -m pip config set global.break-system-packages true
|
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 \
|
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
|
||||||
pip3 install -U /deps/trt-wheels/*.whl --break-system-packages && \
|
pip3 install -U /deps/trt-wheels/*.whl && \
|
||||||
ldconfig
|
ldconfig
|
||||||
|
|
||||||
WORKDIR /opt/frigate/
|
WORKDIR /opt/frigate/
|
||||||
@ -32,4 +32,4 @@ COPY --from=trt-deps /usr/local/cuda-12.1 /usr/local/cuda
|
|||||||
COPY docker/tensorrt/detector/rootfs/ /
|
COPY docker/tensorrt/detector/rootfs/ /
|
||||||
COPY --from=trt-deps /usr/local/lib/libyolo_layer.so /usr/local/lib/libyolo_layer.so
|
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 \
|
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
|
||||||
pip3 install -U /deps/trt-wheels/*.whl --break-system-packages
|
pip3 install -U /deps/trt-wheels/*.whl
|
||||||
|
|||||||
@ -7,20 +7,25 @@ ARG BASE_IMAGE
|
|||||||
FROM ${BASE_IMAGE} AS build-wheels
|
FROM ${BASE_IMAGE} AS build-wheels
|
||||||
ARG DEBIAN_FRONTEND
|
ARG DEBIAN_FRONTEND
|
||||||
|
|
||||||
|
# Add deadsnakes PPA for python3.11
|
||||||
|
RUN apt-get -qq update && \
|
||||||
|
apt-get -qq install -y --no-install-recommends \
|
||||||
|
software-properties-common \
|
||||||
|
&& add-apt-repository ppa:deadsnakes/ppa
|
||||||
|
|
||||||
# Use a separate container to build wheels to prevent build dependencies in final image
|
# Use a separate container to build wheels to prevent build dependencies in final image
|
||||||
RUN apt-get -qq update \
|
RUN apt-get -qq update \
|
||||||
&& apt-get -qq install -y --no-install-recommends \
|
&& apt-get -qq install -y --no-install-recommends \
|
||||||
python3.9 python3.9-dev \
|
python3.11 python3.11-dev \
|
||||||
wget build-essential cmake git \
|
wget build-essential cmake git \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Ensure python3 defaults to python3.9
|
# Ensure python3 defaults to python3.11
|
||||||
RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.9 1
|
RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
|
||||||
|
|
||||||
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
||||||
&& python3 get-pip.py "pip"
|
&& python3 get-pip.py "pip"
|
||||||
|
|
||||||
|
|
||||||
FROM build-wheels AS trt-wheels
|
FROM build-wheels AS trt-wheels
|
||||||
ARG DEBIAN_FRONTEND
|
ARG DEBIAN_FRONTEND
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@ -41,11 +46,12 @@ RUN --mount=type=bind,source=docker/tensorrt/detector/build_python_tensorrt.sh,t
|
|||||||
&& TENSORRT_VER=$(cat /etc/TENSORRT_VER) /deps/build_python_tensorrt.sh
|
&& TENSORRT_VER=$(cat /etc/TENSORRT_VER) /deps/build_python_tensorrt.sh
|
||||||
|
|
||||||
COPY docker/tensorrt/requirements-arm64.txt /requirements-tensorrt.txt
|
COPY docker/tensorrt/requirements-arm64.txt /requirements-tensorrt.txt
|
||||||
ADD https://nvidia.box.com/shared/static/psl23iw3bh7hlgku0mjo1xekxpego3e3.whl /tmp/onnxruntime_gpu-1.15.1-cp311-cp311-linux_aarch64.whl
|
# See https://elinux.org/Jetson_Zoo#ONNX_Runtime
|
||||||
|
ADD https://nvidia.box.com/shared/static/9yvw05k6u343qfnkhdv2x6xhygze0aq1.whl /tmp/onnxruntime_gpu-1.19.0-cp311-cp311-linux_aarch64.whl
|
||||||
|
|
||||||
RUN pip3 uninstall -y onnxruntime-openvino \
|
RUN pip3 uninstall -y onnxruntime-openvino \
|
||||||
&& pip3 wheel --wheel-dir=/trt-wheels -r /requirements-tensorrt.txt \
|
&& pip3 wheel --wheel-dir=/trt-wheels -r /requirements-tensorrt.txt \
|
||||||
&& pip3 install --no-deps /tmp/onnxruntime_gpu-1.15.1-cp311-cp311-linux_aarch64.whl
|
&& pip3 install --no-deps /tmp/onnxruntime_gpu-1.19.0-cp311-cp311-linux_aarch64.whl
|
||||||
|
|
||||||
FROM build-wheels AS trt-model-wheels
|
FROM build-wheels AS trt-model-wheels
|
||||||
ARG DEBIAN_FRONTEND
|
ARG DEBIAN_FRONTEND
|
||||||
@ -67,12 +73,18 @@ RUN --mount=type=bind,source=docker/tensorrt/build_jetson_ffmpeg.sh,target=/deps
|
|||||||
# Frigate w/ TensorRT for NVIDIA Jetson platforms
|
# Frigate w/ TensorRT for NVIDIA Jetson platforms
|
||||||
FROM tensorrt-base AS frigate-tensorrt
|
FROM tensorrt-base AS frigate-tensorrt
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y python-is-python3 libprotobuf17 \
|
&& apt-get install -y python-is-python3 libprotobuf23 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN rm -rf /usr/lib/btbn-ffmpeg/
|
RUN rm -rf /usr/lib/btbn-ffmpeg/
|
||||||
COPY --from=jetson-ffmpeg /rootfs /
|
COPY --from=jetson-ffmpeg /rootfs /
|
||||||
|
|
||||||
|
# ffmpeg runtime dependencies
|
||||||
|
RUN apt-get -qq update \
|
||||||
|
&& apt-get -qq install -y --no-install-recommends \
|
||||||
|
libx264-163 libx265-199 libegl1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=trt-wheels /etc/TENSORRT_VER /etc/TENSORRT_VER
|
COPY --from=trt-wheels /etc/TENSORRT_VER /etc/TENSORRT_VER
|
||||||
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
|
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
|
||||||
--mount=type=bind,from=trt-model-wheels,source=/trt-model-wheels,target=/deps/trt-model-wheels \
|
--mount=type=bind,from=trt-model-wheels,source=/trt-model-wheels,target=/deps/trt-model-wheels \
|
||||||
@ -81,3 +93,6 @@ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels
|
|||||||
|
|
||||||
WORKDIR /opt/frigate/
|
WORKDIR /opt/frigate/
|
||||||
COPY --from=rootfs / /
|
COPY --from=rootfs / /
|
||||||
|
|
||||||
|
# Fixes "Error importing detector runtime: /usr/lib/aarch64-linux-gnu/libstdc++.so.6: cannot allocate memory in static TLS block"
|
||||||
|
ENV LD_PRELOAD /usr/lib/aarch64-linux-gnu/libstdc++.so.6
|
||||||
|
|||||||
@ -14,14 +14,27 @@ apt-get -qq install -y --no-install-recommends libx264-dev libx265-dev
|
|||||||
pushd /tmp
|
pushd /tmp
|
||||||
|
|
||||||
# Install libnvmpi to enable nvmpi decoders (h264_nvmpi, hevc_nvmpi)
|
# Install libnvmpi to enable nvmpi decoders (h264_nvmpi, hevc_nvmpi)
|
||||||
if [ -e /usr/local/cuda-10.2 ]; then
|
if [ -e /usr/local/cuda-12 ]; then
|
||||||
|
# assume Jetpack 6.2
|
||||||
|
apt-key adv --fetch-key https://repo.download.nvidia.com/jetson/jetson-ota-public.asc
|
||||||
|
echo "deb https://repo.download.nvidia.com/jetson/common r36.4 main" >> /etc/apt/sources.list.d/nvidia-l4t-apt-source.list
|
||||||
|
echo "deb https://repo.download.nvidia.com/jetson/t234 r36.4 main" >> /etc/apt/sources.list.d/nvidia-l4t-apt-source.list
|
||||||
|
echo "deb https://repo.download.nvidia.com/jetson/ffmpeg r36.4 main" >> /etc/apt/sources.list.d/nvidia-l4t-apt-source.list
|
||||||
|
|
||||||
|
mkdir -p /opt/nvidia/l4t-packages/
|
||||||
|
touch /opt/nvidia/l4t-packages/.nv-l4t-disable-boot-fw-update-in-preinstall
|
||||||
|
|
||||||
|
apt-get update
|
||||||
|
apt-get -qq install -y --no-install-recommends -o Dpkg::Options::="--force-confold" nvidia-l4t-jetson-multimedia-api
|
||||||
|
elif [ -e /usr/local/cuda-10.2 ]; then
|
||||||
# assume Jetpack 4.X
|
# assume Jetpack 4.X
|
||||||
wget -q https://developer.nvidia.com/embedded/L4T/r32_Release_v5.0/T186/Jetson_Multimedia_API_R32.5.0_aarch64.tbz2 -O jetson_multimedia_api.tbz2
|
wget -q https://developer.nvidia.com/embedded/L4T/r32_Release_v5.0/T186/Jetson_Multimedia_API_R32.5.0_aarch64.tbz2 -O jetson_multimedia_api.tbz2
|
||||||
|
tar xaf jetson_multimedia_api.tbz2 -C / && rm jetson_multimedia_api.tbz2
|
||||||
else
|
else
|
||||||
# assume Jetpack 5.X
|
# assume Jetpack 5.X
|
||||||
wget -q https://developer.nvidia.com/downloads/embedded/l4t/r35_release_v3.1/release/jetson_multimedia_api_r35.3.1_aarch64.tbz2 -O jetson_multimedia_api.tbz2
|
wget -q https://developer.nvidia.com/downloads/embedded/l4t/r35_release_v3.1/release/jetson_multimedia_api_r35.3.1_aarch64.tbz2 -O jetson_multimedia_api.tbz2
|
||||||
|
tar xaf jetson_multimedia_api.tbz2 -C / && rm jetson_multimedia_api.tbz2
|
||||||
fi
|
fi
|
||||||
tar xaf jetson_multimedia_api.tbz2 -C / && rm jetson_multimedia_api.tbz2
|
|
||||||
|
|
||||||
wget -q https://github.com/AndBobsYourUncle/jetson-ffmpeg/archive/9c17b09.zip -O jetson-ffmpeg.zip
|
wget -q https://github.com/AndBobsYourUncle/jetson-ffmpeg/archive/9c17b09.zip -O jetson-ffmpeg.zip
|
||||||
unzip jetson-ffmpeg.zip && rm jetson-ffmpeg.zip && mv jetson-ffmpeg-* jetson-ffmpeg && cd jetson-ffmpeg
|
unzip jetson-ffmpeg.zip && rm jetson-ffmpeg.zip && mv jetson-ffmpeg-* jetson-ffmpeg && cd jetson-ffmpeg
|
||||||
|
|||||||
@ -6,23 +6,23 @@ mkdir -p /trt-wheels
|
|||||||
|
|
||||||
if [[ "${TARGETARCH}" == "arm64" ]]; then
|
if [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||||
|
|
||||||
# NVIDIA supplies python-tensorrt for python3.8, but frigate uses python3.9,
|
# NVIDIA supplies python-tensorrt for python3.10, but frigate uses python3.11,
|
||||||
# so we must build python-tensorrt ourselves.
|
# so we must build python-tensorrt ourselves.
|
||||||
|
|
||||||
# Get python-tensorrt source
|
# Get python-tensorrt source
|
||||||
mkdir /workspace
|
mkdir -p /workspace
|
||||||
cd /workspace
|
cd /workspace
|
||||||
git clone -b ${TENSORRT_VER} https://github.com/NVIDIA/TensorRT.git --depth=1
|
git clone -b release/8.6 https://github.com/NVIDIA/TensorRT.git --depth=1
|
||||||
|
|
||||||
# Collect dependencies
|
# Collect dependencies
|
||||||
EXT_PATH=/workspace/external && mkdir -p $EXT_PATH
|
EXT_PATH=/workspace/external && mkdir -p $EXT_PATH
|
||||||
pip3 install pybind11 && ln -s /usr/local/lib/python3.9/dist-packages/pybind11 $EXT_PATH/pybind11
|
pip3 install pybind11 && ln -s /usr/local/lib/python3.11/dist-packages/pybind11 $EXT_PATH/pybind11
|
||||||
ln -s /usr/include/python3.9 $EXT_PATH/python3.9
|
ln -s /usr/include/python3.11 $EXT_PATH/python3.11
|
||||||
ln -s /usr/include/aarch64-linux-gnu/NvOnnxParser.h /workspace/TensorRT/parsers/onnx/
|
ln -s /usr/include/aarch64-linux-gnu/NvOnnxParser.h /workspace/TensorRT/parsers/onnx/
|
||||||
|
|
||||||
# Build wheel
|
# Build wheel
|
||||||
cd /workspace/TensorRT/python
|
cd /workspace/TensorRT/python
|
||||||
EXT_PATH=$EXT_PATH PYTHON_MAJOR_VERSION=3 PYTHON_MINOR_VERSION=9 TARGET_ARCHITECTURE=aarch64 /bin/bash ./build.sh
|
EXT_PATH=$EXT_PATH PYTHON_MAJOR_VERSION=3 PYTHON_MINOR_VERSION=11 TARGET_ARCHITECTURE=aarch64 TENSORRT_MODULE=tensorrt /bin/bash ./build.sh
|
||||||
mv build/dist/*.whl /trt-wheels/
|
mv build/bindings_wheel/dist/*.whl /trt-wheels/
|
||||||
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
cuda-python == 11.7; platform_machine == 'aarch64'
|
cuda-python == 12.6.*; platform_machine == 'aarch64'
|
||||||
|
|||||||
@ -13,13 +13,24 @@ variable "TRT_BASE" {
|
|||||||
variable "COMPUTE_LEVEL" {
|
variable "COMPUTE_LEVEL" {
|
||||||
default = ""
|
default = ""
|
||||||
}
|
}
|
||||||
|
variable "BASE_HOOK" {
|
||||||
|
# Ensure an up-to-date python 3.11 is available in tensorrt/jetson image
|
||||||
|
default = <<EOT
|
||||||
|
if grep -iq \"ubuntu\" /etc/os-release; then
|
||||||
|
apt-get update &&
|
||||||
|
apt-get install -y software-properties-common &&
|
||||||
|
add-apt-repository ppa:deadsnakes/ppa;
|
||||||
|
fi
|
||||||
|
EOT
|
||||||
|
}
|
||||||
|
|
||||||
target "_build_args" {
|
target "_build_args" {
|
||||||
args = {
|
args = {
|
||||||
BASE_IMAGE = BASE_IMAGE,
|
BASE_IMAGE = BASE_IMAGE,
|
||||||
SLIM_BASE = SLIM_BASE,
|
SLIM_BASE = SLIM_BASE,
|
||||||
TRT_BASE = TRT_BASE,
|
TRT_BASE = TRT_BASE,
|
||||||
COMPUTE_LEVEL = COMPUTE_LEVEL
|
COMPUTE_LEVEL = COMPUTE_LEVEL,
|
||||||
|
BASE_HOOK = BASE_HOOK
|
||||||
}
|
}
|
||||||
platforms = ["linux/${ARCH}"]
|
platforms = ["linux/${ARCH}"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,41 +1,41 @@
|
|||||||
BOARDS += trt
|
BOARDS += trt
|
||||||
|
|
||||||
JETPACK4_BASE ?= timongentzsch/l4t-ubuntu20-opencv:latest # L4T 32.7.1 JetPack 4.6.1
|
|
||||||
JETPACK5_BASE ?= nvcr.io/nvidia/l4t-tensorrt:r8.5.2-runtime # L4T 35.3.1 JetPack 5.1.1
|
JETPACK5_BASE ?= nvcr.io/nvidia/l4t-tensorrt:r8.5.2-runtime # L4T 35.3.1 JetPack 5.1.1
|
||||||
|
JETPACK6_BASE ?= nvcr.io/nvidia/tensorrt:23.12-py3-igpu
|
||||||
X86_DGPU_ARGS := ARCH=amd64 COMPUTE_LEVEL="50 60 70 80 90"
|
X86_DGPU_ARGS := ARCH=amd64 COMPUTE_LEVEL="50 60 70 80 90"
|
||||||
JETPACK4_ARGS := ARCH=arm64 BASE_IMAGE=$(JETPACK4_BASE) SLIM_BASE=$(JETPACK4_BASE) TRT_BASE=$(JETPACK4_BASE)
|
|
||||||
JETPACK5_ARGS := ARCH=arm64 BASE_IMAGE=$(JETPACK5_BASE) SLIM_BASE=$(JETPACK5_BASE) TRT_BASE=$(JETPACK5_BASE)
|
JETPACK5_ARGS := ARCH=arm64 BASE_IMAGE=$(JETPACK5_BASE) SLIM_BASE=$(JETPACK5_BASE) TRT_BASE=$(JETPACK5_BASE)
|
||||||
|
JETPACK6_ARGS := ARCH=arm64 BASE_IMAGE=$(JETPACK6_BASE) SLIM_BASE=$(JETPACK6_BASE) TRT_BASE=$(JETPACK6_BASE)
|
||||||
|
|
||||||
local-trt: version
|
local-trt: version
|
||||||
$(X86_DGPU_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
$(X86_DGPU_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
||||||
--set tensorrt.tags=frigate:latest-tensorrt \
|
--set tensorrt.tags=frigate:latest-tensorrt \
|
||||||
--load
|
--load
|
||||||
|
|
||||||
local-trt-jp4: version
|
|
||||||
$(JETPACK4_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
|
||||||
--set tensorrt.tags=frigate:latest-tensorrt-jp4 \
|
|
||||||
--load
|
|
||||||
|
|
||||||
local-trt-jp5: version
|
local-trt-jp5: version
|
||||||
$(JETPACK5_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
$(JETPACK5_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
||||||
--set tensorrt.tags=frigate:latest-tensorrt-jp5 \
|
--set tensorrt.tags=frigate:latest-tensorrt-jp5 \
|
||||||
--load
|
--load
|
||||||
|
|
||||||
|
local-trt-jp6: version
|
||||||
|
$(JETPACK6_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
||||||
|
--set tensorrt.tags=frigate:latest-tensorrt-jp6 \
|
||||||
|
--load
|
||||||
|
|
||||||
build-trt:
|
build-trt:
|
||||||
$(X86_DGPU_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
$(X86_DGPU_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
||||||
--set tensorrt.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt
|
--set tensorrt.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt
|
||||||
$(JETPACK4_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
|
||||||
--set tensorrt.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt-jp4
|
|
||||||
$(JETPACK5_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
$(JETPACK5_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
||||||
--set tensorrt.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt-jp5
|
--set tensorrt.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt-jp5
|
||||||
|
$(JETPACK6_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
||||||
|
--set tensorrt.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt-jp6
|
||||||
|
|
||||||
push-trt: build-trt
|
push-trt: build-trt
|
||||||
$(X86_DGPU_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
$(X86_DGPU_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
||||||
--set tensorrt.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt \
|
--set tensorrt.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt \
|
||||||
--push
|
--push
|
||||||
$(JETPACK4_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
|
||||||
--set tensorrt.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt-jp4 \
|
|
||||||
--push
|
|
||||||
$(JETPACK5_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
$(JETPACK5_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
||||||
--set tensorrt.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt-jp5 \
|
--set tensorrt.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt-jp5 \
|
||||||
--push
|
--push
|
||||||
|
$(JETPACK6_ARGS) docker buildx bake --file=docker/tensorrt/trt.hcl tensorrt \
|
||||||
|
--set tensorrt.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-tensorrt-jp6 \
|
||||||
|
--push
|
||||||
|
|||||||
@ -5,11 +5,7 @@ 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.
|
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.
|
Frigate has support for CV2 Local Binary Pattern Face Recognizer to recognize faces, which runs locally. A lightweight face landmark detection model is also used to align faces before running them through the face recognizer.
|
||||||
|
|
||||||
## 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
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@ -295,10 +295,8 @@ These instructions were originally based on the [Jellyfin documentation](https:/
|
|||||||
## NVIDIA Jetson (Orin AGX, Orin NX, Orin Nano\*, Xavier AGX, Xavier NX, TX2, TX1, Nano)
|
## NVIDIA Jetson (Orin AGX, Orin NX, Orin Nano\*, Xavier AGX, Xavier NX, TX2, TX1, Nano)
|
||||||
|
|
||||||
A separate set of docker images is available that is based on Jetpack/L4T. They come with an `ffmpeg` build
|
A separate set of docker images is available that is based on Jetpack/L4T. They come with an `ffmpeg` build
|
||||||
with codecs that use the Jetson's dedicated media engine. If your Jetson host is running Jetpack 4.6, use the
|
with codecs that use the Jetson's dedicated media engine. If your Jetson host is running Jetpack 5.0+ use the `stable-tensorrt-jp5`
|
||||||
`stable-tensorrt-jp4` tagged image, or if your Jetson host is running Jetpack 5.0+, use the `stable-tensorrt-jp5`
|
tagged image, or if your Jetson host is running Jetpack 6.0+ use the `stable-tensorrt-jp6` tagged image. Note that the Orin Nano has no video encoder, so frigate will use software encoding on this platform, but the image will still allow hardware decoding and tensorrt object detection.
|
||||||
tagged image. Note that the Orin Nano has no video encoder, so frigate will use software encoding on this platform,
|
|
||||||
but the image will still allow hardware decoding and tensorrt object detection.
|
|
||||||
|
|
||||||
You will need to use the image with the nvidia container runtime:
|
You will need to use the image with the nvidia container runtime:
|
||||||
|
|
||||||
|
|||||||
@ -11,14 +11,37 @@ Frigate offers native notifications using the [WebPush Protocol](https://web.dev
|
|||||||
|
|
||||||
In order to use notifications the following requirements must be met:
|
In order to use notifications the following requirements must be met:
|
||||||
|
|
||||||
- Frigate must be accessed via a secure https connection
|
- Frigate must be accessed via a secure `https` connection ([see the authorization docs](/configuration/authentication)).
|
||||||
- A supported browser must be used. Currently Chrome, Firefox, and Safari are known to be supported.
|
- A supported browser must be used. Currently Chrome, Firefox, and Safari are known to be supported.
|
||||||
- In order for notifications to be usable externally, Frigate must be accessible externally
|
- In order for notifications to be usable externally, Frigate must be accessible externally.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
To configure notifications, go to the Frigate WebUI -> Settings -> Notifications and enable, then fill out the fields and save.
|
To configure notifications, go to the Frigate WebUI -> Settings -> Notifications and enable, then fill out the fields and save.
|
||||||
|
|
||||||
|
Optionally, you can change the default cooldown period for notifications through the `cooldown` parameter in your config file. This parameter can also be overridden at the camera level.
|
||||||
|
|
||||||
|
Notifications will be prevented if either:
|
||||||
|
|
||||||
|
- The global cooldown period hasn't elapsed since any camera's last notification
|
||||||
|
- The camera-specific cooldown period hasn't elapsed for the specific camera
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
notifications:
|
||||||
|
enabled: True
|
||||||
|
email: "johndoe@gmail.com"
|
||||||
|
cooldown: 10 # wait 10 seconds before sending another notification from any camera
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameras:
|
||||||
|
doorbell:
|
||||||
|
...
|
||||||
|
notifications:
|
||||||
|
enabled: True
|
||||||
|
cooldown: 30 # wait 30 seconds before sending another notification from the doorbell camera
|
||||||
|
```
|
||||||
|
|
||||||
### Registration
|
### Registration
|
||||||
|
|
||||||
Once notifications are enabled, press the `Register for Notifications` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent.
|
Once notifications are enabled, press the `Register for Notifications` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent.
|
||||||
|
|||||||
@ -420,6 +420,8 @@ notifications:
|
|||||||
# Optional: Email for push service to reach out to
|
# Optional: Email for push service to reach out to
|
||||||
# NOTE: This is required to use notifications
|
# NOTE: This is required to use notifications
|
||||||
email: "admin@example.com"
|
email: "admin@example.com"
|
||||||
|
# Optional: Cooldown time for notifications in seconds (default: shown below)
|
||||||
|
cooldown: 0
|
||||||
|
|
||||||
# Optional: Record configuration
|
# Optional: Record configuration
|
||||||
# NOTE: Can be overridden at the camera level
|
# NOTE: Can be overridden at the camera level
|
||||||
|
|||||||
@ -34,7 +34,7 @@ Fork [blakeblackshear/frigate-hass-integration](https://github.com/blakeblackshe
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- GNU make
|
- GNU make
|
||||||
- Docker
|
- Docker (including buildx plugin)
|
||||||
- An extra detector (Coral, OpenVINO, etc.) is optional but recommended to simulate real world performance.
|
- An extra detector (Coral, OpenVINO, etc.) is optional but recommended to simulate real world performance.
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|||||||
@ -250,7 +250,7 @@ The official docker image tags for the current stable version are:
|
|||||||
The community supported docker image tags for the current stable version are:
|
The community supported docker image tags for the current stable version are:
|
||||||
|
|
||||||
- `stable-tensorrt-jp5` - Frigate build optimized for nvidia Jetson devices running Jetpack 5
|
- `stable-tensorrt-jp5` - Frigate build optimized for nvidia Jetson devices running Jetpack 5
|
||||||
- `stable-tensorrt-jp4` - Frigate build optimized for nvidia Jetson devices running Jetpack 4.6
|
- `stable-tensorrt-jp6` - Frigate build optimized for nvidia Jetson devices running Jetpack 6
|
||||||
- `stable-rk` - Frigate build for SBCs with Rockchip SoC
|
- `stable-rk` - Frigate build for SBCs with Rockchip SoC
|
||||||
- `stable-rocm` - Frigate build for [AMD GPUs](../configuration/object_detectors.md#amdrocm-gpu-detector)
|
- `stable-rocm` - Frigate build for [AMD GPUs](../configuration/object_detectors.md#amdrocm-gpu-detector)
|
||||||
- `stable-h8l` - Frigate build for the Hailo-8L M.2 PICe Raspberry Pi 5 hat
|
- `stable-h8l` - Frigate build for the Hailo-8L M.2 PICe Raspberry Pi 5 hat
|
||||||
|
|||||||
@ -20,7 +20,6 @@ from fastapi.params import Depends
|
|||||||
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
|
||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
from peewee import operator
|
from peewee import operator
|
||||||
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
||||||
@ -28,6 +27,7 @@ from frigate.api.defs.request.app_body import AppConfigSetBody
|
|||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.models import Event, Timeline
|
from frigate.models import Event, Timeline
|
||||||
|
from frigate.stats.prometheus import get_metrics, update_metrics
|
||||||
from frigate.util.builtin import (
|
from frigate.util.builtin import (
|
||||||
clean_camera_user_pass,
|
clean_camera_user_pass,
|
||||||
get_tz_modifiers,
|
get_tz_modifiers,
|
||||||
@ -113,9 +113,13 @@ def stats_history(request: Request, keys: str = None):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/metrics")
|
@router.get("/metrics")
|
||||||
def metrics():
|
def metrics(request: Request):
|
||||||
"""Expose Prometheus metrics endpoint"""
|
"""Expose Prometheus metrics endpoint and update metrics with latest stats"""
|
||||||
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
# Retrieve the latest statistics and update the Prometheus metrics
|
||||||
|
stats = request.app.stats_emitter.get_latest_stats()
|
||||||
|
update_metrics(stats)
|
||||||
|
content, content_type = get_metrics()
|
||||||
|
return Response(content=content, media_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config")
|
@router.get("/config")
|
||||||
|
|||||||
@ -336,6 +336,7 @@ def events_explore(limit: int = 10):
|
|||||||
"sub_label_score",
|
"sub_label_score",
|
||||||
"average_estimated_speed",
|
"average_estimated_speed",
|
||||||
"velocity_angle",
|
"velocity_angle",
|
||||||
|
"path_data",
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"event_count": label_counts[event.label],
|
"event_count": label_counts[event.label],
|
||||||
@ -622,6 +623,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
|
|||||||
"sub_label_score",
|
"sub_label_score",
|
||||||
"average_estimated_speed",
|
"average_estimated_speed",
|
||||||
"velocity_angle",
|
"velocity_angle",
|
||||||
|
"path_data",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
"""Image and video apis."""
|
"""Image and video apis."""
|
||||||
|
|
||||||
import base64
|
|
||||||
import glob
|
import glob
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@ -40,6 +39,7 @@ from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
|||||||
from frigate.object_processing import TrackedObjectProcessor
|
from frigate.object_processing import TrackedObjectProcessor
|
||||||
from frigate.util.builtin import get_tz_modifiers
|
from frigate.util.builtin import get_tz_modifiers
|
||||||
from frigate.util.image import get_image_from_recording
|
from frigate.util.image import get_image_from_recording
|
||||||
|
from frigate.util.path import get_event_thumbnail_bytes
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -804,10 +804,11 @@ def event_snapshot(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events/{event_id}/thumbnail.jpg")
|
@router.get("/events/{event_id}/thumbnail.{extension}")
|
||||||
def event_thumbnail(
|
def event_thumbnail(
|
||||||
request: Request,
|
request: Request,
|
||||||
event_id: str,
|
event_id: str,
|
||||||
|
extension: str,
|
||||||
max_cache_age: int = Query(
|
max_cache_age: int = Query(
|
||||||
2592000, description="Max cache age in seconds. Default 30 days in seconds."
|
2592000, description="Max cache age in seconds. Default 30 days in seconds."
|
||||||
),
|
),
|
||||||
@ -816,11 +817,15 @@ def event_thumbnail(
|
|||||||
thumbnail_bytes = None
|
thumbnail_bytes = None
|
||||||
event_complete = False
|
event_complete = False
|
||||||
try:
|
try:
|
||||||
event = Event.get(Event.id == event_id)
|
event: Event = Event.get(Event.id == event_id)
|
||||||
if event.end_time is not None:
|
if event.end_time is not None:
|
||||||
event_complete = True
|
event_complete = True
|
||||||
thumbnail_bytes = base64.b64decode(event.thumbnail)
|
|
||||||
|
thumbnail_bytes = get_event_thumbnail_bytes(event)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
|
thumbnail_bytes = None
|
||||||
|
|
||||||
|
if thumbnail_bytes is None:
|
||||||
# see if the object is currently being tracked
|
# see if the object is currently being tracked
|
||||||
try:
|
try:
|
||||||
camera_states = request.app.detected_frames_processor.camera_states.values()
|
camera_states = request.app.detected_frames_processor.camera_states.values()
|
||||||
@ -828,7 +833,7 @@ def event_thumbnail(
|
|||||||
if event_id in camera_state.tracked_objects:
|
if event_id in camera_state.tracked_objects:
|
||||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||||
if tracked_obj is not None:
|
if tracked_obj is not None:
|
||||||
thumbnail_bytes = tracked_obj.get_thumbnail()
|
thumbnail_bytes = tracked_obj.get_thumbnail(extension)
|
||||||
except Exception:
|
except Exception:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"success": False, "message": "Event not found"},
|
content={"success": False, "message": "Event not found"},
|
||||||
@ -843,8 +848,8 @@ def event_thumbnail(
|
|||||||
|
|
||||||
# android notifications prefer a 2:1 ratio
|
# android notifications prefer a 2:1 ratio
|
||||||
if format == "android":
|
if format == "android":
|
||||||
jpg_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
|
img_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
|
||||||
img = cv2.imdecode(jpg_as_np, flags=1)
|
img = cv2.imdecode(img_as_np, flags=1)
|
||||||
thumbnail = cv2.copyMakeBorder(
|
thumbnail = cv2.copyMakeBorder(
|
||||||
img,
|
img,
|
||||||
0,
|
0,
|
||||||
@ -854,17 +859,25 @@ def event_thumbnail(
|
|||||||
cv2.BORDER_CONSTANT,
|
cv2.BORDER_CONSTANT,
|
||||||
(0, 0, 0),
|
(0, 0, 0),
|
||||||
)
|
)
|
||||||
ret, jpg = cv2.imencode(".jpg", thumbnail, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
|
|
||||||
thumbnail_bytes = jpg.tobytes()
|
quality_params = None
|
||||||
|
|
||||||
|
if extension == "jpg" or extension == "jpeg":
|
||||||
|
quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), 70]
|
||||||
|
elif extension == "webp":
|
||||||
|
quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), 60]
|
||||||
|
|
||||||
|
_, img = cv2.imencode(f".{img}", thumbnail, quality_params)
|
||||||
|
thumbnail_bytes = img.tobytes()
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
thumbnail_bytes,
|
thumbnail_bytes,
|
||||||
media_type="image/jpeg",
|
media_type=f"image/{extension}",
|
||||||
headers={
|
headers={
|
||||||
"Cache-Control": f"private, max-age={max_cache_age}"
|
"Cache-Control": f"private, max-age={max_cache_age}"
|
||||||
if event_complete
|
if event_complete
|
||||||
else "no-store",
|
else "no-store",
|
||||||
"Content-Type": "image/jpeg",
|
"Content-Type": f"image/{extension}",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,7 @@ from frigate.const import (
|
|||||||
MODEL_CACHE_DIR,
|
MODEL_CACHE_DIR,
|
||||||
RECORD_DIR,
|
RECORD_DIR,
|
||||||
SHM_FRAMES_VAR,
|
SHM_FRAMES_VAR,
|
||||||
|
THUMB_DIR,
|
||||||
)
|
)
|
||||||
from frigate.data_processing.types import DataProcessorMetrics
|
from frigate.data_processing.types import DataProcessorMetrics
|
||||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||||
@ -105,6 +106,7 @@ class FrigateApp:
|
|||||||
dirs = [
|
dirs = [
|
||||||
CONFIG_DIR,
|
CONFIG_DIR,
|
||||||
RECORD_DIR,
|
RECORD_DIR,
|
||||||
|
THUMB_DIR,
|
||||||
f"{CLIPS_DIR}/cache",
|
f"{CLIPS_DIR}/cache",
|
||||||
CACHE_DIR,
|
CACHE_DIR,
|
||||||
MODEL_CACHE_DIR,
|
MODEL_CACHE_DIR,
|
||||||
|
|||||||
@ -47,6 +47,10 @@ class WebPushClient(Communicator): # type: ignore[misc]
|
|||||||
self.suspended_cameras: dict[str, int] = {
|
self.suspended_cameras: dict[str, int] = {
|
||||||
c.name: 0 for c in self.config.cameras.values()
|
c.name: 0 for c in self.config.cameras.values()
|
||||||
}
|
}
|
||||||
|
self.last_camera_notification_time: dict[str, float] = {
|
||||||
|
c.name: 0 for c in self.config.cameras.values()
|
||||||
|
}
|
||||||
|
self.last_notification_time: float = 0
|
||||||
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
|
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
|
||||||
self.notification_thread = threading.Thread(
|
self.notification_thread = threading.Thread(
|
||||||
target=self._process_notifications, daemon=True
|
target=self._process_notifications, daemon=True
|
||||||
@ -264,6 +268,29 @@ class WebPushClient(Communicator): # type: ignore[misc]
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
camera: str = payload["after"]["camera"]
|
||||||
|
current_time = datetime.datetime.now().timestamp()
|
||||||
|
|
||||||
|
# Check global cooldown period
|
||||||
|
if (
|
||||||
|
current_time - self.last_notification_time
|
||||||
|
< self.config.notifications.cooldown
|
||||||
|
):
|
||||||
|
logger.debug(
|
||||||
|
f"Skipping notification for {camera} - in global cooldown period"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check camera-specific cooldown period
|
||||||
|
if (
|
||||||
|
current_time - self.last_camera_notification_time[camera]
|
||||||
|
< self.config.cameras[camera].notifications.cooldown
|
||||||
|
):
|
||||||
|
logger.debug(
|
||||||
|
f"Skipping notification for {camera} - in camera-specific cooldown period"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
self.check_registrations()
|
self.check_registrations()
|
||||||
|
|
||||||
state = payload["type"]
|
state = payload["type"]
|
||||||
@ -278,6 +305,9 @@ class WebPushClient(Communicator): # type: ignore[misc]
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self.last_camera_notification_time[camera] = current_time
|
||||||
|
self.last_notification_time = current_time
|
||||||
|
|
||||||
reviewId = payload["after"]["id"]
|
reviewId = payload["after"]["id"]
|
||||||
sorted_objects: set[str] = set()
|
sorted_objects: set[str] = set()
|
||||||
|
|
||||||
@ -287,7 +317,6 @@ class WebPushClient(Communicator): # type: ignore[misc]
|
|||||||
|
|
||||||
sorted_objects.update(payload["after"]["data"]["sub_labels"])
|
sorted_objects.update(payload["after"]["data"]["sub_labels"])
|
||||||
|
|
||||||
camera: str = payload["after"]["camera"]
|
|
||||||
title = f"{', '.join(sorted_objects).replace('_', ' ').title()}{' was' if state == 'end' else ''} detected in {', '.join(payload['after']['data']['zones']).replace('_', ' ').title()}"
|
title = f"{', '.join(sorted_objects).replace('_', ' ').title()}{' was' if state == 'end' else ''} detected in {', '.join(payload['after']['data']['zones']).replace('_', ' ').title()}"
|
||||||
message = f"Detected on {camera.replace('_', ' ').title()}"
|
message = f"Detected on {camera.replace('_', ' ').title()}"
|
||||||
image = f"{payload['after']['thumb_path'].replace('/media/frigate', '')}"
|
image = f"{payload['after']['thumb_path'].replace('/media/frigate', '')}"
|
||||||
|
|||||||
@ -10,6 +10,9 @@ __all__ = ["NotificationConfig"]
|
|||||||
class NotificationConfig(FrigateBaseModel):
|
class NotificationConfig(FrigateBaseModel):
|
||||||
enabled: bool = Field(default=False, title="Enable notifications")
|
enabled: bool = Field(default=False, title="Enable notifications")
|
||||||
email: Optional[str] = Field(default=None, title="Email required for push.")
|
email: Optional[str] = Field(default=None, title="Email required for push.")
|
||||||
|
cooldown: Optional[int] = Field(
|
||||||
|
default=0, ge=0, title="Cooldown period for notifications (time in seconds)."
|
||||||
|
)
|
||||||
enabled_in_config: Optional[bool] = Field(
|
enabled_in_config: Optional[bool] = Field(
|
||||||
default=None, title="Keep track of original state of notifications."
|
default=None, title="Keep track of original state of notifications."
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ BASE_DIR = "/media/frigate"
|
|||||||
CLIPS_DIR = f"{BASE_DIR}/clips"
|
CLIPS_DIR = f"{BASE_DIR}/clips"
|
||||||
EXPORT_DIR = f"{BASE_DIR}/exports"
|
EXPORT_DIR = f"{BASE_DIR}/exports"
|
||||||
FACE_DIR = f"{CLIPS_DIR}/faces"
|
FACE_DIR = f"{CLIPS_DIR}/faces"
|
||||||
|
THUMB_DIR = f"{CLIPS_DIR}/thumbs"
|
||||||
RECORD_DIR = f"{BASE_DIR}/recordings"
|
RECORD_DIR = f"{BASE_DIR}/recordings"
|
||||||
BIRDSEYE_PIPE = "/tmp/cache/birdseye"
|
BIRDSEYE_PIPE = "/tmp/cache/birdseye"
|
||||||
CACHE_DIR = "/tmp/cache"
|
CACHE_DIR = "/tmp/cache"
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
"""SQLite-vec embeddings database."""
|
"""SQLite-vec embeddings database."""
|
||||||
|
|
||||||
import base64
|
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@ -21,6 +20,7 @@ from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
|||||||
from frigate.models import Event
|
from frigate.models import Event
|
||||||
from frigate.types import ModelStatusTypesEnum
|
from frigate.types import ModelStatusTypesEnum
|
||||||
from frigate.util.builtin import serialize
|
from frigate.util.builtin import serialize
|
||||||
|
from frigate.util.path import get_event_thumbnail_bytes
|
||||||
|
|
||||||
from .functions.onnx import GenericONNXEmbedding, ModelTypeEnum
|
from .functions.onnx import GenericONNXEmbedding, ModelTypeEnum
|
||||||
|
|
||||||
@ -264,14 +264,7 @@ class Embeddings:
|
|||||||
st = time.time()
|
st = time.time()
|
||||||
|
|
||||||
# Get total count of events to process
|
# Get total count of events to process
|
||||||
total_events = (
|
total_events = Event.select().count()
|
||||||
Event.select()
|
|
||||||
.where(
|
|
||||||
(Event.has_clip == True | Event.has_snapshot == True)
|
|
||||||
& Event.thumbnail.is_null(False)
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
batch_size = 32
|
batch_size = 32
|
||||||
current_page = 1
|
current_page = 1
|
||||||
@ -289,10 +282,6 @@ class Embeddings:
|
|||||||
|
|
||||||
events = (
|
events = (
|
||||||
Event.select()
|
Event.select()
|
||||||
.where(
|
|
||||||
(Event.has_clip == True | Event.has_snapshot == True)
|
|
||||||
& Event.thumbnail.is_null(False)
|
|
||||||
)
|
|
||||||
.order_by(Event.start_time.desc())
|
.order_by(Event.start_time.desc())
|
||||||
.paginate(current_page, batch_size)
|
.paginate(current_page, batch_size)
|
||||||
)
|
)
|
||||||
@ -302,7 +291,12 @@ class Embeddings:
|
|||||||
batch_thumbs = {}
|
batch_thumbs = {}
|
||||||
batch_descs = {}
|
batch_descs = {}
|
||||||
for event in events:
|
for event in events:
|
||||||
batch_thumbs[event.id] = base64.b64decode(event.thumbnail)
|
thumbnail = get_event_thumbnail_bytes(event)
|
||||||
|
|
||||||
|
if thumbnail is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
batch_thumbs[event.id] = thumbnail
|
||||||
totals["thumbnails"] += 1
|
totals["thumbnails"] += 1
|
||||||
|
|
||||||
if description := event.data.get("description", "").strip():
|
if description := event.data.get("description", "").strip():
|
||||||
@ -341,10 +335,6 @@ class Embeddings:
|
|||||||
current_page += 1
|
current_page += 1
|
||||||
events = (
|
events = (
|
||||||
Event.select()
|
Event.select()
|
||||||
.where(
|
|
||||||
(Event.has_clip == True | Event.has_snapshot == True)
|
|
||||||
& Event.thumbnail.is_null(False)
|
|
||||||
)
|
|
||||||
.order_by(Event.start_time.desc())
|
.order_by(Event.start_time.desc())
|
||||||
.paginate(current_page, batch_size)
|
.paginate(current_page, batch_size)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -38,6 +38,7 @@ from frigate.models import Event
|
|||||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||||
from frigate.util.builtin import serialize
|
from frigate.util.builtin import serialize
|
||||||
from frigate.util.image import SharedMemoryFrameManager, calculate_region
|
from frigate.util.image import SharedMemoryFrameManager, calculate_region
|
||||||
|
from frigate.util.path import get_event_thumbnail_bytes
|
||||||
|
|
||||||
from .embeddings import Embeddings
|
from .embeddings import Embeddings
|
||||||
|
|
||||||
@ -215,7 +216,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Extract valid thumbnail
|
# Extract valid thumbnail
|
||||||
thumbnail = base64.b64decode(event.thumbnail)
|
thumbnail = get_event_thumbnail_bytes(event)
|
||||||
|
|
||||||
# Embed the thumbnail
|
# Embed the thumbnail
|
||||||
self._embed_thumbnail(event_id, thumbnail)
|
self._embed_thumbnail(event_id, thumbnail)
|
||||||
@ -390,7 +391,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
logger.error(f"GenAI not enabled for camera {event.camera}")
|
logger.error(f"GenAI not enabled for camera {event.camera}")
|
||||||
return
|
return
|
||||||
|
|
||||||
thumbnail = base64.b64decode(event.thumbnail)
|
thumbnail = get_event_thumbnail_bytes(event)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Trying {source} regeneration for {event}, has_snapshot: {event.has_snapshot}"
|
f"Trying {source} regeneration for {event}, has_snapshot: {event.has_snapshot}"
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from frigate.config import FrigateConfig
|
|||||||
from frigate.const import CLIPS_DIR
|
from frigate.const import CLIPS_DIR
|
||||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||||
from frigate.models import Event, Timeline
|
from frigate.models import Event, Timeline
|
||||||
|
from frigate.util.path import delete_event_images
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -64,7 +65,6 @@ class EventCleanup(threading.Thread):
|
|||||||
def expire_snapshots(self) -> list[str]:
|
def expire_snapshots(self) -> list[str]:
|
||||||
## Expire events from unlisted cameras based on the global config
|
## Expire events from unlisted cameras based on the global config
|
||||||
retain_config = self.config.snapshots.retain
|
retain_config = self.config.snapshots.retain
|
||||||
file_extension = "jpg"
|
|
||||||
update_params = {"has_snapshot": False}
|
update_params = {"has_snapshot": False}
|
||||||
|
|
||||||
distinct_labels = self.get_removed_camera_labels()
|
distinct_labels = self.get_removed_camera_labels()
|
||||||
@ -83,6 +83,7 @@ class EventCleanup(threading.Thread):
|
|||||||
Event.select(
|
Event.select(
|
||||||
Event.id,
|
Event.id,
|
||||||
Event.camera,
|
Event.camera,
|
||||||
|
Event.thumbnail,
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Event.camera.not_in(self.camera_keys),
|
Event.camera.not_in(self.camera_keys),
|
||||||
@ -94,22 +95,15 @@ class EventCleanup(threading.Thread):
|
|||||||
.iterator()
|
.iterator()
|
||||||
)
|
)
|
||||||
logger.debug(f"{len(list(expired_events))} events can be expired")
|
logger.debug(f"{len(list(expired_events))} events can be expired")
|
||||||
|
|
||||||
# delete the media from disk
|
# delete the media from disk
|
||||||
for expired in expired_events:
|
for expired in expired_events:
|
||||||
media_name = f"{expired.camera}-{expired.id}"
|
deleted = delete_event_images(expired)
|
||||||
media_path = Path(
|
|
||||||
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
if not deleted:
|
||||||
media_path.unlink(missing_ok=True)
|
logger.warning(
|
||||||
if file_extension == "jpg":
|
f"Unable to delete event images for {expired.camera}: {expired.id}"
|
||||||
media_path = Path(
|
)
|
||||||
f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"
|
|
||||||
)
|
|
||||||
media_path.unlink(missing_ok=True)
|
|
||||||
except OSError as e:
|
|
||||||
logger.warning(f"Unable to delete event images: {e}")
|
|
||||||
|
|
||||||
# update the clips attribute for the db entry
|
# update the clips attribute for the db entry
|
||||||
query = Event.select(Event.id).where(
|
query = Event.select(Event.id).where(
|
||||||
@ -165,6 +159,7 @@ class EventCleanup(threading.Thread):
|
|||||||
Event.select(
|
Event.select(
|
||||||
Event.id,
|
Event.id,
|
||||||
Event.camera,
|
Event.camera,
|
||||||
|
Event.thumbnail,
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Event.camera == name,
|
Event.camera == name,
|
||||||
@ -181,19 +176,12 @@ class EventCleanup(threading.Thread):
|
|||||||
# so no need to delete mp4 files
|
# so no need to delete mp4 files
|
||||||
for event in expired_events:
|
for event in expired_events:
|
||||||
events_to_update.append(event.id)
|
events_to_update.append(event.id)
|
||||||
|
deleted = delete_event_images(event)
|
||||||
|
|
||||||
try:
|
if not deleted:
|
||||||
media_name = f"{event.camera}-{event.id}"
|
logger.warning(
|
||||||
media_path = Path(
|
f"Unable to delete event images for {event.camera}: {event.id}"
|
||||||
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
|
|
||||||
)
|
)
|
||||||
media_path.unlink(missing_ok=True)
|
|
||||||
media_path = Path(
|
|
||||||
f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"
|
|
||||||
)
|
|
||||||
media_path.unlink(missing_ok=True)
|
|
||||||
except OSError as e:
|
|
||||||
logger.warning(f"Unable to delete event images: {e}")
|
|
||||||
|
|
||||||
# update the clips attribute for the db entry
|
# update the clips attribute for the db entry
|
||||||
for i in range(0, len(events_to_update), CHUNK_SIZE):
|
for i in range(0, len(events_to_update), CHUNK_SIZE):
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
"""Handle external events created by the user."""
|
"""Handle external events created by the user."""
|
||||||
|
|
||||||
import base64
|
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@ -15,7 +14,7 @@ from numpy import ndarray
|
|||||||
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
|
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
|
||||||
from frigate.comms.events_updater import EventUpdatePublisher
|
from frigate.comms.events_updater import EventUpdatePublisher
|
||||||
from frigate.config import CameraConfig, FrigateConfig
|
from frigate.config import CameraConfig, FrigateConfig
|
||||||
from frigate.const import CLIPS_DIR
|
from frigate.const import CLIPS_DIR, THUMB_DIR
|
||||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||||
from frigate.util.image import draw_box_with_label
|
from frigate.util.image import draw_box_with_label
|
||||||
|
|
||||||
@ -55,9 +54,7 @@ class ExternalEventProcessor:
|
|||||||
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||||
event_id = f"{now}-{rand_id}"
|
event_id = f"{now}-{rand_id}"
|
||||||
|
|
||||||
thumbnail = self._write_images(
|
self._write_images(camera_config, label, event_id, draw, snapshot_frame)
|
||||||
camera_config, label, event_id, draw, snapshot_frame
|
|
||||||
)
|
|
||||||
end = now + duration if duration is not None else None
|
end = now + duration if duration is not None else None
|
||||||
|
|
||||||
self.event_sender.publish(
|
self.event_sender.publish(
|
||||||
@ -74,7 +71,6 @@ class ExternalEventProcessor:
|
|||||||
"camera": camera,
|
"camera": camera,
|
||||||
"start_time": now - camera_config.record.event_pre_capture,
|
"start_time": now - camera_config.record.event_pre_capture,
|
||||||
"end_time": end,
|
"end_time": end,
|
||||||
"thumbnail": thumbnail,
|
|
||||||
"has_clip": camera_config.record.enabled and include_recording,
|
"has_clip": camera_config.record.enabled and include_recording,
|
||||||
"has_snapshot": True,
|
"has_snapshot": True,
|
||||||
"type": source_type,
|
"type": source_type,
|
||||||
@ -134,9 +130,9 @@ class ExternalEventProcessor:
|
|||||||
event_id: str,
|
event_id: str,
|
||||||
draw: dict[str, any],
|
draw: dict[str, any],
|
||||||
img_frame: Optional[ndarray],
|
img_frame: Optional[ndarray],
|
||||||
) -> Optional[str]:
|
) -> None:
|
||||||
if img_frame is None:
|
if img_frame is None:
|
||||||
return None
|
return
|
||||||
|
|
||||||
# write clean snapshot if enabled
|
# write clean snapshot if enabled
|
||||||
if camera_config.snapshots.clean_copy:
|
if camera_config.snapshots.clean_copy:
|
||||||
@ -182,8 +178,9 @@ class ExternalEventProcessor:
|
|||||||
# create thumbnail with max height of 175 and save
|
# create thumbnail with max height of 175 and save
|
||||||
width = int(175 * img_frame.shape[1] / img_frame.shape[0])
|
width = int(175 * img_frame.shape[1] / img_frame.shape[0])
|
||||||
thumb = cv2.resize(img_frame, dsize=(width, 175), interpolation=cv2.INTER_AREA)
|
thumb = cv2.resize(img_frame, dsize=(width, 175), interpolation=cv2.INTER_AREA)
|
||||||
ret, jpg = cv2.imencode(".jpg", thumb)
|
cv2.imwrite(
|
||||||
return base64.b64encode(jpg.tobytes()).decode("utf-8")
|
os.path.join(THUMB_DIR, camera_config.name, f"{event_id}.webp"), thumb
|
||||||
|
)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self.event_sender.stop()
|
self.event_sender.stop()
|
||||||
|
|||||||
@ -23,11 +23,11 @@ def should_update_db(prev_event: Event, current_event: Event) -> bool:
|
|||||||
if (
|
if (
|
||||||
prev_event["top_score"] != current_event["top_score"]
|
prev_event["top_score"] != current_event["top_score"]
|
||||||
or prev_event["entered_zones"] != current_event["entered_zones"]
|
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["end_time"] != current_event["end_time"]
|
||||||
or prev_event["average_estimated_speed"]
|
or prev_event["average_estimated_speed"]
|
||||||
!= current_event["average_estimated_speed"]
|
!= current_event["average_estimated_speed"]
|
||||||
or prev_event["velocity_angle"] != current_event["velocity_angle"]
|
or prev_event["velocity_angle"] != current_event["velocity_angle"]
|
||||||
|
or prev_event["path_data"] != current_event["path_data"]
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@ -201,7 +201,7 @@ class EventProcessor(threading.Thread):
|
|||||||
Event.start_time: start_time,
|
Event.start_time: start_time,
|
||||||
Event.end_time: end_time,
|
Event.end_time: end_time,
|
||||||
Event.zones: list(event_data["entered_zones"]),
|
Event.zones: list(event_data["entered_zones"]),
|
||||||
Event.thumbnail: event_data["thumbnail"],
|
Event.thumbnail: event_data.get("thumbnail"),
|
||||||
Event.has_clip: event_data["has_clip"],
|
Event.has_clip: event_data["has_clip"],
|
||||||
Event.has_snapshot: event_data["has_snapshot"],
|
Event.has_snapshot: event_data["has_snapshot"],
|
||||||
Event.model_hash: first_detector.model.model_hash,
|
Event.model_hash: first_detector.model.model_hash,
|
||||||
@ -217,6 +217,7 @@ class EventProcessor(threading.Thread):
|
|||||||
"velocity_angle": event_data["velocity_angle"],
|
"velocity_angle": event_data["velocity_angle"],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"max_severity": event_data.get("max_severity"),
|
"max_severity": event_data.get("max_severity"),
|
||||||
|
"path_data": event_data.get("path_data"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,7 +257,7 @@ class EventProcessor(threading.Thread):
|
|||||||
Event.camera: event_data["camera"],
|
Event.camera: event_data["camera"],
|
||||||
Event.start_time: event_data["start_time"],
|
Event.start_time: event_data["start_time"],
|
||||||
Event.end_time: event_data["end_time"],
|
Event.end_time: event_data["end_time"],
|
||||||
Event.thumbnail: event_data["thumbnail"],
|
Event.thumbnail: event_data.get("thumbnail"),
|
||||||
Event.has_clip: event_data["has_clip"],
|
Event.has_clip: event_data["has_clip"],
|
||||||
Event.has_snapshot: event_data["has_snapshot"],
|
Event.has_snapshot: event_data["has_snapshot"],
|
||||||
Event.zones: [],
|
Event.zones: [],
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@ -16,13 +15,13 @@ from frigate.comms.dispatcher import Dispatcher
|
|||||||
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
|
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import (
|
from frigate.config import (
|
||||||
|
CameraMqttConfig,
|
||||||
FrigateConfig,
|
FrigateConfig,
|
||||||
MqttConfig,
|
|
||||||
RecordConfig,
|
RecordConfig,
|
||||||
SnapshotsConfig,
|
SnapshotsConfig,
|
||||||
ZoomingModeEnum,
|
ZoomingModeEnum,
|
||||||
)
|
)
|
||||||
from frigate.const import CLIPS_DIR, UPDATE_CAMERA_ACTIVITY
|
from frigate.const import UPDATE_CAMERA_ACTIVITY
|
||||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||||
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||||
from frigate.track.tracked_object import TrackedObject
|
from frigate.track.tracked_object import TrackedObject
|
||||||
@ -413,6 +412,11 @@ class CameraState:
|
|||||||
|
|
||||||
self.previous_frame_id = frame_name
|
self.previous_frame_id = frame_name
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
for obj in self.tracked_objects.values():
|
||||||
|
if not obj.obj_data.get("end_time"):
|
||||||
|
obj.write_thumbnail_to_disk()
|
||||||
|
|
||||||
|
|
||||||
class TrackedObjectProcessor(threading.Thread):
|
class TrackedObjectProcessor(threading.Thread):
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -479,7 +483,7 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
EventStateEnum.update,
|
EventStateEnum.update,
|
||||||
camera,
|
camera,
|
||||||
frame_name,
|
frame_name,
|
||||||
obj.to_dict(include_thumbnail=True),
|
obj.to_dict(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -491,41 +495,13 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
obj.has_snapshot = self.should_save_snapshot(camera, obj)
|
obj.has_snapshot = self.should_save_snapshot(camera, obj)
|
||||||
obj.has_clip = self.should_retain_recording(camera, obj)
|
obj.has_clip = self.should_retain_recording(camera, obj)
|
||||||
|
|
||||||
|
# write thumbnail to disk if it will be saved as an event
|
||||||
|
if obj.has_snapshot or obj.has_clip:
|
||||||
|
obj.write_thumbnail_to_disk()
|
||||||
|
|
||||||
# write the snapshot to disk
|
# write the snapshot to disk
|
||||||
if obj.has_snapshot:
|
if obj.has_snapshot:
|
||||||
snapshot_config: SnapshotsConfig = self.config.cameras[camera].snapshots
|
obj.write_snapshot_to_disk()
|
||||||
jpg_bytes = obj.get_jpg_bytes(
|
|
||||||
timestamp=snapshot_config.timestamp,
|
|
||||||
bounding_box=snapshot_config.bounding_box,
|
|
||||||
crop=snapshot_config.crop,
|
|
||||||
height=snapshot_config.height,
|
|
||||||
quality=snapshot_config.quality,
|
|
||||||
)
|
|
||||||
if jpg_bytes is None:
|
|
||||||
logger.warning(f"Unable to save snapshot for {obj.obj_data['id']}.")
|
|
||||||
else:
|
|
||||||
with open(
|
|
||||||
os.path.join(CLIPS_DIR, f"{camera}-{obj.obj_data['id']}.jpg"),
|
|
||||||
"wb",
|
|
||||||
) as j:
|
|
||||||
j.write(jpg_bytes)
|
|
||||||
|
|
||||||
# write clean snapshot if enabled
|
|
||||||
if snapshot_config.clean_copy:
|
|
||||||
png_bytes = obj.get_clean_png()
|
|
||||||
if png_bytes is None:
|
|
||||||
logger.warning(
|
|
||||||
f"Unable to save clean snapshot for {obj.obj_data['id']}."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
with open(
|
|
||||||
os.path.join(
|
|
||||||
CLIPS_DIR,
|
|
||||||
f"{camera}-{obj.obj_data['id']}-clean.png",
|
|
||||||
),
|
|
||||||
"wb",
|
|
||||||
) as p:
|
|
||||||
p.write(png_bytes)
|
|
||||||
|
|
||||||
if not obj.false_positive:
|
if not obj.false_positive:
|
||||||
message = {
|
message = {
|
||||||
@ -542,14 +518,15 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
EventStateEnum.end,
|
EventStateEnum.end,
|
||||||
camera,
|
camera,
|
||||||
frame_name,
|
frame_name,
|
||||||
obj.to_dict(include_thumbnail=True),
|
obj.to_dict(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def snapshot(camera, obj: TrackedObject, frame_name: str):
|
def snapshot(camera, obj: TrackedObject, frame_name: str):
|
||||||
mqtt_config: MqttConfig = self.config.cameras[camera].mqtt
|
mqtt_config: CameraMqttConfig = self.config.cameras[camera].mqtt
|
||||||
if mqtt_config.enabled and self.should_mqtt_snapshot(camera, obj):
|
if mqtt_config.enabled and self.should_mqtt_snapshot(camera, obj):
|
||||||
jpg_bytes = obj.get_jpg_bytes(
|
jpg_bytes = obj.get_img_bytes(
|
||||||
|
ext="jpg",
|
||||||
timestamp=mqtt_config.timestamp,
|
timestamp=mqtt_config.timestamp,
|
||||||
bounding_box=mqtt_config.bounding_box,
|
bounding_box=mqtt_config.bounding_box,
|
||||||
crop=mqtt_config.crop,
|
crop=mqtt_config.crop,
|
||||||
@ -750,6 +727,10 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
event_id, camera, _ = update
|
event_id, camera, _ = update
|
||||||
self.camera_states[camera].finished(event_id)
|
self.camera_states[camera].finished(event_id)
|
||||||
|
|
||||||
|
# shut down camera states
|
||||||
|
for state in self.camera_states.values():
|
||||||
|
state.shutdown()
|
||||||
|
|
||||||
self.requestor.stop()
|
self.requestor.stop()
|
||||||
self.detection_publisher.stop()
|
self.detection_publisher.stop()
|
||||||
self.event_sender.stop()
|
self.event_sender.stop()
|
||||||
|
|||||||
@ -80,8 +80,8 @@ class RecordingExporter(threading.Thread):
|
|||||||
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
|
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
|
||||||
|
|
||||||
def get_datetime_from_timestamp(self, timestamp: int) -> str:
|
def get_datetime_from_timestamp(self, timestamp: int) -> str:
|
||||||
"""Convenience fun to get a simple date time from timestamp."""
|
# return in iso format
|
||||||
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y/%m/%d %H:%M")
|
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
def save_thumbnail(self, id: str) -> str:
|
def save_thumbnail(self, id: str) -> str:
|
||||||
thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp")
|
thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp")
|
||||||
@ -236,6 +236,10 @@ class RecordingExporter(threading.Thread):
|
|||||||
if self.config.ffmpeg.apple_compatibility:
|
if self.config.ffmpeg.apple_compatibility:
|
||||||
ffmpeg_cmd += FFMPEG_HVC1_ARGS
|
ffmpeg_cmd += FFMPEG_HVC1_ARGS
|
||||||
|
|
||||||
|
# add metadata
|
||||||
|
title = f"Frigate Recording for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}"
|
||||||
|
ffmpeg_cmd.extend(["-metadata", f"title={title}"])
|
||||||
|
|
||||||
ffmpeg_cmd.append(video_path)
|
ffmpeg_cmd.append(video_path)
|
||||||
|
|
||||||
return ffmpeg_cmd, playlist_lines
|
return ffmpeg_cmd, playlist_lines
|
||||||
@ -323,6 +327,10 @@ class RecordingExporter(threading.Thread):
|
|||||||
)
|
)
|
||||||
).split(" ")
|
).split(" ")
|
||||||
|
|
||||||
|
# add metadata
|
||||||
|
title = f"Frigate Preview for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}"
|
||||||
|
ffmpeg_cmd.extend(["-metadata", f"title={title}"])
|
||||||
|
|
||||||
return ffmpeg_cmd, playlist_lines
|
return ffmpeg_cmd, playlist_lines
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
|
|||||||
@ -1,207 +1,495 @@
|
|||||||
from typing import Dict
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from prometheus_client import (
|
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
||||||
CONTENT_TYPE_LATEST,
|
from prometheus_client.core import (
|
||||||
Counter,
|
REGISTRY,
|
||||||
Gauge,
|
CounterMetricFamily,
|
||||||
Info,
|
GaugeMetricFamily,
|
||||||
generate_latest,
|
InfoMetricFamily,
|
||||||
)
|
|
||||||
|
|
||||||
# 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:
|
class CustomCollector(object):
|
||||||
"""Update Prometheus metrics based on Frigate stats"""
|
def __init__(self, _url):
|
||||||
try:
|
self.process_stats = {}
|
||||||
# Update process metrics
|
self.previous_event_id = None
|
||||||
if "cpu_usages" in stats:
|
self.previous_event_start_time = None
|
||||||
for pid, proc_stats in stats["cpu_usages"].items():
|
self.all_events = {}
|
||||||
cmdline = proc_stats.get("cmdline", "")
|
|
||||||
process_type = "Other"
|
|
||||||
process_name = cmdline
|
|
||||||
|
|
||||||
CPU_USAGE.labels(
|
def add_metric(self, metric, label, stats, key, multiplier=1.0): # Now a method
|
||||||
pid=pid,
|
try:
|
||||||
name=process_name,
|
string = str(stats[key])
|
||||||
process=process_name,
|
value = float(re.findall(r"-?\d*\.?\d*", string)[0])
|
||||||
type=process_type,
|
metric.add_metric(label, value * multiplier)
|
||||||
cmdline=cmdline,
|
except (KeyError, TypeError, IndexError, ValueError):
|
||||||
).set(float(proc_stats["cpu"]))
|
pass
|
||||||
|
|
||||||
MEMORY_USAGE.labels(
|
def add_metric_process(
|
||||||
pid=pid,
|
self,
|
||||||
name=process_name,
|
metric,
|
||||||
process=process_name,
|
camera_stats,
|
||||||
type=process_type,
|
camera_name,
|
||||||
cmdline=cmdline,
|
pid_name,
|
||||||
).set(float(proc_stats["mem"]))
|
process_name,
|
||||||
|
cpu_or_memory,
|
||||||
|
process_type,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
pid = str(camera_stats[pid_name])
|
||||||
|
label_values = [pid, camera_name, process_name, process_type]
|
||||||
|
try:
|
||||||
|
# new frigate:0.13.0-beta3 stat 'cmdline'
|
||||||
|
label_values.append(self.process_stats[pid]["cmdline"])
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
metric.add_metric(label_values, self.process_stats[pid][cpu_or_memory])
|
||||||
|
del self.process_stats[pid][cpu_or_memory]
|
||||||
|
except (KeyError, TypeError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
# Update camera metrics
|
def collect(self):
|
||||||
if "cameras" in stats:
|
stats = self.process_stats # Assign self.process_stats to local variable 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
|
try:
|
||||||
if "detectors" in stats:
|
self.process_stats = stats["cpu_usages"]
|
||||||
for name, detector in stats["detectors"].items():
|
except KeyError:
|
||||||
if "inference_speed" in detector:
|
pass
|
||||||
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
|
# process stats for cameras, detectors and other
|
||||||
if "gpu_usages" in stats:
|
cpu_usages = GaugeMetricFamily(
|
||||||
for gpu_name, gpu_stats in stats["gpu_usages"].items():
|
"frigate_cpu_usage_percent",
|
||||||
if "gpu" in gpu_stats:
|
"Process CPU usage %",
|
||||||
GPU_USAGE.labels(gpu_name=gpu_name).set(float(gpu_stats["gpu"]))
|
labels=["pid", "name", "process", "type", "cmdline"],
|
||||||
if "mem" in gpu_stats:
|
)
|
||||||
GPU_MEMORY.labels(gpu_name=gpu_name).set(float(gpu_stats["mem"]))
|
mem_usages = GaugeMetricFamily(
|
||||||
|
"frigate_mem_usage_percent",
|
||||||
|
"Process memory usage %",
|
||||||
|
labels=["pid", "name", "process", "type", "cmdline"],
|
||||||
|
)
|
||||||
|
|
||||||
# Update service metrics
|
# camera stats
|
||||||
if "service" in stats:
|
audio_dBFS = GaugeMetricFamily(
|
||||||
service = stats["service"]
|
"frigate_audio_dBFS", "Audio dBFS for camera", labels=["camera_name"]
|
||||||
|
)
|
||||||
|
audio_rms = GaugeMetricFamily(
|
||||||
|
"frigate_audio_rms", "Audio RMS for camera", labels=["camera_name"]
|
||||||
|
)
|
||||||
|
camera_fps = GaugeMetricFamily(
|
||||||
|
"frigate_camera_fps",
|
||||||
|
"Frames per second being consumed from your camera.",
|
||||||
|
labels=["camera_name"],
|
||||||
|
)
|
||||||
|
detection_enabled = GaugeMetricFamily(
|
||||||
|
"frigate_detection_enabled",
|
||||||
|
"Detection enabled for camera",
|
||||||
|
labels=["camera_name"],
|
||||||
|
)
|
||||||
|
detection_fps = GaugeMetricFamily(
|
||||||
|
"frigate_detection_fps",
|
||||||
|
"Number of times detection is run per second.",
|
||||||
|
labels=["camera_name"],
|
||||||
|
)
|
||||||
|
process_fps = GaugeMetricFamily(
|
||||||
|
"frigate_process_fps",
|
||||||
|
"Frames per second being processed by frigate.",
|
||||||
|
labels=["camera_name"],
|
||||||
|
)
|
||||||
|
skipped_fps = GaugeMetricFamily(
|
||||||
|
"frigate_skipped_fps",
|
||||||
|
"Frames per second skip for processing by frigate.",
|
||||||
|
labels=["camera_name"],
|
||||||
|
)
|
||||||
|
|
||||||
if "uptime" in service:
|
# read camera stats assuming version < frigate:0.13.0-beta3
|
||||||
UPTIME.set(service["uptime"])
|
cameras = stats
|
||||||
if "last_updated" in service:
|
try:
|
||||||
LAST_UPDATE.set(service["last_updated"])
|
# try to read camera stats in case >= frigate:0.13.0-beta3
|
||||||
|
cameras = stats["cameras"]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Storage metrics
|
for camera_name, camera_stats in cameras.items():
|
||||||
if "storage" in service:
|
self.add_metric(audio_dBFS, [camera_name], camera_stats, "audio_dBFS")
|
||||||
for path, storage in service["storage"].items():
|
self.add_metric(audio_rms, [camera_name], camera_stats, "audio_rms")
|
||||||
if "free" in storage:
|
self.add_metric(camera_fps, [camera_name], camera_stats, "camera_fps")
|
||||||
STORAGE_FREE.labels(storage=path).set(
|
self.add_metric(
|
||||||
storage["free"] * 1e6
|
detection_enabled, [camera_name], camera_stats, "detection_enabled"
|
||||||
) # MB to bytes
|
)
|
||||||
if "total" in storage:
|
self.add_metric(detection_fps, [camera_name], camera_stats, "detection_fps")
|
||||||
STORAGE_TOTAL.labels(storage=path).set(storage["total"] * 1e6)
|
self.add_metric(process_fps, [camera_name], camera_stats, "process_fps")
|
||||||
if "used" in storage:
|
self.add_metric(skipped_fps, [camera_name], camera_stats, "skipped_fps")
|
||||||
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
|
self.add_metric_process(
|
||||||
if "temperatures" in service:
|
cpu_usages,
|
||||||
for device, temp in service["temperatures"].items():
|
camera_stats,
|
||||||
TEMPERATURE.labels(device=device).set(temp)
|
camera_name,
|
||||||
|
"ffmpeg_pid",
|
||||||
|
"ffmpeg",
|
||||||
|
"cpu",
|
||||||
|
"Camera",
|
||||||
|
)
|
||||||
|
self.add_metric_process(
|
||||||
|
cpu_usages,
|
||||||
|
camera_stats,
|
||||||
|
camera_name,
|
||||||
|
"capture_pid",
|
||||||
|
"capture",
|
||||||
|
"cpu",
|
||||||
|
"Camera",
|
||||||
|
)
|
||||||
|
self.add_metric_process(
|
||||||
|
cpu_usages, camera_stats, camera_name, "pid", "detect", "cpu", "Camera"
|
||||||
|
)
|
||||||
|
|
||||||
# Version info
|
self.add_metric_process(
|
||||||
if "version" in service and "latest_version" in service:
|
mem_usages,
|
||||||
SYSTEM_INFO.info(
|
camera_stats,
|
||||||
{
|
camera_name,
|
||||||
"version": service["version"],
|
"ffmpeg_pid",
|
||||||
"latest_version": service["latest_version"],
|
"ffmpeg",
|
||||||
}
|
"mem",
|
||||||
|
"Camera",
|
||||||
|
)
|
||||||
|
self.add_metric_process(
|
||||||
|
mem_usages,
|
||||||
|
camera_stats,
|
||||||
|
camera_name,
|
||||||
|
"capture_pid",
|
||||||
|
"capture",
|
||||||
|
"mem",
|
||||||
|
"Camera",
|
||||||
|
)
|
||||||
|
self.add_metric_process(
|
||||||
|
mem_usages, camera_stats, camera_name, "pid", "detect", "mem", "Camera"
|
||||||
|
)
|
||||||
|
|
||||||
|
yield audio_dBFS
|
||||||
|
yield audio_rms
|
||||||
|
yield camera_fps
|
||||||
|
yield detection_enabled
|
||||||
|
yield detection_fps
|
||||||
|
yield process_fps
|
||||||
|
yield skipped_fps
|
||||||
|
|
||||||
|
# bandwidth stats
|
||||||
|
bandwidth_usages = GaugeMetricFamily(
|
||||||
|
"frigate_bandwidth_usages_kBps",
|
||||||
|
"bandwidth usages kilobytes per second",
|
||||||
|
labels=["pid", "name", "process", "cmdline"],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for b_pid, b_stats in stats["bandwidth_usages"].items():
|
||||||
|
label = [b_pid] # pid label
|
||||||
|
try:
|
||||||
|
n = stats["cpu_usages"][b_pid]["cmdline"]
|
||||||
|
for p_name, p_stats in stats["processes"].items():
|
||||||
|
if str(p_stats["pid"]) == b_pid:
|
||||||
|
n = p_name
|
||||||
|
break
|
||||||
|
|
||||||
|
# new frigate:0.13.0-beta3 stat 'cmdline'
|
||||||
|
label.append(n) # name label
|
||||||
|
label.append(stats["cpu_usages"][b_pid]["cmdline"]) # process label
|
||||||
|
label.append(stats["cpu_usages"][b_pid]["cmdline"]) # cmdline label
|
||||||
|
self.add_metric(bandwidth_usages, label, b_stats, "bandwidth")
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
yield bandwidth_usages
|
||||||
|
|
||||||
|
# detector stats
|
||||||
|
try:
|
||||||
|
yield GaugeMetricFamily(
|
||||||
|
"frigate_detection_total_fps",
|
||||||
|
"Sum of detection_fps across all cameras and detectors.",
|
||||||
|
value=stats["detection_fps"],
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
detector_inference_speed = GaugeMetricFamily(
|
||||||
|
"frigate_detector_inference_speed_seconds",
|
||||||
|
"Time spent running object detection in seconds.",
|
||||||
|
labels=["name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
detector_detection_start = GaugeMetricFamily(
|
||||||
|
"frigate_detection_start",
|
||||||
|
"Detector start time (unix timestamp)",
|
||||||
|
labels=["name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for detector_name, detector_stats in stats["detectors"].items():
|
||||||
|
self.add_metric(
|
||||||
|
detector_inference_speed,
|
||||||
|
[detector_name],
|
||||||
|
detector_stats,
|
||||||
|
"inference_speed",
|
||||||
|
0.001,
|
||||||
|
) # ms to seconds
|
||||||
|
self.add_metric(
|
||||||
|
detector_detection_start,
|
||||||
|
[detector_name],
|
||||||
|
detector_stats,
|
||||||
|
"detection_start",
|
||||||
)
|
)
|
||||||
|
self.add_metric_process(
|
||||||
|
cpu_usages,
|
||||||
|
stats["detectors"],
|
||||||
|
detector_name,
|
||||||
|
"pid",
|
||||||
|
"detect",
|
||||||
|
"cpu",
|
||||||
|
"Detector",
|
||||||
|
)
|
||||||
|
self.add_metric_process(
|
||||||
|
mem_usages,
|
||||||
|
stats["detectors"],
|
||||||
|
detector_name,
|
||||||
|
"pid",
|
||||||
|
"detect",
|
||||||
|
"mem",
|
||||||
|
"Detector",
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
yield detector_inference_speed
|
||||||
|
yield detector_detection_start
|
||||||
|
|
||||||
|
# detector process stats
|
||||||
|
try:
|
||||||
|
for detector_name, detector_stats in stats["detectors"].items():
|
||||||
|
p_pid = str(detector_stats["pid"])
|
||||||
|
label = [p_pid] # pid label
|
||||||
|
try:
|
||||||
|
# new frigate:0.13.0-beta3 stat 'cmdline'
|
||||||
|
label.append(detector_name) # name label
|
||||||
|
label.append(detector_name) # process label
|
||||||
|
label.append("detectors") # type label
|
||||||
|
label.append(self.process_stats[p_pid]["cmdline"]) # cmdline label
|
||||||
|
self.add_metric(cpu_usages, label, self.process_stats[p_pid], "cpu")
|
||||||
|
self.add_metric(mem_usages, label, self.process_stats[p_pid], "mem")
|
||||||
|
del self.process_stats[p_pid]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# other named process stats
|
||||||
|
try:
|
||||||
|
for process_name, process_stats in stats["processes"].items():
|
||||||
|
p_pid = str(process_stats["pid"])
|
||||||
|
label = [p_pid] # pid label
|
||||||
|
try:
|
||||||
|
# new frigate:0.13.0-beta3 stat 'cmdline'
|
||||||
|
label.append(process_name) # name label
|
||||||
|
label.append(process_name) # process label
|
||||||
|
label.append(process_name) # type label
|
||||||
|
label.append(self.process_stats[p_pid]["cmdline"]) # cmdline label
|
||||||
|
self.add_metric(cpu_usages, label, self.process_stats[p_pid], "cpu")
|
||||||
|
self.add_metric(mem_usages, label, self.process_stats[p_pid], "mem")
|
||||||
|
del self.process_stats[p_pid]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# remaining process stats
|
||||||
|
try:
|
||||||
|
for process_id, pid_stats in self.process_stats.items():
|
||||||
|
label = [process_id] # pid label
|
||||||
|
try:
|
||||||
|
# new frigate:0.13.0-beta3 stat 'cmdline'
|
||||||
|
label.append(pid_stats["cmdline"]) # name label
|
||||||
|
label.append(pid_stats["cmdline"]) # process label
|
||||||
|
label.append("Other") # type label
|
||||||
|
label.append(pid_stats["cmdline"]) # cmdline label
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
self.add_metric(cpu_usages, label, pid_stats, "cpu")
|
||||||
|
self.add_metric(mem_usages, label, pid_stats, "mem")
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
yield cpu_usages
|
||||||
|
yield mem_usages
|
||||||
|
|
||||||
|
# gpu stats
|
||||||
|
gpu_usages = GaugeMetricFamily(
|
||||||
|
"frigate_gpu_usage_percent", "GPU utilisation %", labels=["gpu_name"]
|
||||||
|
)
|
||||||
|
gpu_mem_usages = GaugeMetricFamily(
|
||||||
|
"frigate_gpu_mem_usage_percent", "GPU memory usage %", labels=["gpu_name"]
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for gpu_name, gpu_stats in stats["gpu_usages"].items():
|
||||||
|
self.add_metric(gpu_usages, [gpu_name], gpu_stats, "gpu")
|
||||||
|
self.add_metric(gpu_mem_usages, [gpu_name], gpu_stats, "mem")
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
yield gpu_usages
|
||||||
|
yield gpu_mem_usages
|
||||||
|
|
||||||
|
# service stats
|
||||||
|
uptime_seconds = GaugeMetricFamily(
|
||||||
|
"frigate_service_uptime_seconds", "Uptime seconds"
|
||||||
|
)
|
||||||
|
last_updated_timestamp = GaugeMetricFamily(
|
||||||
|
"frigate_service_last_updated_timestamp",
|
||||||
|
"Stats recorded time (unix timestamp)",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service_stats = stats["service"]
|
||||||
|
self.add_metric(uptime_seconds, [""], service_stats, "uptime")
|
||||||
|
self.add_metric(last_updated_timestamp, [""], service_stats, "last_updated")
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"latest_version": stats["service"]["latest_version"],
|
||||||
|
"version": stats["service"]["version"],
|
||||||
|
}
|
||||||
|
yield InfoMetricFamily(
|
||||||
|
"frigate_service", "Frigate version info", value=info
|
||||||
|
)
|
||||||
|
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
yield uptime_seconds
|
||||||
|
yield last_updated_timestamp
|
||||||
|
|
||||||
|
temperatures = GaugeMetricFamily(
|
||||||
|
"frigate_device_temperature", "Device Temperature", labels=["device"]
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
for device_name in stats["service"]["temperatures"]:
|
||||||
|
self.add_metric(
|
||||||
|
temperatures,
|
||||||
|
[device_name],
|
||||||
|
stats["service"]["temperatures"],
|
||||||
|
device_name,
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
yield temperatures
|
||||||
|
|
||||||
|
storage_free = GaugeMetricFamily(
|
||||||
|
"frigate_storage_free_bytes", "Storage free bytes", labels=["storage"]
|
||||||
|
)
|
||||||
|
storage_mount_type = InfoMetricFamily(
|
||||||
|
"frigate_storage_mount_type",
|
||||||
|
"Storage mount type",
|
||||||
|
labels=["mount_type", "storage"],
|
||||||
|
)
|
||||||
|
storage_total = GaugeMetricFamily(
|
||||||
|
"frigate_storage_total_bytes", "Storage total bytes", labels=["storage"]
|
||||||
|
)
|
||||||
|
storage_used = GaugeMetricFamily(
|
||||||
|
"frigate_storage_used_bytes", "Storage used bytes", labels=["storage"]
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for storage_path, storage_stats in stats["service"]["storage"].items():
|
||||||
|
self.add_metric(
|
||||||
|
storage_free, [storage_path], storage_stats, "free", 1e6
|
||||||
|
) # MB to bytes
|
||||||
|
self.add_metric(
|
||||||
|
storage_total, [storage_path], storage_stats, "total", 1e6
|
||||||
|
) # MB to bytes
|
||||||
|
self.add_metric(
|
||||||
|
storage_used, [storage_path], storage_stats, "used", 1e6
|
||||||
|
) # MB to bytes
|
||||||
|
storage_mount_type.add_metric(
|
||||||
|
storage_path,
|
||||||
|
{
|
||||||
|
"mount_type": storage_stats["mount_type"],
|
||||||
|
"storage": storage_path,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
yield storage_free
|
||||||
|
yield storage_mount_type
|
||||||
|
yield storage_total
|
||||||
|
yield storage_used
|
||||||
|
|
||||||
|
# count events
|
||||||
|
events = []
|
||||||
|
|
||||||
|
if len(events) > 0:
|
||||||
|
# events[0] is newest event, last element is oldest, don't need to sort
|
||||||
|
|
||||||
|
if not self.previous_event_id:
|
||||||
|
# ignore all previous events on startup, prometheus might have already counted them
|
||||||
|
self.previous_event_id = events[0]["id"]
|
||||||
|
self.previous_event_start_time = int(events[0]["start_time"])
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
# break if event already counted
|
||||||
|
if event["id"] == self.previous_event_id:
|
||||||
|
break
|
||||||
|
|
||||||
|
# break if event starts before previous event
|
||||||
|
if event["start_time"] < self.previous_event_start_time:
|
||||||
|
break
|
||||||
|
|
||||||
|
# store counted events in a dict
|
||||||
|
try:
|
||||||
|
cam = self.all_events[event["camera"]]
|
||||||
|
try:
|
||||||
|
cam[event["label"]] += 1
|
||||||
|
except KeyError:
|
||||||
|
# create label dict if not exists
|
||||||
|
cam.update({event["label"]: 1})
|
||||||
|
except KeyError:
|
||||||
|
# create camera and label dict if not exists
|
||||||
|
self.all_events.update({event["camera"]: {event["label"]: 1}})
|
||||||
|
|
||||||
|
# don't recount events next time
|
||||||
|
self.previous_event_id = events[0]["id"]
|
||||||
|
self.previous_event_start_time = int(events[0]["start_time"])
|
||||||
|
|
||||||
|
camera_events = CounterMetricFamily(
|
||||||
|
"frigate_camera_events",
|
||||||
|
"Count of camera events since exporter started",
|
||||||
|
labels=["camera", "label"],
|
||||||
|
)
|
||||||
|
|
||||||
|
for camera, cam_dict in self.all_events.items():
|
||||||
|
for label, label_value in cam_dict.items():
|
||||||
|
camera_events.add_metric([camera, label], label_value)
|
||||||
|
|
||||||
|
yield camera_events
|
||||||
|
|
||||||
|
|
||||||
|
collector = CustomCollector(None)
|
||||||
|
REGISTRY.register(collector)
|
||||||
|
|
||||||
|
|
||||||
|
def update_metrics(stats):
|
||||||
|
"""Updates the Prometheus metrics with the given stats data."""
|
||||||
|
try:
|
||||||
|
collector.process_stats = stats # Directly assign the stats data
|
||||||
|
# Important: Since we are not fetching from URL, we need to manually call collect
|
||||||
|
for _ in collector.collect():
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error updating Prometheus metrics: {str(e)}")
|
logging.error(f"Error updating metrics: {e}")
|
||||||
|
|
||||||
|
|
||||||
def get_metrics() -> tuple[str, str]:
|
def get_metrics():
|
||||||
"""Get Prometheus metrics in text format"""
|
"""Returns the Prometheus metrics in text format."""
|
||||||
return generate_latest(), CONTENT_TYPE_LATEST
|
content = generate_latest(REGISTRY) # Use generate_latest
|
||||||
|
return content, CONTENT_TYPE_LATEST
|
||||||
|
|||||||
@ -263,12 +263,13 @@ class NorfairTracker(ObjectTracker):
|
|||||||
|
|
||||||
# Get the correct tracker for this object's label
|
# Get the correct tracker for this object's label
|
||||||
tracker = self.get_tracker(obj["label"])
|
tracker = self.get_tracker(obj["label"])
|
||||||
obj["score_history"] = [
|
obj_match = next(
|
||||||
p.data["score"]
|
(o for o in tracker.tracked_objects if o.global_id == track_id), None
|
||||||
for p in next(
|
)
|
||||||
(o for o in tracker.tracked_objects if o.global_id == track_id)
|
# if we don't have a match, we have a new object
|
||||||
).past_detections
|
obj["score_history"] = (
|
||||||
]
|
[p.data["score"] for p in obj_match.past_detections] if obj_match else []
|
||||||
|
)
|
||||||
self.tracked_objects[id] = obj
|
self.tracked_objects[id] = obj
|
||||||
self.disappeared[id] = 0
|
self.disappeared[id] = 0
|
||||||
self.positions[id] = {
|
self.positions[id] = {
|
||||||
@ -519,7 +520,11 @@ class NorfairTracker(ObjectTracker):
|
|||||||
default_detections.extend(dets)
|
default_detections.extend(dets)
|
||||||
|
|
||||||
# Update default tracker with untracked detections
|
# Update default tracker with untracked detections
|
||||||
mode = "ptz" if self.ptz_metrics.autotracker_enabled.value else "static"
|
mode = (
|
||||||
|
"ptz"
|
||||||
|
if self.camera_config.onvif.autotracking.enabled_in_config
|
||||||
|
else "static"
|
||||||
|
)
|
||||||
tracked_objects = self.default_tracker[mode].update(
|
tracked_objects = self.default_tracker[mode].update(
|
||||||
detections=default_detections, coord_transformations=coord_transformations
|
detections=default_detections, coord_transformations=coord_transformations
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
"""Object attribute."""
|
"""Object attribute."""
|
||||||
|
|
||||||
import base64
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
|
import os
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from statistics import median
|
from statistics import median
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@ -12,8 +13,10 @@ import numpy as np
|
|||||||
from frigate.config import (
|
from frigate.config import (
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
ModelConfig,
|
ModelConfig,
|
||||||
|
SnapshotsConfig,
|
||||||
UIConfig,
|
UIConfig,
|
||||||
)
|
)
|
||||||
|
from frigate.const import CLIPS_DIR, THUMB_DIR
|
||||||
from frigate.review.types import SeverityEnum
|
from frigate.review.types import SeverityEnum
|
||||||
from frigate.util.image import (
|
from frigate.util.image import (
|
||||||
area,
|
area,
|
||||||
@ -66,6 +69,7 @@ class TrackedObject:
|
|||||||
self.current_estimated_speed = 0
|
self.current_estimated_speed = 0
|
||||||
self.average_estimated_speed = 0
|
self.average_estimated_speed = 0
|
||||||
self.velocity_angle = 0
|
self.velocity_angle = 0
|
||||||
|
self.path_data = []
|
||||||
self.previous = self.to_dict()
|
self.previous = self.to_dict()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -148,6 +152,7 @@ class TrackedObject:
|
|||||||
"attributes": obj_data["attributes"],
|
"attributes": obj_data["attributes"],
|
||||||
"current_estimated_speed": self.current_estimated_speed,
|
"current_estimated_speed": self.current_estimated_speed,
|
||||||
"velocity_angle": self.velocity_angle,
|
"velocity_angle": self.velocity_angle,
|
||||||
|
"path_data": self.path_data,
|
||||||
}
|
}
|
||||||
thumb_update = True
|
thumb_update = True
|
||||||
|
|
||||||
@ -300,11 +305,34 @@ class TrackedObject:
|
|||||||
if self.obj_data["frame_time"] - self.previous["frame_time"] >= (1 / 3):
|
if self.obj_data["frame_time"] - self.previous["frame_time"] >= (1 / 3):
|
||||||
autotracker_update = True
|
autotracker_update = True
|
||||||
|
|
||||||
|
# update path
|
||||||
|
width = self.camera_config.detect.width
|
||||||
|
height = self.camera_config.detect.height
|
||||||
|
bottom_center = (
|
||||||
|
round(obj_data["centroid"][0] / width, 4),
|
||||||
|
round(obj_data["box"][3] / height, 4),
|
||||||
|
)
|
||||||
|
|
||||||
|
# calculate a reasonable movement threshold (e.g., 5% of the frame diagonal)
|
||||||
|
threshold = 0.05 * math.sqrt(width**2 + height**2) / max(width, height)
|
||||||
|
|
||||||
|
if not self.path_data:
|
||||||
|
self.path_data.append((bottom_center, obj_data["frame_time"]))
|
||||||
|
elif (
|
||||||
|
math.dist(self.path_data[-1][0], bottom_center) >= threshold
|
||||||
|
or len(self.path_data) == 1
|
||||||
|
):
|
||||||
|
# check Euclidean distance before appending
|
||||||
|
self.path_data.append((bottom_center, obj_data["frame_time"]))
|
||||||
|
logger.debug(
|
||||||
|
f"Point tracking: {obj_data['id']}, {bottom_center}, {obj_data['frame_time']}"
|
||||||
|
)
|
||||||
|
|
||||||
self.obj_data.update(obj_data)
|
self.obj_data.update(obj_data)
|
||||||
self.current_zones = current_zones
|
self.current_zones = current_zones
|
||||||
return (thumb_update, significant_change, autotracker_update)
|
return (thumb_update, significant_change, autotracker_update)
|
||||||
|
|
||||||
def to_dict(self, include_thumbnail: bool = False):
|
def to_dict(self):
|
||||||
event = {
|
event = {
|
||||||
"id": self.obj_data["id"],
|
"id": self.obj_data["id"],
|
||||||
"camera": self.camera_config.name,
|
"camera": self.camera_config.name,
|
||||||
@ -336,11 +364,9 @@ class TrackedObject:
|
|||||||
"current_estimated_speed": self.current_estimated_speed,
|
"current_estimated_speed": self.current_estimated_speed,
|
||||||
"average_estimated_speed": self.average_estimated_speed,
|
"average_estimated_speed": self.average_estimated_speed,
|
||||||
"velocity_angle": self.velocity_angle,
|
"velocity_angle": self.velocity_angle,
|
||||||
|
"path_data": self.path_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
if include_thumbnail:
|
|
||||||
event["thumbnail"] = base64.b64encode(self.get_thumbnail()).decode("utf-8")
|
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
def is_active(self):
|
def is_active(self):
|
||||||
@ -352,22 +378,16 @@ class TrackedObject:
|
|||||||
> self.camera_config.detect.stationary.threshold
|
> self.camera_config.detect.stationary.threshold
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_thumbnail(self):
|
def get_thumbnail(self, ext: str):
|
||||||
if (
|
img_bytes = self.get_img_bytes(
|
||||||
self.thumbnail_data is None
|
ext, timestamp=False, bounding_box=False, crop=True, height=175
|
||||||
or self.thumbnail_data["frame_time"] not in self.frame_cache
|
|
||||||
):
|
|
||||||
ret, jpg = cv2.imencode(".jpg", np.zeros((175, 175, 3), np.uint8))
|
|
||||||
|
|
||||||
jpg_bytes = self.get_jpg_bytes(
|
|
||||||
timestamp=False, bounding_box=False, crop=True, height=175
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if jpg_bytes:
|
if img_bytes:
|
||||||
return jpg_bytes
|
return img_bytes
|
||||||
else:
|
else:
|
||||||
ret, jpg = cv2.imencode(".jpg", np.zeros((175, 175, 3), np.uint8))
|
_, img = cv2.imencode(f".{ext}", np.zeros((175, 175, 3), np.uint8))
|
||||||
return jpg.tobytes()
|
return img.tobytes()
|
||||||
|
|
||||||
def get_clean_png(self):
|
def get_clean_png(self):
|
||||||
if self.thumbnail_data is None:
|
if self.thumbnail_data is None:
|
||||||
@ -390,8 +410,14 @@ class TrackedObject:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_jpg_bytes(
|
def get_img_bytes(
|
||||||
self, timestamp=False, bounding_box=False, crop=False, height=None, quality=70
|
self,
|
||||||
|
ext: str,
|
||||||
|
timestamp=False,
|
||||||
|
bounding_box=False,
|
||||||
|
crop=False,
|
||||||
|
height: int | None = None,
|
||||||
|
quality: int | None = None,
|
||||||
):
|
):
|
||||||
if self.thumbnail_data is None:
|
if self.thumbnail_data is None:
|
||||||
return None
|
return None
|
||||||
@ -476,14 +502,69 @@ class TrackedObject:
|
|||||||
position=self.camera_config.timestamp_style.position,
|
position=self.camera_config.timestamp_style.position,
|
||||||
)
|
)
|
||||||
|
|
||||||
ret, jpg = cv2.imencode(
|
quality_params = None
|
||||||
".jpg", best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), quality]
|
|
||||||
)
|
if ext == "jpg":
|
||||||
|
quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), quality or 70]
|
||||||
|
elif ext == "webp":
|
||||||
|
quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), quality or 60]
|
||||||
|
|
||||||
|
ret, jpg = cv2.imencode(f".{ext}", best_frame, quality_params)
|
||||||
|
|
||||||
if ret:
|
if ret:
|
||||||
return jpg.tobytes()
|
return jpg.tobytes()
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def write_snapshot_to_disk(self) -> None:
|
||||||
|
snapshot_config: SnapshotsConfig = self.camera_config.snapshots
|
||||||
|
jpg_bytes = self.get_img_bytes(
|
||||||
|
ext="jpg",
|
||||||
|
timestamp=snapshot_config.timestamp,
|
||||||
|
bounding_box=snapshot_config.bounding_box,
|
||||||
|
crop=snapshot_config.crop,
|
||||||
|
height=snapshot_config.height,
|
||||||
|
quality=snapshot_config.quality,
|
||||||
|
)
|
||||||
|
if jpg_bytes is None:
|
||||||
|
logger.warning(f"Unable to save snapshot for {self.obj_data['id']}.")
|
||||||
|
else:
|
||||||
|
with open(
|
||||||
|
os.path.join(
|
||||||
|
CLIPS_DIR, f"{self.camera_config.name}-{self.obj_data['id']}.jpg"
|
||||||
|
),
|
||||||
|
"wb",
|
||||||
|
) as j:
|
||||||
|
j.write(jpg_bytes)
|
||||||
|
|
||||||
|
# write clean snapshot if enabled
|
||||||
|
if snapshot_config.clean_copy:
|
||||||
|
png_bytes = self.get_clean_png()
|
||||||
|
if png_bytes is None:
|
||||||
|
logger.warning(
|
||||||
|
f"Unable to save clean snapshot for {self.obj_data['id']}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
with open(
|
||||||
|
os.path.join(
|
||||||
|
CLIPS_DIR,
|
||||||
|
f"{self.camera_config.name}-{self.obj_data['id']}-clean.png",
|
||||||
|
),
|
||||||
|
"wb",
|
||||||
|
) as p:
|
||||||
|
p.write(png_bytes)
|
||||||
|
|
||||||
|
def write_thumbnail_to_disk(self) -> None:
|
||||||
|
directory = os.path.join(THUMB_DIR, self.camera_config.name)
|
||||||
|
|
||||||
|
if not os.path.exists(directory):
|
||||||
|
os.makedirs(directory)
|
||||||
|
|
||||||
|
thumb_bytes = self.get_thumbnail("webp")
|
||||||
|
|
||||||
|
with open(os.path.join(directory, f"{self.obj_data['id']}.webp"), "wb") as f:
|
||||||
|
f.write(thumb_bytes)
|
||||||
|
|
||||||
|
|
||||||
def zone_filtered(obj: TrackedObject, object_config):
|
def zone_filtered(obj: TrackedObject, object_config):
|
||||||
object_name = obj.obj_data["label"]
|
object_name = obj.obj_data["label"]
|
||||||
|
|||||||
51
frigate/util/path.py
Normal file
51
frigate/util/path.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"""Path utilities."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from frigate.const import CLIPS_DIR, THUMB_DIR
|
||||||
|
from frigate.models import Event
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_thumbnail_bytes(event: Event) -> bytes | None:
|
||||||
|
if event.thumbnail:
|
||||||
|
return base64.b64decode(event.thumbnail)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with open(
|
||||||
|
os.path.join(THUMB_DIR, event.camera, f"{event.id}.webp"), "rb"
|
||||||
|
) as f:
|
||||||
|
return f.read()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
### Deletion
|
||||||
|
|
||||||
|
|
||||||
|
def delete_event_images(event: Event) -> bool:
|
||||||
|
return delete_event_snapshot(event) and delete_event_thumbnail(event)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_event_snapshot(event: Event) -> bool:
|
||||||
|
media_name = f"{event.camera}-{event.id}"
|
||||||
|
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
||||||
|
|
||||||
|
try:
|
||||||
|
media_path.unlink(missing_ok=True)
|
||||||
|
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
|
||||||
|
media_path.unlink(missing_ok=True)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def delete_event_thumbnail(event: Event) -> bool:
|
||||||
|
if event.thumbnail:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
Path(os.path.join(THUMB_DIR, event.camera, f"{event.id}.webp")).unlink(
|
||||||
|
missing_ok=True
|
||||||
|
)
|
||||||
|
return True
|
||||||
36
migrations/028_optional_event_thumbnail.py
Normal file
36
migrations/028_optional_event_thumbnail.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""Peewee migrations -- 028_optional_event_thumbnail.py.
|
||||||
|
|
||||||
|
Some examples (model - class or model name)::
|
||||||
|
|
||||||
|
> Model = migrator.orm['model_name'] # Return model in current state by name
|
||||||
|
|
||||||
|
> migrator.sql(sql) # Run custom SQL
|
||||||
|
> migrator.python(func, *args, **kwargs) # Run python code
|
||||||
|
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||||
|
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||||
|
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||||
|
> migrator.change_fields(model, **fields) # Change fields
|
||||||
|
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||||
|
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||||
|
> migrator.rename_table(model, new_table_name)
|
||||||
|
> migrator.add_index(model, *col_names, unique=False)
|
||||||
|
> migrator.drop_index(model, *col_names)
|
||||||
|
> migrator.add_not_null(model, *field_names)
|
||||||
|
> migrator.drop_not_null(model, *field_names)
|
||||||
|
> migrator.add_default(model, field_name, default)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import peewee as pw
|
||||||
|
|
||||||
|
from frigate.models import Event
|
||||||
|
|
||||||
|
SQL = pw.SQL
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(migrator, database, fake=False, **kwargs):
|
||||||
|
migrator.drop_not_null(Event, "thumbnail")
|
||||||
|
|
||||||
|
|
||||||
|
def rollback(migrator, database, fake=False, **kwargs):
|
||||||
|
migrator.add_not_null(Event, "thumbnail")
|
||||||
@ -11,6 +11,9 @@
|
|||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils"
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,7 +80,7 @@ export default function SearchThumbnail({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
src={`${apiHost}api/events/${searchResult.id}/thumbnail.jpg`}
|
src={`${apiHost}api/events/${searchResult.id}/thumbnail.webp`}
|
||||||
loading={isSafari ? "eager" : "lazy"}
|
loading={isSafari ? "eager" : "lazy"}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
onImgLoad();
|
onImgLoad();
|
||||||
|
|||||||
@ -11,6 +11,9 @@ import {
|
|||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
import {
|
import {
|
||||||
@ -24,12 +27,19 @@ import { VolumeSlider } from "@/components/ui/slider";
|
|||||||
import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
|
import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
|
||||||
import {
|
import {
|
||||||
AllGroupsStreamingSettings,
|
AllGroupsStreamingSettings,
|
||||||
|
FrigateConfig,
|
||||||
GroupStreamingSettings,
|
GroupStreamingSettings,
|
||||||
} from "@/types/frigateConfig";
|
} from "@/types/frigateConfig";
|
||||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||||
import { IoIosWarning } from "react-icons/io";
|
import {
|
||||||
|
IoIosNotifications,
|
||||||
|
IoIosNotificationsOff,
|
||||||
|
IoIosWarning,
|
||||||
|
} from "react-icons/io";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
|
import { useNotifications, useNotificationSuspend } from "@/api/ws";
|
||||||
|
|
||||||
type LiveContextMenuProps = {
|
type LiveContextMenuProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -48,6 +58,7 @@ type LiveContextMenuProps = {
|
|||||||
statsState: boolean;
|
statsState: boolean;
|
||||||
toggleStats: () => void;
|
toggleStats: () => void;
|
||||||
resetPreferredLiveMode: () => void;
|
resetPreferredLiveMode: () => void;
|
||||||
|
config?: FrigateConfig;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
export default function LiveContextMenu({
|
export default function LiveContextMenu({
|
||||||
@ -67,6 +78,7 @@ export default function LiveContextMenu({
|
|||||||
statsState,
|
statsState,
|
||||||
toggleStats,
|
toggleStats,
|
||||||
resetPreferredLiveMode,
|
resetPreferredLiveMode,
|
||||||
|
config,
|
||||||
children,
|
children,
|
||||||
}: LiveContextMenuProps) {
|
}: LiveContextMenuProps) {
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
@ -185,6 +197,44 @@ export default function LiveContextMenu({
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// notifications
|
||||||
|
|
||||||
|
const notificationsEnabledInConfig =
|
||||||
|
config?.cameras[camera].notifications.enabled_in_config;
|
||||||
|
|
||||||
|
const { payload: notificationState, send: sendNotification } =
|
||||||
|
useNotifications(camera);
|
||||||
|
const { payload: notificationSuspendUntil, send: sendNotificationSuspend } =
|
||||||
|
useNotificationSuspend(camera);
|
||||||
|
const [isSuspended, setIsSuspended] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (notificationSuspendUntil) {
|
||||||
|
setIsSuspended(
|
||||||
|
notificationSuspendUntil !== "0" || notificationState === "OFF",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [notificationSuspendUntil, notificationState]);
|
||||||
|
|
||||||
|
const handleSuspend = (duration: string) => {
|
||||||
|
if (duration === "off") {
|
||||||
|
sendNotification("OFF");
|
||||||
|
} else {
|
||||||
|
sendNotificationSuspend(Number.parseInt(duration));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSuspendedUntil = (timestamp: string) => {
|
||||||
|
if (timestamp === "0") return "Frigate restarts.";
|
||||||
|
|
||||||
|
return formatUnixTimestampToDateTime(Number.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 (
|
return (
|
||||||
<div className={cn("w-full", className)}>
|
<div className={cn("w-full", className)}>
|
||||||
<ContextMenu key={camera} onOpenChange={handleOpenChange}>
|
<ContextMenu key={camera} onOpenChange={handleOpenChange}>
|
||||||
@ -288,6 +338,115 @@ export default function LiveContextMenu({
|
|||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{notificationsEnabledInConfig && (
|
||||||
|
<>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuSub>
|
||||||
|
<ContextMenuSubTrigger>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>Notifications</span>
|
||||||
|
</div>
|
||||||
|
</ContextMenuSubTrigger>
|
||||||
|
<ContextMenuSubContent>
|
||||||
|
<div className="flex flex-col gap-0.5 px-2 py-1.5 text-sm font-medium">
|
||||||
|
<div className="flex w-full items-center gap-1">
|
||||||
|
{notificationState === "ON" ? (
|
||||||
|
<>
|
||||||
|
{isSuspended ? (
|
||||||
|
<>
|
||||||
|
<IoIosNotificationsOff className="size-5 text-muted-foreground" />
|
||||||
|
<span>Suspended</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IoIosNotifications className="size-5 text-muted-foreground" />
|
||||||
|
<span>Enabled</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IoIosNotificationsOff className="size-5 text-danger" />
|
||||||
|
<span>Disabled</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isSuspended && (
|
||||||
|
<span className="text-xs text-primary-variant">
|
||||||
|
Until {formatSuspendedUntil(notificationSuspendUntil)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSuspended ? (
|
||||||
|
<>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
sendNotification("ON");
|
||||||
|
sendNotificationSuspend(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
{notificationState === "ON" ? (
|
||||||
|
<span>Unsuspend</span>
|
||||||
|
) : (
|
||||||
|
<span>Enable</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
notificationState === "ON" && (
|
||||||
|
<>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<div className="px-2 py-1.5">
|
||||||
|
<p className="mb-2 text-sm font-medium text-muted-foreground">
|
||||||
|
Suspend for:
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<ContextMenuItem onClick={() => handleSuspend("5")}>
|
||||||
|
5 minutes
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => handleSuspend("10")}
|
||||||
|
>
|
||||||
|
10 minutes
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => handleSuspend("30")}
|
||||||
|
>
|
||||||
|
30 minutes
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => handleSuspend("60")}
|
||||||
|
>
|
||||||
|
1 hour
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => handleSuspend("840")}
|
||||||
|
>
|
||||||
|
12 hours
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => handleSuspend("1440")}
|
||||||
|
>
|
||||||
|
24 hours
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => handleSuspend("off")}
|
||||||
|
>
|
||||||
|
Until restart
|
||||||
|
</ContextMenuItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</ContextMenuSubContent>
|
||||||
|
</ContextMenuSub>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|
||||||
|
|||||||
@ -112,6 +112,12 @@ export default function ExportDialog({
|
|||||||
});
|
});
|
||||||
}, [camera, name, range, setRange, setName, setMode]);
|
}, [camera, name, range, setRange, setName, setMode]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
setName("");
|
||||||
|
setMode("none");
|
||||||
|
setRange(undefined);
|
||||||
|
}, [setMode, setRange]);
|
||||||
|
|
||||||
const Overlay = isDesktop ? Dialog : Drawer;
|
const Overlay = isDesktop ? Dialog : Drawer;
|
||||||
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
|
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
|
||||||
const Content = isDesktop ? DialogContent : DrawerContent;
|
const Content = isDesktop ? DialogContent : DrawerContent;
|
||||||
@ -129,7 +135,7 @@ export default function ExportDialog({
|
|||||||
show={mode == "timeline"}
|
show={mode == "timeline"}
|
||||||
onPreview={() => setShowPreview(true)}
|
onPreview={() => setShowPreview(true)}
|
||||||
onSave={() => onStartExport()}
|
onSave={() => onStartExport()}
|
||||||
onCancel={() => setMode("none")}
|
onCancel={handleCancel}
|
||||||
/>
|
/>
|
||||||
<Overlay
|
<Overlay
|
||||||
open={mode == "select"}
|
open={mode == "select"}
|
||||||
@ -176,7 +182,7 @@ export default function ExportDialog({
|
|||||||
setName={setName}
|
setName={setName}
|
||||||
setRange={setRange}
|
setRange={setRange}
|
||||||
setMode={setMode}
|
setMode={setMode}
|
||||||
onCancel={() => setMode("none")}
|
onCancel={handleCancel}
|
||||||
/>
|
/>
|
||||||
</Content>
|
</Content>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
|
|||||||
@ -52,6 +52,8 @@ import {
|
|||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ObjectPath } from "./ObjectPath";
|
||||||
|
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||||
|
|
||||||
type ObjectLifecycleProps = {
|
type ObjectLifecycleProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -108,6 +110,17 @@ export default function ObjectLifecycle({
|
|||||||
[config, event],
|
[config, event],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getObjectColor = useCallback(
|
||||||
|
(label: string) => {
|
||||||
|
const objectColor = config?.model?.colormap[label];
|
||||||
|
if (objectColor) {
|
||||||
|
const reversed = [...objectColor].reverse();
|
||||||
|
return reversed;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config],
|
||||||
|
);
|
||||||
|
|
||||||
const getZonePolygon = useCallback(
|
const getZonePolygon = useCallback(
|
||||||
(zoneName: string) => {
|
(zoneName: string) => {
|
||||||
if (!imgRef.current || !config) {
|
if (!imgRef.current || !config) {
|
||||||
@ -120,7 +133,7 @@ export default function ObjectLifecycle({
|
|||||||
|
|
||||||
return zonePoints
|
return zonePoints
|
||||||
.split(",")
|
.split(",")
|
||||||
.map(parseFloat)
|
.map(Number.parseFloat)
|
||||||
.reduce((acc, value, index) => {
|
.reduce((acc, value, index) => {
|
||||||
const isXCoordinate = index % 2 === 0;
|
const isXCoordinate = index % 2 === 0;
|
||||||
const coordinate = isXCoordinate
|
const coordinate = isXCoordinate
|
||||||
@ -158,6 +171,47 @@ export default function ObjectLifecycle({
|
|||||||
);
|
);
|
||||||
}, [config, event.camera]);
|
}, [config, event.camera]);
|
||||||
|
|
||||||
|
const savedPathPoints = useMemo(() => {
|
||||||
|
return (
|
||||||
|
event.data.path_data?.map(([coords, timestamp]: [number[], number]) => ({
|
||||||
|
x: coords[0],
|
||||||
|
y: coords[1],
|
||||||
|
timestamp,
|
||||||
|
lifecycle_item: undefined,
|
||||||
|
})) || []
|
||||||
|
);
|
||||||
|
}, [event.data.path_data]);
|
||||||
|
|
||||||
|
const eventSequencePoints = useMemo(() => {
|
||||||
|
return (
|
||||||
|
eventSequence
|
||||||
|
?.filter((event) => event.data.box !== undefined)
|
||||||
|
.map((event) => {
|
||||||
|
const [left, top, width, height] = event.data.box!;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: left + width / 2, // Center x-coordinate
|
||||||
|
y: top + height, // Bottom y-coordinate
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
lifecycle_item: event,
|
||||||
|
};
|
||||||
|
}) || []
|
||||||
|
);
|
||||||
|
}, [eventSequence]);
|
||||||
|
|
||||||
|
// final object path with timeline points included
|
||||||
|
const pathPoints = useMemo(() => {
|
||||||
|
// don't display a path if we don't have any saved path points
|
||||||
|
if (
|
||||||
|
savedPathPoints.length === 0 ||
|
||||||
|
config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config
|
||||||
|
)
|
||||||
|
return [];
|
||||||
|
return [...savedPathPoints, ...eventSequencePoints].sort(
|
||||||
|
(a, b) => a.timestamp - b.timestamp,
|
||||||
|
);
|
||||||
|
}, [savedPathPoints, eventSequencePoints, config, event]);
|
||||||
|
|
||||||
const [timeIndex, setTimeIndex] = useState(0);
|
const [timeIndex, setTimeIndex] = useState(0);
|
||||||
|
|
||||||
const handleSetBox = useCallback(
|
const handleSetBox = useCallback(
|
||||||
@ -171,12 +225,13 @@ export default function ObjectLifecycle({
|
|||||||
top: `${box[1] * imgRect.height}px`,
|
top: `${box[1] * imgRect.height}px`,
|
||||||
width: `${box[2] * imgRect.width}px`,
|
width: `${box[2] * imgRect.width}px`,
|
||||||
height: `${box[3] * imgRect.height}px`,
|
height: `${box[3] * imgRect.height}px`,
|
||||||
|
borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`,
|
||||||
};
|
};
|
||||||
|
|
||||||
setBoxStyle(style);
|
setBoxStyle(style);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[imgRef],
|
[imgRef, event, getObjectColor],
|
||||||
);
|
);
|
||||||
|
|
||||||
// image
|
// image
|
||||||
@ -254,6 +309,21 @@ export default function ObjectLifecycle({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [mainApi, thumbnailApi]);
|
}, [mainApi, thumbnailApi]);
|
||||||
|
|
||||||
|
const handlePathPointClick = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (!mainApi || !thumbnailApi || !eventSequence) return;
|
||||||
|
const sequenceIndex = eventSequence.findIndex(
|
||||||
|
(item) => item.timestamp === pathPoints[index].timestamp,
|
||||||
|
);
|
||||||
|
if (sequenceIndex !== -1) {
|
||||||
|
mainApi.scrollTo(sequenceIndex);
|
||||||
|
thumbnailApi.scrollTo(sequenceIndex);
|
||||||
|
setCurrent(sequenceIndex);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mainApi, thumbnailApi, eventSequence, pathPoints],
|
||||||
|
);
|
||||||
|
|
||||||
if (!event.id || !eventSequence || !config || !timeIndex) {
|
if (!event.id || !eventSequence || !config || !timeIndex) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
@ -325,6 +395,8 @@ export default function ObjectLifecycle({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{showZones &&
|
{showZones &&
|
||||||
|
imgRef.current?.width &&
|
||||||
|
imgRef.current?.height &&
|
||||||
lifecycleZones?.map((zone) => (
|
lifecycleZones?.map((zone) => (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
@ -355,13 +427,36 @@ export default function ObjectLifecycle({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{boxStyle && (
|
{boxStyle && (
|
||||||
<div
|
<div className="absolute border-2" style={boxStyle}>
|
||||||
className="absolute border-2 border-red-600"
|
|
||||||
style={boxStyle}
|
|
||||||
>
|
|
||||||
<div className="absolute bottom-[-3px] left-1/2 h-[5px] w-[5px] -translate-x-1/2 transform bg-yellow-500" />
|
<div className="absolute bottom-[-3px] left-1/2 h-[5px] w-[5px] -translate-x-1/2 transform bg-yellow-500" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{imgRef.current?.width &&
|
||||||
|
imgRef.current?.height &&
|
||||||
|
pathPoints &&
|
||||||
|
pathPoints.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
width: imgRef.current?.clientWidth,
|
||||||
|
height: imgRef.current?.clientHeight,
|
||||||
|
}}
|
||||||
|
key="path"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
|
||||||
|
className="absolute inset-0"
|
||||||
|
>
|
||||||
|
<ObjectPath
|
||||||
|
positions={pathPoints}
|
||||||
|
color={getObjectColor(event.label)}
|
||||||
|
width={2}
|
||||||
|
imgRef={imgRef}
|
||||||
|
onPointClick={handlePathPointClick}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem>
|
<ContextMenuItem>
|
||||||
@ -412,6 +507,11 @@ export default function ObjectLifecycle({
|
|||||||
{current + 1} of {eventSequence.length}
|
{current + 1} of {eventSequence.length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && (
|
||||||
|
<div className="-mt-2 mb-2 text-sm text-danger">
|
||||||
|
Bounding box positions will be inaccurate for autotracking cameras.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showControls && (
|
{showControls && (
|
||||||
<AnnotationSettingsPane
|
<AnnotationSettingsPane
|
||||||
event={event}
|
event={event}
|
||||||
@ -655,47 +755,3 @@ export function LifecycleIcon({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLifecycleItemDescription(lifecycleItem: ObjectLifecycleSequence) {
|
|
||||||
const label = (
|
|
||||||
(Array.isArray(lifecycleItem.data.sub_label)
|
|
||||||
? lifecycleItem.data.sub_label[0]
|
|
||||||
: lifecycleItem.data.sub_label) || lifecycleItem.data.label
|
|
||||||
).replaceAll("_", " ");
|
|
||||||
|
|
||||||
switch (lifecycleItem.class_type) {
|
|
||||||
case "visible":
|
|
||||||
return `${label} detected`;
|
|
||||||
case "entered_zone":
|
|
||||||
return `${label} entered ${lifecycleItem.data.zones
|
|
||||||
.join(" and ")
|
|
||||||
.replaceAll("_", " ")}`;
|
|
||||||
case "active":
|
|
||||||
return `${label} became active`;
|
|
||||||
case "stationary":
|
|
||||||
return `${label} became stationary`;
|
|
||||||
case "attribute": {
|
|
||||||
let title = "";
|
|
||||||
if (
|
|
||||||
lifecycleItem.data.attribute == "face" ||
|
|
||||||
lifecycleItem.data.attribute == "license_plate"
|
|
||||||
) {
|
|
||||||
title = `${lifecycleItem.data.attribute.replaceAll(
|
|
||||||
"_",
|
|
||||||
" ",
|
|
||||||
)} detected for ${label}`;
|
|
||||||
} else {
|
|
||||||
title = `${
|
|
||||||
lifecycleItem.data.label
|
|
||||||
} recognized as ${lifecycleItem.data.attribute.replaceAll("_", " ")}`;
|
|
||||||
}
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
case "gone":
|
|
||||||
return `${label} left`;
|
|
||||||
case "heard":
|
|
||||||
return `${label} heard`;
|
|
||||||
case "external":
|
|
||||||
return `${label} detected`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
113
web/src/components/overlay/detail/ObjectPath.tsx
Normal file
113
web/src/components/overlay/detail/ObjectPath.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { LifecycleClassType, Position } from "@/types/timeline";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||||
|
|
||||||
|
type ObjectPathProps = {
|
||||||
|
positions?: Position[];
|
||||||
|
color?: number[];
|
||||||
|
width?: number;
|
||||||
|
pointRadius?: number;
|
||||||
|
imgRef: React.RefObject<HTMLImageElement>;
|
||||||
|
onPointClick?: (index: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeColorMap: Partial<
|
||||||
|
Record<LifecycleClassType, [number, number, number]>
|
||||||
|
> = {
|
||||||
|
[LifecycleClassType.VISIBLE]: [0, 255, 0], // Green
|
||||||
|
[LifecycleClassType.GONE]: [255, 0, 0], // Red
|
||||||
|
[LifecycleClassType.ENTERED_ZONE]: [255, 165, 0], // Orange
|
||||||
|
[LifecycleClassType.ATTRIBUTE]: [128, 0, 128], // Purple
|
||||||
|
[LifecycleClassType.ACTIVE]: [255, 255, 0], // Yellow
|
||||||
|
[LifecycleClassType.STATIONARY]: [128, 128, 128], // Gray
|
||||||
|
[LifecycleClassType.HEARD]: [0, 255, 255], // Cyan
|
||||||
|
[LifecycleClassType.EXTERNAL]: [165, 42, 42], // Brown
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ObjectPath({
|
||||||
|
positions,
|
||||||
|
color = [0, 0, 255],
|
||||||
|
width = 2,
|
||||||
|
pointRadius = 4,
|
||||||
|
imgRef,
|
||||||
|
onPointClick,
|
||||||
|
}: ObjectPathProps) {
|
||||||
|
const getAbsolutePositions = useCallback(() => {
|
||||||
|
if (!imgRef.current || !positions) return [];
|
||||||
|
const imgRect = imgRef.current.getBoundingClientRect();
|
||||||
|
return positions.map((pos) => ({
|
||||||
|
x: pos.x * imgRect.width,
|
||||||
|
y: pos.y * imgRect.height,
|
||||||
|
timestamp: pos.timestamp,
|
||||||
|
lifecycle_item: pos.lifecycle_item,
|
||||||
|
}));
|
||||||
|
}, [positions, imgRef]);
|
||||||
|
|
||||||
|
const generateStraightPath = useCallback((points: Position[]) => {
|
||||||
|
if (!points || points.length < 2) return "";
|
||||||
|
let path = `M ${points[0].x} ${points[0].y}`;
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
path += ` L ${points[i].x} ${points[i].y}`;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getPointColor = (baseColor: number[], type?: LifecycleClassType) => {
|
||||||
|
if (type) {
|
||||||
|
const typeColor = typeColorMap[type];
|
||||||
|
if (typeColor) {
|
||||||
|
return `rgb(${typeColor.join(",")})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// normal path point
|
||||||
|
return `rgb(${baseColor.map((c) => Math.max(0, c - 10)).join(",")})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!imgRef.current) return null;
|
||||||
|
const absolutePositions = getAbsolutePositions();
|
||||||
|
const lineColor = `rgb(${color.join(",")})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d={generateStraightPath(absolutePositions)}
|
||||||
|
fill="none"
|
||||||
|
stroke={lineColor}
|
||||||
|
strokeWidth={width}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{absolutePositions.map((pos, index) => (
|
||||||
|
<Tooltip key={`point-${index}`}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<circle
|
||||||
|
cx={pos.x}
|
||||||
|
cy={pos.y}
|
||||||
|
r={pointRadius}
|
||||||
|
fill={getPointColor(color, pos.lifecycle_item?.class_type)}
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth={width / 2}
|
||||||
|
onClick={() =>
|
||||||
|
pos.lifecycle_item && onPointClick && onPointClick(index)
|
||||||
|
}
|
||||||
|
style={{ cursor: pos.lifecycle_item ? "pointer" : "default" }}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent side="top" className="capitalize">
|
||||||
|
{pos.lifecycle_item
|
||||||
|
? getLifecycleItemDescription(pos.lifecycle_item)
|
||||||
|
: "Tracked point"}
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -385,7 +385,7 @@ function EventItem({
|
|||||||
src={
|
src={
|
||||||
event.has_snapshot
|
event.has_snapshot
|
||||||
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
||||||
: `${apiHost}api/events/${event.id}/thumbnail.jpg`
|
: `${apiHost}api/events/${event.id}/thumbnail.webp`
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{hovered && (
|
{hovered && (
|
||||||
@ -400,7 +400,7 @@ function EventItem({
|
|||||||
href={
|
href={
|
||||||
event.has_snapshot
|
event.has_snapshot
|
||||||
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
||||||
: `${apiHost}api/events/${event.id}/thumbnail.jpg`
|
: `${apiHost}api/events/${event.id}/thumbnail.webp`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
|
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
|
||||||
|
|||||||
@ -511,7 +511,7 @@ function ObjectDetailsTab({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
src={`${apiHost}api/events/${search.id}/thumbnail.jpg`}
|
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
|
||||||
/>
|
/>
|
||||||
{config?.semantic_search.enabled && search.data.type == "object" && (
|
{config?.semantic_search.enabled && search.data.type == "object" && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
59
web/src/components/ui/alert.tsx
Normal file
59
web/src/components/ui/alert.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Alert.displayName = "Alert"
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertTitle.displayName = "AlertTitle"
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDescription.displayName = "AlertDescription"
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
@ -327,7 +327,7 @@ function FaceAttempt({
|
|||||||
.post(`/faces/reprocess`, { training_file: image })
|
.post(`/faces/reprocess`, { training_file: image })
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
if (resp.status == 200) {
|
if (resp.status == 200) {
|
||||||
toast.success(`Successfully trained face.`, {
|
toast.success(`Successfully updated face score.`, {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
onRefresh();
|
onRefresh();
|
||||||
@ -335,11 +335,14 @@ function FaceAttempt({
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error.response?.data?.message) {
|
if (error.response?.data?.message) {
|
||||||
toast.error(`Failed to train: ${error.response.data.message}`, {
|
toast.error(
|
||||||
position: "top-center",
|
`Failed to update score: ${error.response.data.message}`,
|
||||||
});
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(`Failed to train: ${error.message}`, {
|
toast.error(`Failed to update score: ${error.message}`, {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -419,7 +422,7 @@ function FaceAttempt({
|
|||||||
onClick={() => onReprocess()}
|
onClick={() => onReprocess()}
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Delete Face Attempt</TooltipContent>
|
<TooltipContent>Reprocess Face</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
|
|||||||
@ -1,353 +0,0 @@
|
|||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|
||||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
|
||||||
import { isMobile } from "react-device-detect";
|
|
||||||
import { FaVideo } from "react-icons/fa";
|
|
||||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
|
||||||
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
|
||||||
import { PolygonType } from "@/types/canvas";
|
|
||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
|
||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
|
||||||
import CameraSettingsView from "@/views/settings/CameraSettingsView";
|
|
||||||
import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
|
|
||||||
import MotionTunerView from "@/views/settings/MotionTunerView";
|
|
||||||
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
|
||||||
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",
|
|
||||||
"explore settings",
|
|
||||||
"camera settings",
|
|
||||||
"masks / zones",
|
|
||||||
"motion tuner",
|
|
||||||
"debug",
|
|
||||||
"users",
|
|
||||||
"notifications",
|
|
||||||
] as const;
|
|
||||||
type SettingsType = (typeof allSettingsViews)[number];
|
|
||||||
|
|
||||||
export default function Settings() {
|
|
||||||
const [page, setPage] = useState<SettingsType>("UI settings");
|
|
||||||
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
|
||||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
// available settings views
|
|
||||||
|
|
||||||
const settingsViews = useMemo(() => {
|
|
||||||
const views = [...allSettingsViews];
|
|
||||||
|
|
||||||
if (!("Notification" in window) || !window.isSecureContext) {
|
|
||||||
const index = views.indexOf("notifications");
|
|
||||||
views.splice(index, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return views;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// TODO: confirm leave page
|
|
||||||
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
|
||||||
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
const cameras = useMemo(() => {
|
|
||||||
if (!config) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.values(config.cameras)
|
|
||||||
.filter((conf) => conf.ui.dashboard && conf.enabled)
|
|
||||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const [selectedCamera, setSelectedCamera] = useState<string>("");
|
|
||||||
|
|
||||||
const [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>();
|
|
||||||
|
|
||||||
const handleDialog = useCallback(
|
|
||||||
(save: boolean) => {
|
|
||||||
if (unsavedChanges && save) {
|
|
||||||
// TODO
|
|
||||||
}
|
|
||||||
setConfirmationDialogOpen(false);
|
|
||||||
setUnsavedChanges(false);
|
|
||||||
},
|
|
||||||
[unsavedChanges],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (cameras.length > 0 && selectedCamera === "") {
|
|
||||||
setSelectedCamera(cameras[0].name);
|
|
||||||
}
|
|
||||||
}, [cameras, selectedCamera]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (tabsRef.current) {
|
|
||||||
const element = tabsRef.current.querySelector(
|
|
||||||
`[data-nav-item="${pageToggle}"]`,
|
|
||||||
);
|
|
||||||
if (element instanceof HTMLElement) {
|
|
||||||
scrollIntoView(element, {
|
|
||||||
behavior: "smooth",
|
|
||||||
inline: "start",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [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";
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex size-full flex-col p-2">
|
|
||||||
<div className="relative flex h-11 w-full items-center justify-between">
|
|
||||||
<ScrollArea className="w-full whitespace-nowrap">
|
|
||||||
<div ref={tabsRef} className="flex flex-row">
|
|
||||||
<ToggleGroup
|
|
||||||
className="*:rounded-md *:px-3 *:py-4"
|
|
||||||
type="single"
|
|
||||||
size="sm"
|
|
||||||
value={pageToggle}
|
|
||||||
onValueChange={(value: SettingsType) => {
|
|
||||||
if (value) {
|
|
||||||
setPageToggle(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Object.values(settingsViews).map((item) => (
|
|
||||||
<ToggleGroupItem
|
|
||||||
key={item}
|
|
||||||
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "UI settings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
|
||||||
value={item}
|
|
||||||
data-nav-item={item}
|
|
||||||
aria-label={`Select ${item}`}
|
|
||||||
>
|
|
||||||
<div className="capitalize">{item}</div>
|
|
||||||
</ToggleGroupItem>
|
|
||||||
))}
|
|
||||||
</ToggleGroup>
|
|
||||||
<ScrollBar orientation="horizontal" className="h-0" />
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
{(page == "debug" ||
|
|
||||||
page == "camera settings" ||
|
|
||||||
page == "masks / zones" ||
|
|
||||||
page == "motion tuner") && (
|
|
||||||
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
|
||||||
{page == "masks / zones" && (
|
|
||||||
<ZoneMaskFilterButton
|
|
||||||
selectedZoneMask={filterZoneMask}
|
|
||||||
updateZoneMaskFilter={setFilterZoneMask}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<CameraSelectButton
|
|
||||||
allCameras={cameras}
|
|
||||||
selectedCamera={selectedCamera}
|
|
||||||
setSelectedCamera={setSelectedCamera}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
|
|
||||||
{page == "UI settings" && <UiSettingsView />}
|
|
||||||
{page == "explore settings" && (
|
|
||||||
<SearchSettingsView setUnsavedChanges={setUnsavedChanges} />
|
|
||||||
)}
|
|
||||||
{page == "debug" && (
|
|
||||||
<ObjectSettingsView selectedCamera={selectedCamera} />
|
|
||||||
)}
|
|
||||||
{page == "camera settings" && (
|
|
||||||
<CameraSettingsView
|
|
||||||
selectedCamera={selectedCamera}
|
|
||||||
setUnsavedChanges={setUnsavedChanges}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{page == "masks / zones" && (
|
|
||||||
<MasksAndZonesView
|
|
||||||
selectedCamera={selectedCamera}
|
|
||||||
selectedZoneMask={filterZoneMask}
|
|
||||||
setUnsavedChanges={setUnsavedChanges}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{page == "motion tuner" && (
|
|
||||||
<MotionTunerView
|
|
||||||
selectedCamera={selectedCamera}
|
|
||||||
setUnsavedChanges={setUnsavedChanges}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{page == "users" && <AuthenticationView />}
|
|
||||||
{page == "notifications" && (
|
|
||||||
<NotificationView setUnsavedChanges={setUnsavedChanges} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{confirmationDialogOpen && (
|
|
||||||
<AlertDialog
|
|
||||||
open={confirmationDialogOpen}
|
|
||||||
onOpenChange={() => setConfirmationDialogOpen(false)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>You have unsaved changes.</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Do you want to save your changes before continuing?
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel onClick={() => handleDialog(false)}>
|
|
||||||
Cancel
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={() => handleDialog(true)}>
|
|
||||||
Save
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type CameraSelectButtonProps = {
|
|
||||||
allCameras: CameraConfig[];
|
|
||||||
selectedCamera: string;
|
|
||||||
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function CameraSelectButton({
|
|
||||||
allCameras,
|
|
||||||
selectedCamera,
|
|
||||||
setSelectedCamera,
|
|
||||||
}: CameraSelectButtonProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
if (!allCameras.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trigger = (
|
|
||||||
<Button
|
|
||||||
className="flex items-center gap-2 bg-selected capitalize hover:bg-selected"
|
|
||||||
aria-label="Select a camera"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<FaVideo className="text-background dark:text-primary" />
|
|
||||||
<div className="hidden text-background dark:text-primary md:block">
|
|
||||||
{selectedCamera == undefined
|
|
||||||
? "No Camera"
|
|
||||||
: selectedCamera.replaceAll("_", " ")}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
const content = (
|
|
||||||
<>
|
|
||||||
{isMobile && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuLabel className="flex justify-center">
|
|
||||||
Camera
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="scrollbar-container mb-5 h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden p-4 md:mb-1">
|
|
||||||
<div className="flex flex-col gap-2.5">
|
|
||||||
{allCameras.map((item) => (
|
|
||||||
<FilterSwitch
|
|
||||||
key={item.name}
|
|
||||||
isChecked={item.name === selectedCamera}
|
|
||||||
label={item.name.replaceAll("_", " ")}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
if (isChecked) {
|
|
||||||
setSelectedCamera(item.name);
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(open: boolean) => {
|
|
||||||
if (!open) {
|
|
||||||
setSelectedCamera(selectedCamera);
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
|
||||||
<DrawerContent className="max-h-[75dvh] overflow-hidden">
|
|
||||||
{content}
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu
|
|
||||||
modal={false}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(open: boolean) => {
|
|
||||||
if (!open) {
|
|
||||||
setSelectedCamera(selectedCamera);
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>{content}</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -22,5 +22,6 @@ export interface Event {
|
|||||||
area: number;
|
area: number;
|
||||||
ratio: number;
|
ratio: number;
|
||||||
type: "object" | "audio" | "manual";
|
type: "object" | "audio" | "manual";
|
||||||
|
path_data: [number[], number][];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,15 @@
|
|||||||
|
export enum LifecycleClassType {
|
||||||
|
VISIBLE = "visible",
|
||||||
|
GONE = "gone",
|
||||||
|
ENTERED_ZONE = "entered_zone",
|
||||||
|
ATTRIBUTE = "attribute",
|
||||||
|
ACTIVE = "active",
|
||||||
|
STATIONARY = "stationary",
|
||||||
|
HEARD = "heard",
|
||||||
|
EXTERNAL = "external",
|
||||||
|
PATH_POINT = "path_point",
|
||||||
|
}
|
||||||
|
|
||||||
export type ObjectLifecycleSequence = {
|
export type ObjectLifecycleSequence = {
|
||||||
camera: string;
|
camera: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@ -10,15 +22,7 @@ export type ObjectLifecycleSequence = {
|
|||||||
attribute: string;
|
attribute: string;
|
||||||
zones: string[];
|
zones: string[];
|
||||||
};
|
};
|
||||||
class_type:
|
class_type: LifecycleClassType;
|
||||||
| "visible"
|
|
||||||
| "gone"
|
|
||||||
| "entered_zone"
|
|
||||||
| "attribute"
|
|
||||||
| "active"
|
|
||||||
| "stationary"
|
|
||||||
| "heard"
|
|
||||||
| "external";
|
|
||||||
source_id: string;
|
source_id: string;
|
||||||
source: string;
|
source: string;
|
||||||
};
|
};
|
||||||
@ -28,3 +32,10 @@ export type TimeRange = { before: number; after: number };
|
|||||||
export type TimelineType = "timeline" | "events";
|
export type TimelineType = "timeline" | "events";
|
||||||
|
|
||||||
export type TimelineScrubMode = "auto" | "drag" | "hover" | "compat";
|
export type TimelineScrubMode = "auto" | "drag" | "hover" | "compat";
|
||||||
|
|
||||||
|
export type Position = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
timestamp: number;
|
||||||
|
lifecycle_item?: ObjectLifecycleSequence;
|
||||||
|
};
|
||||||
|
|||||||
47
web/src/utils/lifecycleUtil.ts
Normal file
47
web/src/utils/lifecycleUtil.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||||
|
|
||||||
|
export function getLifecycleItemDescription(
|
||||||
|
lifecycleItem: ObjectLifecycleSequence,
|
||||||
|
) {
|
||||||
|
const label = (
|
||||||
|
(Array.isArray(lifecycleItem.data.sub_label)
|
||||||
|
? lifecycleItem.data.sub_label[0]
|
||||||
|
: lifecycleItem.data.sub_label) || lifecycleItem.data.label
|
||||||
|
).replaceAll("_", " ");
|
||||||
|
|
||||||
|
switch (lifecycleItem.class_type) {
|
||||||
|
case "visible":
|
||||||
|
return `${label} detected`;
|
||||||
|
case "entered_zone":
|
||||||
|
return `${label} entered ${lifecycleItem.data.zones
|
||||||
|
.join(" and ")
|
||||||
|
.replaceAll("_", " ")}`;
|
||||||
|
case "active":
|
||||||
|
return `${label} became active`;
|
||||||
|
case "stationary":
|
||||||
|
return `${label} became stationary`;
|
||||||
|
case "attribute": {
|
||||||
|
let title = "";
|
||||||
|
if (
|
||||||
|
lifecycleItem.data.attribute == "face" ||
|
||||||
|
lifecycleItem.data.attribute == "license_plate"
|
||||||
|
) {
|
||||||
|
title = `${lifecycleItem.data.attribute.replaceAll(
|
||||||
|
"_",
|
||||||
|
" ",
|
||||||
|
)} detected for ${label}`;
|
||||||
|
} else {
|
||||||
|
title = `${
|
||||||
|
lifecycleItem.data.label
|
||||||
|
} recognized as ${lifecycleItem.data.attribute.replaceAll("_", " ")}`;
|
||||||
|
}
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
case "gone":
|
||||||
|
return `${label} left`;
|
||||||
|
case "heard":
|
||||||
|
return `${label} heard`;
|
||||||
|
case "external":
|
||||||
|
return `${label} detected`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -262,7 +262,7 @@ function ExploreThumbnailImage({
|
|||||||
}
|
}
|
||||||
loading={isSafari ? "eager" : "lazy"}
|
loading={isSafari ? "eager" : "lazy"}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
src={`${apiHost}api/events/${event.id}/thumbnail.jpg`}
|
src={`${apiHost}api/events/${event.id}/thumbnail.webp`}
|
||||||
onClick={() => setSearchDetail(event)}
|
onClick={() => setSearchDetail(event)}
|
||||||
onLoad={onImgLoad}
|
onLoad={onImgLoad}
|
||||||
alt={`${event.label} thumbnail`}
|
alt={`${event.label} thumbnail`}
|
||||||
|
|||||||
@ -584,6 +584,7 @@ export default function DraggableGridLayout({
|
|||||||
resetPreferredLiveMode={() =>
|
resetPreferredLiveMode={() =>
|
||||||
resetPreferredLiveMode(camera.name)
|
resetPreferredLiveMode(camera.name)
|
||||||
}
|
}
|
||||||
|
config={config}
|
||||||
>
|
>
|
||||||
<LivePlayer
|
<LivePlayer
|
||||||
key={camera.name}
|
key={camera.name}
|
||||||
@ -790,6 +791,7 @@ type GridLiveContextMenuProps = {
|
|||||||
muteAll: () => void;
|
muteAll: () => void;
|
||||||
unmuteAll: () => void;
|
unmuteAll: () => void;
|
||||||
resetPreferredLiveMode: () => void;
|
resetPreferredLiveMode: () => void;
|
||||||
|
config?: FrigateConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
const GridLiveContextMenu = React.forwardRef<
|
const GridLiveContextMenu = React.forwardRef<
|
||||||
@ -819,6 +821,7 @@ const GridLiveContextMenu = React.forwardRef<
|
|||||||
muteAll,
|
muteAll,
|
||||||
unmuteAll,
|
unmuteAll,
|
||||||
resetPreferredLiveMode,
|
resetPreferredLiveMode,
|
||||||
|
config,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@ -849,6 +852,7 @@ const GridLiveContextMenu = React.forwardRef<
|
|||||||
muteAll={muteAll}
|
muteAll={muteAll}
|
||||||
unmuteAll={unmuteAll}
|
unmuteAll={unmuteAll}
|
||||||
resetPreferredLiveMode={resetPreferredLiveMode}
|
resetPreferredLiveMode={resetPreferredLiveMode}
|
||||||
|
config={config}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</LiveContextMenu>
|
</LiveContextMenu>
|
||||||
|
|||||||
@ -507,6 +507,7 @@ export default function LiveDashboardView({
|
|||||||
resetPreferredLiveMode={() =>
|
resetPreferredLiveMode={() =>
|
||||||
resetPreferredLiveMode(camera.name)
|
resetPreferredLiveMode(camera.name)
|
||||||
}
|
}
|
||||||
|
config={config}
|
||||||
>
|
>
|
||||||
<LivePlayer
|
<LivePlayer
|
||||||
cameraRef={cameraRef}
|
cameraRef={cameraRef}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
|
import { LuAlertCircle, LuCheck, LuExternalLink, LuX } from "react-icons/lu";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -39,6 +39,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
|
||||||
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
|
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
|
||||||
|
|
||||||
@ -161,6 +162,9 @@ export default function NotificationView({
|
|||||||
useState<ServiceWorkerRegistration | null>();
|
useState<ServiceWorkerRegistration | null>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!("Notification" in window) || !window.isSecureContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.getRegistration(NOTIFICATION_SERVICE_WORKER)
|
.getRegistration(NOTIFICATION_SERVICE_WORKER)
|
||||||
.then((worker) => {
|
.then((worker) => {
|
||||||
@ -279,6 +283,60 @@ export default function NotificationView({
|
|||||||
saveToConfig(values as NotificationSettingsValueType);
|
saveToConfig(values as NotificationSettingsValueType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!("Notification" in window) || !window.isSecureContext) {
|
||||||
|
return (
|
||||||
|
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||||
|
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="col-span-1">
|
||||||
|
<Heading as="h3" className="my-2">
|
||||||
|
Notification Settings
|
||||||
|
</Heading>
|
||||||
|
<div className="max-w-6xl">
|
||||||
|
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||||
|
<p>
|
||||||
|
Frigate can natively send push notifications to your device
|
||||||
|
when it is running in the browser or installed as a PWA.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center text-primary">
|
||||||
|
<Link
|
||||||
|
to="https://docs.frigate.video/configuration/notifications"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline"
|
||||||
|
>
|
||||||
|
Read the Documentation{" "}
|
||||||
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<LuAlertCircle className="size-5" />
|
||||||
|
<AlertTitle>Notifications Unavailable</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription>
|
||||||
|
Web push notifications require a secure context (
|
||||||
|
<code>https://...</code>). This is a browser limitation. Access
|
||||||
|
Frigate securely to use notifications.
|
||||||
|
<div className="mt-3 flex items-center">
|
||||||
|
<Link
|
||||||
|
to="https://docs.frigate.video/configuration/authentication"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline"
|
||||||
|
>
|
||||||
|
Read the Documentation{" "}
|
||||||
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex size-full flex-col md:flex-row">
|
<div className="flex size-full flex-col md:flex-row">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user