mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-21 19:29:00 +03:00
Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f141b585bc | ||
|
|
9de7f80727 | ||
|
|
2101b1f91d | ||
|
|
2b5a30e23b | ||
|
|
dd6114586c | ||
|
|
d508a8b5fe | ||
|
|
feb40e9d05 | ||
|
|
2548af13b9 | ||
|
|
547c61f54c | ||
|
|
34e92ef4ae | ||
|
|
dbf440b0c7 | ||
|
|
321fd136c0 | ||
|
|
c9708102a9 | ||
|
|
85d8d7b136 | ||
|
|
b0e6a64bd0 | ||
|
|
2af1d22643 | ||
|
|
c026060ff3 | ||
|
|
8a117de87e | ||
|
|
a43f87c477 | ||
|
|
15c7a4074b | ||
|
|
ca650a29a1 | ||
|
|
0896df6500 | ||
|
|
01998dfb70 | ||
|
|
cb1cdcc814 | ||
|
|
63473b0b82 | ||
|
|
2ada0d22cb | ||
|
|
81f5f362a9 | ||
|
|
27914fc3b8 | ||
|
|
c11ca42fb5 | ||
|
|
9b7d4d0a8f | ||
|
|
cbdac9ece5 | ||
|
|
63f9689b0e | ||
|
|
6969a7fd47 | ||
|
|
8274b733da | ||
|
|
858c1241db | ||
|
|
c16e536b46 | ||
|
|
49c6073de6 | ||
|
|
2a8b62acb1 | ||
|
|
37d40558b7 | ||
|
|
afe513336c | ||
|
|
8a143b4284 | ||
|
|
717517aeb5 | ||
|
|
f4d5ebf4db | ||
|
|
b18572a3b6 | ||
|
|
b10615aef1 | ||
|
|
5d13925d2b | ||
|
|
ebae6cb1ed | ||
|
|
2f9b373c1a | ||
|
|
f48356cbee | ||
|
|
3538a1df3d | ||
|
|
c15fbee537 | ||
|
|
7612c3ece0 | ||
|
|
d3d05fa397 | ||
|
|
1fa7ce5486 | ||
|
|
bc74ba5b35 | ||
|
|
caebc583da | ||
|
|
57e933e68a | ||
|
|
c104913f81 | ||
|
|
d38a5659a9 | ||
|
|
0c54d2c47c | ||
|
|
4bb52f0357 | ||
|
|
c1594b2482 | ||
|
|
41919f9648 | ||
|
|
79e0fd1343 | ||
|
|
b1b78feec2 | ||
|
|
f27538607e | ||
|
|
2fc70c62a5 | ||
|
|
a3890c0304 | ||
|
|
953d7d0428 | ||
|
|
1a0e2abb78 | ||
|
|
5b0c7694d3 | ||
|
|
9e0b0778f8 | ||
|
|
120fff31a7 | ||
|
|
4d4d54d030 | ||
|
|
2c9bfaa49c | ||
|
|
f39ddbc00d | ||
|
|
8094dd4075 | ||
|
|
52d94231c7 | ||
|
|
ac8e647b92 | ||
|
|
da1fb935b4 | ||
|
|
83188e7ea4 | ||
|
|
3a69273f0c | ||
|
|
511542eaf8 | ||
|
|
976863518b | ||
|
|
895afcdb0e | ||
|
|
da2636d6f7 | ||
|
|
27d3be0356 | ||
|
|
fa196f85a7 | ||
|
|
9a3a64a9bd |
@@ -109,6 +109,7 @@ imdecode
|
||||
imencode
|
||||
imread
|
||||
imwrite
|
||||
inpoint
|
||||
interp
|
||||
iostat
|
||||
iotop
|
||||
@@ -264,6 +265,7 @@ tensorrt
|
||||
tflite
|
||||
thresholded
|
||||
timelapse
|
||||
titlecase
|
||||
tmpfs
|
||||
tobytes
|
||||
toggleable
|
||||
|
||||
@@ -39,14 +39,14 @@ jobs:
|
||||
STABLE_TAG=${BASE}:stable
|
||||
PULL_TAG=${BASE}:${BUILD_TAG}
|
||||
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${VERSION_TAG}
|
||||
for variant in standard-arm64 tensorrt tensorrt-jp5 tensorrt-jp6 rk h8l rocm; do
|
||||
for variant in standard-arm64 tensorrt tensorrt-jp6 rk 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}
|
||||
done
|
||||
|
||||
# stable tag
|
||||
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}
|
||||
for variant in standard-arm64 tensorrt tensorrt-jp5 tensorrt-jp6 rk h8l rocm; do
|
||||
for variant in standard-arm64 tensorrt tensorrt-jp6 rk 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}
|
||||
done
|
||||
fi
|
||||
|
||||
+7
-6
@@ -24,11 +24,9 @@
|
||||
- 通过RTSP重新流传输以减少摄像头的连接数
|
||||
- 支持WebRTC和MSE,实现低延迟的实时观看
|
||||
|
||||
## 文档(英文)
|
||||
## 社区中文翻译文档
|
||||
|
||||
你可以在这里查看文档 https://docs.frigate.video
|
||||
|
||||
文档还暂时没有提供翻译,将会在未来提供。
|
||||
你可以在这里查看文档 https://docs.frigate-cn.video
|
||||
|
||||
## 赞助
|
||||
|
||||
@@ -60,5 +58,8 @@
|
||||
## 翻译
|
||||
我们使用 [Weblate](https://hosted.weblate.org/projects/frigate-nvr/) 平台提供翻译支持,欢迎参与进来一起完善。
|
||||
|
||||
## 中文讨论社区
|
||||
欢迎加入非官方中文讨论QQ群:1043861059
|
||||
|
||||
## 非官方中文讨论社区
|
||||
欢迎加入中文讨论QQ群:1043861059
|
||||
|
||||
Bilibili:https://space.bilibili.com/3546894915602564
|
||||
|
||||
@@ -260,7 +260,7 @@ ENTRYPOINT ["/init"]
|
||||
CMD []
|
||||
|
||||
HEALTHCHECK --start-period=300s --start-interval=5s --interval=15s --timeout=5s --retries=3 \
|
||||
CMD curl --fail --silent --show-error http://127.0.0.1:5000/api/version || exit 1
|
||||
CMD test -f /dev/shm/.frigate-is-stopping && exit 0; curl --fail --silent --show-error http://127.0.0.1:5000/api/version || exit 1
|
||||
|
||||
# Frigate deps with Node.js and NPM for devcontainer
|
||||
FROM deps AS devcontainer
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
s6_version="3.1.5.0"
|
||||
s6_version="3.2.1.0"
|
||||
|
||||
if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||
s6_arch="x86_64"
|
||||
|
||||
@@ -31,6 +31,7 @@ norfair == 2.2.*
|
||||
setproctitle == 1.3.*
|
||||
ws4py == 0.5.*
|
||||
unidecode == 1.3.*
|
||||
titlecase == 2.4.*
|
||||
# Image Manipulation
|
||||
numpy == 1.26.*
|
||||
opencv-python-headless == 4.11.0.*
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
|
||||
set -o errexit -o nounset -o pipefail
|
||||
|
||||
# opt out of openvino telemetry
|
||||
if [ -e /usr/local/bin/opt_in_out ]; then
|
||||
/usr/local/bin/opt_in_out --opt_out
|
||||
fi
|
||||
|
||||
# Logs should be sent to stdout so that s6 can collect them
|
||||
|
||||
# Tell S6-Overlay not to restart this service
|
||||
|
||||
@@ -138,5 +138,9 @@ function migrate_db_from_media_to_config() {
|
||||
fi
|
||||
}
|
||||
|
||||
# remove leftover from last run, not normally needed, but just in case
|
||||
# used by the docker healthcheck
|
||||
rm -f /dev/shm/.frigate-is-stopping
|
||||
|
||||
migrate_addon_config_dir
|
||||
migrate_db_from_media_to_config
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
@@ -21,11 +22,11 @@ try:
|
||||
raw_config = f.read()
|
||||
|
||||
if config_file.endswith((".yaml", ".yml")):
|
||||
config: dict[str, any] = yaml.load(raw_config)
|
||||
config: dict[str, Any] = yaml.load(raw_config)
|
||||
elif config_file.endswith(".json"):
|
||||
config: dict[str, any] = json.loads(raw_config)
|
||||
config: dict[str, Any] = json.loads(raw_config)
|
||||
except FileNotFoundError:
|
||||
config: dict[str, any] = {}
|
||||
config: dict[str, Any] = {}
|
||||
|
||||
path = config.get("ffmpeg", {}).get("path", "default")
|
||||
if path == "default":
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
@@ -37,13 +38,13 @@ try:
|
||||
raw_config = f.read()
|
||||
|
||||
if config_file.endswith((".yaml", ".yml")):
|
||||
config: dict[str, any] = yaml.load(raw_config)
|
||||
config: dict[str, Any] = yaml.load(raw_config)
|
||||
elif config_file.endswith(".json"):
|
||||
config: dict[str, any] = json.loads(raw_config)
|
||||
config: dict[str, Any] = json.loads(raw_config)
|
||||
except FileNotFoundError:
|
||||
config: dict[str, any] = {}
|
||||
config: dict[str, Any] = {}
|
||||
|
||||
go2rtc_config: dict[str, any] = config.get("go2rtc", {})
|
||||
go2rtc_config: dict[str, Any] = config.get("go2rtc", {})
|
||||
|
||||
# Need to enable CORS for go2rtc so the frigate integration / card work automatically
|
||||
if go2rtc_config.get("api") is None:
|
||||
@@ -53,7 +54,7 @@ elif go2rtc_config["api"].get("origin") is None:
|
||||
|
||||
# Need to set default location for HA config
|
||||
if go2rtc_config.get("hass") is None:
|
||||
go2rtc_config["hass"] = {"config": "/config"}
|
||||
go2rtc_config["hass"] = {"config": "/homeassistant"}
|
||||
|
||||
# we want to ensure that logs are easy to read
|
||||
if go2rtc_config.get("log") is None:
|
||||
@@ -134,7 +135,7 @@ for name in go2rtc_config.get("streams", {}):
|
||||
|
||||
# add birdseye restream stream if enabled
|
||||
if config.get("birdseye", {}).get("restream", False):
|
||||
birdseye: dict[str, any] = config.get("birdseye")
|
||||
birdseye: dict[str, Any] = config.get("birdseye")
|
||||
|
||||
input = f"-f rawvideo -pix_fmt yuv420p -video_size {birdseye.get('width', 1280)}x{birdseye.get('height', 720)} -r 10 -i {BIRDSEYE_PIPE}"
|
||||
ffmpeg_cmd = f"exec:{parse_preset_hardware_acceleration_encode(ffmpeg_path, config.get('ffmpeg', {}).get('hwaccel_args', ''), input, '-rtsp_transport tcp -f rtsp {output}')}"
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
base_path = os.environ.get("FRIGATE_BASE_PATH", "")
|
||||
|
||||
result: dict[str, any] = {"base_path": base_path}
|
||||
result: dict[str, Any] = {"base_path": base_path}
|
||||
|
||||
print(json.dumps(result))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
@@ -19,12 +20,12 @@ try:
|
||||
raw_config = f.read()
|
||||
|
||||
if config_file.endswith((".yaml", ".yml")):
|
||||
config: dict[str, any] = yaml.load(raw_config)
|
||||
config: dict[str, Any] = yaml.load(raw_config)
|
||||
elif config_file.endswith(".json"):
|
||||
config: dict[str, any] = json.loads(raw_config)
|
||||
config: dict[str, Any] = json.loads(raw_config)
|
||||
except FileNotFoundError:
|
||||
config: dict[str, any] = {}
|
||||
config: dict[str, Any] = {}
|
||||
|
||||
tls_config: dict[str, any] = config.get("tls", {"enabled": True})
|
||||
tls_config: dict[str, Any] = config.get("tls", {"enabled": True})
|
||||
|
||||
print(json.dumps(tls_config))
|
||||
|
||||
@@ -22,7 +22,7 @@ RUN apt update && \
|
||||
|
||||
RUN mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib
|
||||
RUN cd /opt/rocm-$ROCM/lib && \
|
||||
cp -dpr libMIOpen*.so* libamd*.so* libhip*.so* libhsa*.so* libmigraphx*.so* librocm*.so* librocblas*.so* libroctracer*.so* librocfft*.so* librocprofiler*.so* libroctx*.so* /opt/rocm-dist/opt/rocm-$ROCM/lib/ && \
|
||||
cp -dpr libMIOpen*.so* libamd*.so* libhip*.so* libhsa*.so* libmigraphx*.so* librocm*.so* librocblas*.so* libroctracer*.so* librocsolver*.so* librocfft*.so* librocprofiler*.so* libroctx*.so* /opt/rocm-dist/opt/rocm-$ROCM/lib/ && \
|
||||
mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib && \
|
||||
cp -dpr migraphx/lib/* /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib
|
||||
RUN cd /opt/rocm-dist/opt/ && ln -s rocm-$ROCM rocm
|
||||
|
||||
@@ -47,12 +47,11 @@ RUN --mount=type=bind,source=docker/tensorrt/detector/build_python_tensorrt.sh,t
|
||||
&& TENSORRT_VER=$(cat /etc/TENSORRT_VER) /deps/build_python_tensorrt.sh
|
||||
|
||||
COPY docker/tensorrt/requirements-arm64.txt /requirements-tensorrt.txt
|
||||
# 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 \
|
||||
&& pip3 wheel --wheel-dir=/trt-wheels -r /requirements-tensorrt.txt \
|
||||
&& pip3 install --no-deps /tmp/onnxruntime_gpu-1.19.0-cp311-cp311-linux_aarch64.whl
|
||||
RUN pip3 wheel --wheel-dir=/trt-wheels -r /requirements-tensorrt.txt
|
||||
|
||||
# See https://elinux.org/Jetson_Zoo#ONNX_Runtime
|
||||
ADD https://nvidia.box.com/shared/static/9yvw05k6u343qfnkhdv2x6xhygze0aq1.whl /trt-wheels/onnxruntime_gpu-1.19.0-cp311-cp311-linux_aarch64.whl
|
||||
|
||||
FROM build-wheels AS trt-model-wheels
|
||||
ARG DEBIAN_FRONTEND
|
||||
@@ -93,7 +92,8 @@ RUN mkdir -p /etc/ld.so.conf.d && echo /usr/lib/ffmpeg/jetson/lib/ > /etc/ld.so.
|
||||
COPY --from=trt-wheels /etc/TENSORRT_VER /etc/TENSORRT_VER
|
||||
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 \
|
||||
pip3 install -U /deps/trt-wheels/*.whl /deps/trt-model-wheels/*.whl \
|
||||
pip3 uninstall -y onnxruntime \
|
||||
&& pip3 install -U /deps/trt-wheels/*.whl /deps/trt-model-wheels/*.whl \
|
||||
&& ldconfig
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
|
||||
@@ -77,7 +77,7 @@ Changing the secret will invalidate current tokens.
|
||||
|
||||
Frigate can be configured to leverage features of common upstream authentication proxies such as Authelia, Authentik, oauth2_proxy, or traefik-forward-auth.
|
||||
|
||||
If you are leveraging the authentication of an upstream proxy, you likely want to disable Frigate's authentication. Optionally, if communication between the reverse proxy and Frigate is over an untrusted network, you should set an `auth_secret` in the `proxy` config and configure the proxy to send the secret value as a header named `X-Proxy-Secret`. Assuming this is an untrusted network, you will also want to [configure a real TLS certificate](tls.md) to ensure the traffic can't simply be sniffed to steal the secret.
|
||||
If you are leveraging the authentication of an upstream proxy, you likely want to disable Frigate's authentication as there is no correspondence between users in Frigate's database and users authenticated via the proxy. Optionally, if communication between the reverse proxy and Frigate is over an untrusted network, you should set an `auth_secret` in the `proxy` config and configure the proxy to send the secret value as a header named `X-Proxy-Secret`. Assuming this is an untrusted network, you will also want to [configure a real TLS certificate](tls.md) to ensure the traffic can't simply be sniffed to steal the secret.
|
||||
|
||||
Here is an example of how to disable Frigate's authentication and also ensure the requests come only from your known proxy.
|
||||
|
||||
@@ -97,11 +97,12 @@ python3 -c 'import secrets; print(secrets.token_hex(64))'
|
||||
|
||||
### Header mapping
|
||||
|
||||
If you have disabled Frigate's authentication and your proxy supports passing a header with authenticated usernames and/or roles, you can use the `header_map` config to specify the header name so it is passed to Frigate. For example, the following will map the `X-Forwarded-User` and `X-Forwarded-Role` values. Header names are not case sensitive.
|
||||
If you have disabled Frigate's authentication and your proxy supports passing a header with authenticated usernames and/or roles, you can use the `header_map` config to specify the header name so it is passed to Frigate. For example, the following will map the `X-Forwarded-User` and `X-Forwarded-Role` values. Header names are not case sensitive. Multiple values can be included in the role header. Frigate expects that the character separating the roles is a comma, but this can be specified using the `separator` config entry.
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
...
|
||||
separator: "|" # This value defaults to a comma, but Authentik uses a pipe, for example.
|
||||
header_map:
|
||||
user: x-forwarded-user
|
||||
role: x-forwarded-role
|
||||
@@ -109,6 +110,14 @@ proxy:
|
||||
|
||||
Frigate supports both `admin` and `viewer` roles (see below). When using port `8971`, Frigate validates these headers and subsequent requests use the headers `remote-user` and `remote-role` for authorization.
|
||||
|
||||
A default role can be provided. Any value in the mapped `role` header will override the default.
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
...
|
||||
default_role: viewer
|
||||
```
|
||||
|
||||
#### Port Considerations
|
||||
|
||||
**Authenticated Port (8971)**
|
||||
|
||||
@@ -3,7 +3,7 @@ id: face_recognition
|
||||
title: Face Recognition
|
||||
---
|
||||
|
||||
Face recognition identifies known individuals by matching detected faces with previously learned facial data. When a known person is recognized, their name will be added as a `sub_label`. This information is included in the UI, filters, as well as in notifications.
|
||||
Face recognition identifies known individuals by matching detected faces with previously learned facial data. When a known `person` is recognized, their name will be added as a `sub_label`. This information is included in the UI, filters, as well as in notifications.
|
||||
|
||||
## Model Requirements
|
||||
|
||||
@@ -13,6 +13,12 @@ When running a Frigate+ model (or any custom model that natively detects faces)
|
||||
|
||||
When running a default COCO model or another model that does not include `face` as a detectable label, face detection will run via CV2 using a lightweight DNN model that runs on the CPU. In this case, you should _not_ define `face` in your list of objects to track.
|
||||
|
||||
:::note
|
||||
|
||||
Frigate needs to first detect a `person` before it can detect and recognize a face.
|
||||
|
||||
:::
|
||||
|
||||
### Face Recognition
|
||||
|
||||
Frigate has support for two face recognition model types:
|
||||
@@ -22,11 +28,13 @@ Frigate has support for two face recognition model types:
|
||||
|
||||
In both cases, a lightweight face landmark detection model is also used to align faces before running recognition.
|
||||
|
||||
All of these features run locally on your system.
|
||||
|
||||
## Minimum System Requirements
|
||||
|
||||
The `small` model is optimized for efficiency and runs on the CPU, most CPUs should run the model efficiently.
|
||||
|
||||
The `large` model is optimized for accuracy, an integrated or discrete GPU is highly recommended. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation.
|
||||
The `large` model is optimized for accuracy, an integrated or discrete GPU is required. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -39,7 +47,7 @@ face_recognition:
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
Fine-tune face recognition with these optional parameters:
|
||||
Fine-tune face recognition with these optional parameters at the global level of your config. The only optional parameters that can be set at the camera level are `enabled` and `min_area`.
|
||||
|
||||
### Detection
|
||||
|
||||
@@ -57,11 +65,22 @@ Fine-tune face recognition with these optional parameters:
|
||||
- Default: `0.8`.
|
||||
- `recognition_threshold`: Recognition confidence score required to add the face to the object as a sub label.
|
||||
- Default: `0.9`.
|
||||
- `min_faces`: Min face attempts for the sub label to be applied to the person object.
|
||||
- Default: `1`
|
||||
- `save_attempts`: Number of images of recognized faces to save for training.
|
||||
- Default: `100`.
|
||||
- `blur_confidence_filter`: Enables a filter that calculates how blurry the face is and adjusts the confidence based on this.
|
||||
- Default: `True`.
|
||||
|
||||
## Usage
|
||||
|
||||
Follow these steps to begin:
|
||||
|
||||
1. **Enable face recognition** in your configuration file and restart Frigate.
|
||||
2. **Upload one face** using the **Add Face** button's wizard in the Face Library section of the Frigate UI. Read below for the best practices on expanding your training set.
|
||||
3. When Frigate detects and attempts to recognize a face, it will appear in the **Train** tab of the Face Library, along with its associated recognition confidence.
|
||||
4. From the **Train** tab, you can **assign the face** to a new or existing person to improve recognition accuracy for the future.
|
||||
|
||||
## Creating a Robust Training Set
|
||||
|
||||
The number of images needed for a sufficient training set for face recognition varies depending on several factors:
|
||||
@@ -90,22 +109,32 @@ When choosing images to include in the face training set it is recommended to al
|
||||
|
||||
:::
|
||||
|
||||
### Understanding the Train Tab
|
||||
|
||||
The Train tab in the face library displays recent face recognition attempts. Detected face images are grouped according to the person they were identified as potentially matching.
|
||||
|
||||
Each face image is labeled with a name (or `Unknown`) along with the confidence score of the recognition attempt. While each image can be used to train the system for a specific person, not all images are suitable for training.
|
||||
|
||||
Refer to the guidelines below for best practices on selecting images for training.
|
||||
|
||||
### Step 1 - Building a Strong Foundation
|
||||
|
||||
When first enabling face recognition it is important to build a foundation of strong images. It is recommended to start by uploading 1-5 "portrait" photos for each person. It is important that the person's face in the photo is straight-on and not turned which will ensure a good starting point.
|
||||
When first enabling face recognition it is important to build a foundation of strong images. It is recommended to start by uploading 1-5 photos containing just this person's face. It is important that the person's face in the photo is front-facing and not turned, this will ensure a good starting point.
|
||||
|
||||
Then it is recommended to use the `Face Library` tab in Frigate to select and train images for each person as they are detected. When building a strong foundation it is strongly recommended to only train on images that are straight-on. Ignore images from cameras that recognize faces from an angle.
|
||||
Then it is recommended to use the `Face Library` tab in Frigate to select and train images for each person as they are detected. When building a strong foundation it is strongly recommended to only train on images that are front-facing. Ignore images from cameras that recognize faces from an angle. Aim to strike a balance between the quality of images while also having a range of conditions (day / night, different weather conditions, different times of day, etc.) in order to have diversity in the images used for each person and not have over-fitting.
|
||||
|
||||
Aim to strike a balance between the quality of images while also having a range of conditions (day / night, different weather conditions, different times of day, etc.) in order to have diversity in the images used for each person and not have over-fitting.
|
||||
|
||||
Once a person starts to be consistently recognized correctly on images that are straight-on, it is time to move on to the next step.
|
||||
You do not want to train images that are 90%+ as these are already being confidently recognized. In this step the goal is to train on clear, lower scoring front-facing images until the majority of front-facing images for a given person are consistently recognized correctly. Then it is time to move on to step 2.
|
||||
|
||||
### Step 2 - Expanding The Dataset
|
||||
|
||||
Once straight-on images are performing well, start choosing slightly off-angle images to include for training. It is important to still choose images where enough face detail is visible to recognize someone.
|
||||
Once front-facing images are performing well, start choosing slightly off-angle images to include for training. It is important to still choose images where enough face detail is visible to recognize someone, and you still only want to train on images that score lower.
|
||||
|
||||
## FAQ
|
||||
|
||||
### Detection does not work well with blurry images?
|
||||
|
||||
Accuracy is definitely a going to be improved with higher quality cameras / streams. It is important to look at the DORI (Detection Observation Recognition Identification) range of your camera, if that specification is posted. This specification explains the distance from the camera that a person can be detected, observed, recognized, and identified. The identification range is the most relevant here, and the distance listed by the camera is the furthest that face recognition will realistically work.
|
||||
|
||||
### Why can't I bulk upload photos?
|
||||
|
||||
It is important to methodically add photos to the library, bulk importing photos (especially from a general photo library) will lead to over-fitting in that particular scenario and hurt recognition performance.
|
||||
@@ -122,6 +151,15 @@ This can happen for a few different reasons, but this is usually an indicator th
|
||||
- When you provide images with different poses, lighting, and expressions, the algorithm extracts features that are consistent across those variations.
|
||||
- By training on a diverse set of images, the algorithm becomes less sensitive to minor variations and noise in the input image.
|
||||
|
||||
Review your face collections and remove most of the unclear or low-quality images. Then, use the **Reprocess** button on each face in the **Train** tab to evaluate how the changes affect recognition scores.
|
||||
|
||||
Avoid training on images that already score highly, as this can lead to over-fitting. Instead, focus on relatively clear images that score lower - ideally with different lighting, angles, and conditions—to help the model generalize more effectively.
|
||||
|
||||
### Frigate misidentified a face. Can I tell it that a face is "not" a specific person?
|
||||
|
||||
No, face recognition does not support negative training (i.e., explicitly telling it who someone is _not_). Instead, the best approach is to improve the training data by using a more diverse and representative set of images for each person.
|
||||
For more guidance, refer to the section above on improving recognition accuracy.
|
||||
|
||||
### I see scores above the threshold in the train tab, but a sub label wasn't assigned?
|
||||
|
||||
The Frigate considers the recognition scores across all recognition attempts for each person object. The scores are continually weighted based on the area of the face, and a sub label will only be assigned to person if a person is confidently recognized consistently. This avoids cases where a single high confidence recognition would throw off the results.
|
||||
@@ -141,3 +179,7 @@ Face recognition does not run on the recording stream, this would be suboptimal
|
||||
### I get an unknown error when taking a photo directly with my iPhone
|
||||
|
||||
By default iOS devices will use HEIC (High Efficiency Image Container) for images, but this format is not supported for uploads. Choosing `large` as the format instead of `original` will use JPG which will work correctly.
|
||||
|
||||
### How can I delete the face database and start over?
|
||||
|
||||
Frigate does not store anything in its database related to face recognition. You can simply delete all of your faces through the Frigate UI or remove the contents of the `/media/frigate/clips/faces` directory.
|
||||
|
||||
@@ -167,7 +167,7 @@ Analyze the sequence of images containing the {label}. Focus on the likely inten
|
||||
|
||||
:::tip
|
||||
|
||||
Prompts can use variable replacements like `{label}`, `{sub_label}`, and `{camera}` to substitute information from the tracked object as part of the prompt.
|
||||
Prompts can use variable replacements `{label}`, `{sub_label}`, and `{camera}` to substitute information from the tracked object as part of the prompt.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -24,3 +24,9 @@ Object detection and enrichments (like Semantic Search, Face Recognition, and Li
|
||||
- Jetson devices will automatically be detected and used for enrichments in the `-tensorrt-jp6` Frigate image.
|
||||
|
||||
Utilizing a GPU for enrichments does not require you to use the same GPU for object detection. For example, you can run the `tensorrt` Docker image for enrichments and still use other dedicated hardware for object detection.
|
||||
|
||||
:::note
|
||||
|
||||
A Google Coral is a TPU (Tensor Processing Unit), not a dedicated GPU (Graphics Processing Unit) and therefore does not provide any kind of acceleration for Frigate's enrichments.
|
||||
|
||||
:::
|
||||
|
||||
@@ -3,7 +3,7 @@ id: license_plate_recognition
|
||||
title: License Plate Recognition (LPR)
|
||||
---
|
||||
|
||||
Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a known name as a `sub_label` to tracked objects of type `car`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.
|
||||
Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a known name as a `sub_label` to tracked objects of type `car` or `motorcycle`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.
|
||||
|
||||
LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. However, LPR does not run on stationary vehicles.
|
||||
|
||||
@@ -13,7 +13,7 @@ When a plate is recognized, the details are:
|
||||
- Viewable in the Review Item Details pane in Review (sub labels).
|
||||
- Viewable in the Tracked Object Details pane in Explore (sub labels and recognized license plates).
|
||||
- Filterable through the More Filters menu in Explore.
|
||||
- Published via the `frigate/events` MQTT topic as a `sub_label` (known) or `recognized_license_plate` (unknown) for the `car` tracked object.
|
||||
- Published via the `frigate/events` MQTT topic as a `sub_label` (known) or `recognized_license_plate` (unknown) for the `car` or `motorcycle` tracked object.
|
||||
- Published via the `frigate/tracked_object_update` MQTT topic with `name` (if known) and `plate`.
|
||||
|
||||
## Model Requirements
|
||||
@@ -24,7 +24,7 @@ Users without a model that detects license plates can still run LPR. Frigate use
|
||||
|
||||
:::note
|
||||
|
||||
In the default mode, Frigate's LPR needs to first detect a `car` before it can recognize a license plate. If you're using a dedicated LPR camera and have a zoomed-in view where a `car` will not be detected, you can still run LPR, but the configuration parameters will differ from the default mode. See the [Dedicated LPR Cameras](#dedicated-lpr-cameras) section below.
|
||||
In the default mode, Frigate's LPR needs to first detect a `car` or `motorcycle` before it can recognize a license plate. If you're using a dedicated LPR camera and have a zoomed-in view where a `car` or `motorcycle` will not be detected, you can still run LPR, but the configuration parameters will differ from the default mode. See the [Dedicated LPR Cameras](#dedicated-lpr-cameras) section below.
|
||||
|
||||
:::
|
||||
|
||||
@@ -51,7 +51,7 @@ cameras:
|
||||
enabled: False
|
||||
```
|
||||
|
||||
For non-dedicated LPR cameras, ensure that your camera is configured to detect objects of type `car`, and that a car is actually being detected by Frigate. Otherwise, LPR will not run.
|
||||
For non-dedicated LPR cameras, ensure that your camera is configured to detect objects of type `car` or `motorcycle`, and that a car or motorcycle is actually being detected by Frigate. Otherwise, LPR will not run.
|
||||
|
||||
Like the other real-time processors in Frigate, license plate recognition runs on the camera stream defined by the `detect` role in your config. To ensure optimal performance, select a suitable resolution for this stream in your camera's firmware that fits your specific scene and requirements.
|
||||
|
||||
@@ -87,7 +87,7 @@ Fine-tune the LPR feature using these optional parameters at the global level of
|
||||
|
||||
### Matching
|
||||
|
||||
- **`known_plates`**: List of strings or regular expressions that assign custom a `sub_label` to `car` objects when a recognized plate matches a known value.
|
||||
- **`known_plates`**: List of strings or regular expressions that assign custom a `sub_label` to `car` and `motorcycle` objects when a recognized plate matches a known value.
|
||||
- These labels appear in the UI, filters, and notifications.
|
||||
- Unknown plates are still saved but are added to the `recognized_license_plate` field rather than the `sub_label`.
|
||||
- **`match_distance`**: Allows for minor variations (missing/incorrect characters) when matching a detected plate to a known plate.
|
||||
@@ -164,11 +164,17 @@ Dedicated LPR cameras are single-purpose cameras with powerful optical zoom to c
|
||||
|
||||
To mark a camera as a dedicated LPR camera, add `type: "lpr"` the camera configuration.
|
||||
|
||||
:::note
|
||||
|
||||
Frigate's dedicated LPR mode is optimized for cameras with a narrow field of view, specifically positioned and zoomed to capture license plates exclusively. If your camera provides a general overview of a scene rather than a tightly focused view, this mode is not recommended.
|
||||
|
||||
:::
|
||||
|
||||
Users can configure Frigate's dedicated LPR mode in two different ways depending on whether a Frigate+ (or native `license_plate` detecting) model is used:
|
||||
|
||||
### Using a Frigate+ (or Native `license_plate` Detecting) Model
|
||||
|
||||
Users running a Frigate+ model (or any model that natively detects `license_plate`) can take advantage of `license_plate` detection. This allows license plates to be treated as standard objects in dedicated LPR mode, meaning that alerts, detections, snapshots, zones, and other Frigate features work as usual, and plates are detected efficiently through your configured object detector.
|
||||
Users running a Frigate+ model (or any model that natively detects `license_plate`) can take advantage of `license_plate` detection. This allows license plates to be treated as standard objects in dedicated LPR mode, meaning that alerts, detections, snapshots, and other Frigate features work as usual, and plates are detected efficiently through your configured object detector.
|
||||
|
||||
An example configuration for a dedicated LPR camera using a `license_plate`-detecting model:
|
||||
|
||||
@@ -213,11 +219,11 @@ cameras:
|
||||
With this setup:
|
||||
|
||||
- License plates are treated as normal objects in Frigate.
|
||||
- Scores, alerts, detections, snapshots, zones, and object masks work as expected.
|
||||
- Scores, alerts, detections, and snapshots work as expected.
|
||||
- Snapshots will have license plate bounding boxes on them.
|
||||
- The `frigate/events` MQTT topic will publish tracked object updates.
|
||||
- Debug view will display `license_plate` bounding boxes.
|
||||
- If you are using a Frigate+ model and want to submit images from your dedicated LPR camera for model training and fine-tuning, annotate both the `car` and the `license_plate` in the snapshots on the Frigate+ website, even if the car is barely visible.
|
||||
- If you are using a Frigate+ model and want to submit images from your dedicated LPR camera for model training and fine-tuning, annotate both the `car` / `motorcycle` and the `license_plate` in the snapshots on the Frigate+ website, even if the car is barely visible.
|
||||
|
||||
### Using the Secondary LPR Pipeline (Without Frigate+)
|
||||
|
||||
@@ -279,7 +285,6 @@ With this setup:
|
||||
| License Plate Detection | Uses `license_plate` as a tracked object | Runs a dedicated LPR pipeline |
|
||||
| FPS Setting | 5 (increase for fast-moving cars) | 5 (increase for fast-moving cars, but it may use much more CPU) |
|
||||
| Object Detection | Standard Frigate+ detection applies | Bypasses standard object detection |
|
||||
| Zones & Object Masks | Supported | Not supported |
|
||||
| Debug View | May show `license_plate` bounding boxes | May **not** show `license_plate` bounding boxes |
|
||||
| MQTT `frigate/events` | Publishes tracked object updates | Publishes limited updates |
|
||||
| Explore | Recognized plates available in More Filters | Recognized plates available in More Filters |
|
||||
@@ -311,9 +316,9 @@ Recognized plates will show as object labels in the debug view and will appear i
|
||||
|
||||
If you are still having issues detecting plates, start with a basic configuration and see the debugging tips below.
|
||||
|
||||
### Can I run LPR without detecting `car` objects?
|
||||
### Can I run LPR without detecting `car` or `motorcycle` objects?
|
||||
|
||||
In normal LPR mode, Frigate requires a `car` to be detected first before recognizing a license plate. If you have a dedicated LPR camera, you can change the camera `type` to `"lpr"` to use the Dedicated LPR Camera algorithm. This comes with important caveats, though. See the [Dedicated LPR Cameras](#dedicated-lpr-cameras) section above.
|
||||
In normal LPR mode, Frigate requires a `car` or `motorcycle` to be detected first before recognizing a license plate. If you have a dedicated LPR camera, you can change the camera `type` to `"lpr"` to use the Dedicated LPR Camera algorithm. This comes with important caveats, though. See the [Dedicated LPR Cameras](#dedicated-lpr-cameras) section above.
|
||||
|
||||
### How can I improve detection accuracy?
|
||||
|
||||
@@ -335,19 +340,37 @@ Use `match_distance` to allow small character mismatches. Alternatively, define
|
||||
|
||||
### How do I debug LPR issues?
|
||||
|
||||
- View MQTT messages for `frigate/events` to verify detected plates.
|
||||
- If you are using a Frigate+ model or a model that detects license plates, watch the debug view (Settings --> Debug) to ensure that `license_plate` is being detected with a `car`.
|
||||
- Watch the debug view to see plates recognized in real-time. For non-dedicated LPR cameras, the `car` label will change to the recognized plate when LPR is enabled and working.
|
||||
- Adjust `detection_threshold` and `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
|
||||
- Enable `debug_save_plates` to save images of detected text on plates to the clips directory (`/media/frigate/clips/lpr`). Ensure these images are readable and the text is clear.
|
||||
- Enable debug logs for LPR by adding `frigate.data_processing.common.license_plate: debug` to your `logger` configuration. These logs are _very_ verbose, so only enable this when necessary.
|
||||
Start with ["Why isn't my license plate being detected and recognized?"](#why-isnt-my-license-plate-being-detected-and-recognized). If you are still having issues, work through these steps.
|
||||
|
||||
```yaml
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
frigate.data_processing.common.license_plate: debug
|
||||
```
|
||||
1. Enable debug logs to see exactly what Frigate is doing.
|
||||
|
||||
- Enable debug logs for LPR by adding `frigate.data_processing.common.license_plate: debug` to your `logger` configuration. These logs are _very_ verbose, so only keep this enabled when necessary.
|
||||
|
||||
```yaml
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
frigate.data_processing.common.license_plate: debug
|
||||
```
|
||||
|
||||
2. Ensure your plates are being _detected_.
|
||||
|
||||
If you are using a Frigate+ or `license_plate` detecting model:
|
||||
|
||||
- Watch the debug view (Settings --> Debug) to ensure that `license_plate` is being detected.
|
||||
- View MQTT messages for `frigate/events` to verify detected plates.
|
||||
- You may need to adjust your `min_score` and/or `threshold` for the `license_plate` object if your plates are not being detected.
|
||||
|
||||
If you are **not** using a Frigate+ or `license_plate` detecting model:
|
||||
|
||||
- Watch the debug logs for messages from the YOLOv9 plate detector.
|
||||
- You may need to adjust your `detection_threshold` if your plates are not being detected.
|
||||
|
||||
3. Ensure the characters on detected plates are being _recognized_.
|
||||
|
||||
- Enable `debug_save_plates` to save images of detected text on plates to the clips directory (`/media/frigate/clips/lpr`). Ensure these images are readable and the text is clear.
|
||||
- Watch the debug view to see plates recognized in real-time. For non-dedicated LPR cameras, the `car` or `motorcycle` label will change to the recognized plate when LPR is enabled and working.
|
||||
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
|
||||
|
||||
### Will LPR slow down my system?
|
||||
|
||||
@@ -357,12 +380,16 @@ LPR's performance impact depends on your hardware. Ensure you have at least 4GB
|
||||
|
||||
The YOLOv9 license plate detector model will run (and the metric will appear) if you've enabled LPR but haven't defined `license_plate` as an object to track, either at the global or camera level.
|
||||
|
||||
If you are detecting `car` on cameras where you don't want to run LPR, make sure you disable LPR it at the camera level. And if you do want to run LPR on those cameras, make sure you define `license_plate` as an object to track.
|
||||
If you are detecting `car` or `motorcycle` on cameras where you don't want to run LPR, make sure you disable LPR it at the camera level. And if you do want to run LPR on those cameras, make sure you define `license_plate` as an object to track.
|
||||
|
||||
### It looks like Frigate picked up my camera's timestamp as the license plate. How can I prevent this?
|
||||
### It looks like Frigate picked up my camera's timestamp or overlay text as the license plate. How can I prevent this?
|
||||
|
||||
This could happen if cars travel close to your camera's timestamp. You could either move the timestamp through your camera's firmware, or apply a mask to it in Frigate.
|
||||
This could happen if cars or motorcycles travel close to your camera's timestamp or overlay text. You could either move the text through your camera's firmware, or apply a mask to it in Frigate.
|
||||
|
||||
If you are using a model that natively detects `license_plate`, add an _object mask_ of type `license_plate` and a _motion mask_ over your timestamp.
|
||||
If you are using a model that natively detects `license_plate`, add an _object mask_ of type `license_plate` and a _motion mask_ over your text.
|
||||
|
||||
If you are using dedicated LPR camera mode, only a _motion mask_ over your timestamp is required.
|
||||
If you are not using a model that natively detects `license_plate` or you are using dedicated LPR camera mode, only a _motion mask_ over your text is required.
|
||||
|
||||
### I see "Error running ... model" in my logs. How can I fix this?
|
||||
|
||||
This usually happens when your GPU is unable to compile or use one of the LPR models. Set your `device` to `CPU` and try again. GPU acceleration only provides a slight performance increase, and the models are lightweight enough to run without issue on most CPUs.
|
||||
|
||||
@@ -197,6 +197,12 @@ The default dashboard ("All Cameras") will always use Smart Streaming and the fi
|
||||
|
||||
Cameras can be temporarily disabled through the Frigate UI and through [MQTT](/integrations/mqtt#frigatecamera_nameenabledset) to conserve system resources. When disabled, Frigate's ffmpeg processes are terminated — recording stops, object detection is paused, and the Live dashboard displays a blank image with a disabled message. Review items, tracked objects, and historical footage for disabled cameras can still be accessed via the UI.
|
||||
|
||||
:::note
|
||||
|
||||
Disabling a camera via the Frigate UI or MQTT is temporary and does not persist through restarts of Frigate.
|
||||
|
||||
:::
|
||||
|
||||
For restreamed cameras, go2rtc remains active but does not use system resources for decoding or processing unless there are active external consumers (such as the Advanced Camera Card in Home Assistant using a go2rtc source).
|
||||
|
||||
Note that disabling a camera through the config file (`enabled: False`) removes all related UI elements, including historical footage access. To retain access while disabling the camera, keep it enabled in the config and use the UI or MQTT to disable it temporarily.
|
||||
|
||||
@@ -152,7 +152,7 @@ Use this configuration for YOLO-based models. When no custom model path or URL i
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
hailo8l:
|
||||
hailo:
|
||||
type: hailo8l
|
||||
device: PCIe
|
||||
|
||||
@@ -185,7 +185,7 @@ For SSD-based models, provide either a model path or URL to your compiled SSD mo
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
hailo8l:
|
||||
hailo:
|
||||
type: hailo8l
|
||||
device: PCIe
|
||||
|
||||
@@ -209,7 +209,7 @@ The Hailo detector supports all YOLO models compiled for Hailo hardware that inc
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
hailo8l:
|
||||
hailo:
|
||||
type: hailo8l
|
||||
device: PCIe
|
||||
|
||||
@@ -1024,7 +1024,7 @@ x.export()
|
||||
|
||||
### Downloading YOLO-NAS Model
|
||||
|
||||
You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb).
|
||||
You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) which can be run directly in [Google Colab](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb).
|
||||
|
||||
:::warning
|
||||
|
||||
@@ -1053,13 +1053,14 @@ python3 yolo_to_onnx.py -m yolov7-320
|
||||
|
||||
#### YOLOv9
|
||||
|
||||
YOLOv9 models can be exported using the below code or they [can be downloaded from hugging face](https://huggingface.co/Xenova/yolov9-onnx/tree/main)
|
||||
YOLOv9 models can be exported using the below code
|
||||
|
||||
```sh
|
||||
git clone https://github.com/WongKinYiu/yolov9
|
||||
cd yolov9
|
||||
|
||||
# setup the virtual environment so installation doesn't affect main system
|
||||
# NOTE: Virtual environment must be using Python 3.11 or older.
|
||||
python3 -m venv ./
|
||||
bin/pip install -r requirements.txt
|
||||
bin/pip install onnx onnxruntime onnx-simplifier>=0.4.1
|
||||
@@ -1069,5 +1070,5 @@ wget -O yolov9-t.pt "https://github.com/WongKinYiu/yolov9/releases/download/v0.1
|
||||
|
||||
# prepare and run export script
|
||||
sed -i "s/ckpt = torch.load(attempt_download(w), map_location='cpu')/ckpt = torch.load(attempt_download(w), map_location='cpu', weights_only=False)/g" ./models/experimental.py
|
||||
python3 export.py --weights ./yolov9-t.pt --imgsz 320 --simplify --include onnx
|
||||
bin/python3 export.py --weights ./yolov9-t.pt --imgsz 320 --simplify --include onnx
|
||||
```
|
||||
|
||||
@@ -78,16 +78,21 @@ proxy:
|
||||
# Optional: Mapping for headers from upstream proxies. Only used if Frigate's auth
|
||||
# is disabled.
|
||||
# NOTE: Many authentication proxies pass a header downstream with the authenticated
|
||||
# user name. Not all values are supported. It must be a whitelisted header.
|
||||
# user name and role. Not all values are supported. It must be a whitelisted header.
|
||||
# See the docs for more info.
|
||||
header_map:
|
||||
user: x-forwarded-user
|
||||
role: x-forwarded-role
|
||||
# Optional: Url for logging out a user. This sets the location of the logout url in
|
||||
# the UI.
|
||||
logout_url: /api/logout
|
||||
# Optional: Auth secret that is checked against the X-Proxy-Secret header sent from
|
||||
# the proxy. If not set, all requests are trusted regardless of origin.
|
||||
auth_secret: None
|
||||
# Optional: The default role to use for proxy auth. Must be "admin" or "viewer"
|
||||
default_role: viewer
|
||||
# Optional: The character used to separate multiple values in the proxy headers. (default: shown below)
|
||||
separator: ","
|
||||
|
||||
# Optional: Authentication configuration
|
||||
auth:
|
||||
@@ -543,9 +548,9 @@ semantic_search:
|
||||
model_size: "small"
|
||||
|
||||
# Optional: Configuration for face recognition capability
|
||||
# NOTE: Can (enabled, min_area) be overridden at the camera level
|
||||
# NOTE: enabled, min_area can be overridden at the camera level
|
||||
face_recognition:
|
||||
# Optional: Enable semantic search (default: shown below)
|
||||
# Optional: Enable face recognition (default: shown below)
|
||||
enabled: False
|
||||
# Optional: Minimum face distance score required to mark as a potential match (default: shown below)
|
||||
unknown_score: 0.8
|
||||
@@ -556,10 +561,14 @@ face_recognition:
|
||||
recognition_threshold: 0.9
|
||||
# Optional: Min area of detected face box to consider running face recognition (default: shown below)
|
||||
min_area: 500
|
||||
# Optional: Min face attempts for the sub label to be applied to the person object (default: shown below)
|
||||
min_faces: 1
|
||||
# Optional: Number of images of recognized faces to save for training (default: shown below)
|
||||
save_attempts: 100
|
||||
# Optional: Apply a blur quality filter to adjust confidence based on the blur level of the image (default: shown below)
|
||||
blur_confidence_filter: True
|
||||
# Optional: Set the model size used face recognition. (default: shown below)
|
||||
model_size: small
|
||||
|
||||
# Optional: Configuration for license plate recognition capability
|
||||
# NOTE: enabled, min_area, and enhancement can be overridden at the camera level
|
||||
|
||||
@@ -19,7 +19,7 @@ For best performance, 16GB or more of RAM and a dedicated GPU are recommended.
|
||||
|
||||
## Configuration
|
||||
|
||||
Semantic Search is disabled by default, and must be enabled in your config file or in the UI's Settings page before it can be used. Semantic Search is a global configuration setting.
|
||||
Semantic Search is disabled by default, and must be enabled in your config file or in the UI's Enrichments Settings page before it can be used. Semantic Search is a global configuration setting.
|
||||
|
||||
```yaml
|
||||
semantic_search:
|
||||
@@ -29,9 +29,9 @@ semantic_search:
|
||||
|
||||
:::tip
|
||||
|
||||
The embeddings database can be re-indexed from the existing tracked objects in your database by adding `reindex: True` to your `semantic_search` configuration or by toggling the switch on the Search Settings page in the UI and restarting Frigate. Depending on the number of tracked objects you have, it can take a long while to complete and may max out your CPU while indexing. Make sure to turn the UI's switch off or set the config back to `False` before restarting Frigate again.
|
||||
The embeddings database can be re-indexed from the existing tracked objects in your database by pressing the "Reindex" button in the Enrichments Settings in the UI or by adding `reindex: True` to your `semantic_search` configuration and restarting Frigate. Depending on the number of tracked objects you have, it can take a long while to complete and may max out your CPU while indexing.
|
||||
|
||||
If you are enabling Semantic Search for the first time, be advised that Frigate does not automatically index older tracked objects. You will need to enable the `reindex` feature in order to do that.
|
||||
If you are enabling Semantic Search for the first time, be advised that Frigate does not automatically index older tracked objects. You will need to reindex as described above.
|
||||
|
||||
:::
|
||||
|
||||
@@ -72,7 +72,7 @@ For most users, especially native English speakers, the V1 model remains the rec
|
||||
|
||||
:::note
|
||||
|
||||
Switching between V1 and V2 requires reindexing your embeddings. To do this, set `reindex: True` in your Semantic Search configuration and restart Frigate. The embeddings from V1 and V2 are incompatible, and failing to reindex will result in incorrect search results.
|
||||
Switching between V1 and V2 requires reindexing your embeddings. The embeddings from V1 and V2 are incompatible, and failing to reindex will result in incorrect search results.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ title: Snapshots
|
||||
|
||||
Frigate can save a snapshot image to `/media/frigate/clips` for each object that is detected named as `<camera>-<id>.jpg`. They are also accessible [via the api](../integrations/api/event-snapshot-events-event-id-snapshot-jpg-get.api.mdx)
|
||||
|
||||
For users with Frigate+ enabled, snapshots are accessible in the UI in the Frigate+ pane to allow for quick submission to the Frigate+ service.
|
||||
Snapshots are accessible in the UI in the Explore pane. This allows for quick submission to the Frigate+ service.
|
||||
|
||||
To only save snapshots for objects that enter a specific zone, [see the zone docs](./zones.md#restricting-snapshots-to-specific-zones)
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ Your zone must be defined with exactly 4 points and should be aligned to the gro
|
||||
|
||||

|
||||
|
||||
Speed estimation requires a minimum number of frames for your object to be tracked before a valid estimate can be calculated, so create your zone away from places where objects enter and exit for the best results. _Your zone should not take up the full frame._ An object's speed is tracked while it is in the zone and then saved to Frigate's database.
|
||||
Speed estimation requires a minimum number of frames for your object to be tracked before a valid estimate can be calculated, so create your zone away from places where objects enter and exit for the best results. The object's bounding box must be stable and remain a constant size as it enters and exits the zone. _Your zone should not take up the full frame, and the zone does **not** need to be the same size or larger than the objects passing through it._ An object's speed is tracked while it passes through the zone and then saved to Frigate's database.
|
||||
|
||||
Accurate real-world distance measurements are required to estimate speeds. These distances can be specified in your zone config through the `distances` field.
|
||||
|
||||
@@ -165,8 +165,9 @@ These speed values are output as a number in miles per hour (mph) or kilometers
|
||||
|
||||
#### Best practices and caveats
|
||||
|
||||
- Speed estimation works best with a straight road or path when your object travels in a straight line across that path. Avoid creating your zone near intersections or anywhere that objects would make a turn. If the bounding box changes shape (either because the object made a turn or became partially obscured, for example), speed estimation will not be accurate.
|
||||
- Create a zone where the bottom center of your object's bounding box travels directly through it and does not become obscured at any time. See the photo example above.
|
||||
- Speed estimation works best with a straight road or path when your object travels in a straight line across that path. Avoid creating your zone near intersections or anywhere that objects would make a turn.
|
||||
- Create a zone where the bottom center of your object's bounding box travels directly through it and does not become obscured at any time.
|
||||
- A large zone can be used (as in the photo example above), but it may cause inaccurate estimation if the object's bounding box changes shape (such as when it turns or becomes partially hidden). Generally it's best to make your zone large enough to capture a few frames, but small enough so that the bounding box doesn't change size as it enters, travels through, and exits the zone.
|
||||
- Depending on the size and location of your zone, you may want to decrease the zone's `inertia` value from the default of 3.
|
||||
- The more accurate your real-world dimensions can be measured, the more accurate speed estimation will be. However, due to the way Frigate's tracking algorithm works, you may need to tweak the real-world distance values so that estimated speeds better match real-world speeds.
|
||||
- Once an object leaves the zone, speed accuracy will likely decrease due to perspective distortion and misalignment with the calibrated area. Therefore, speed values will show as a zero through MQTT and will not be visible on the debug view when an object is outside of a speed tracking zone.
|
||||
|
||||
@@ -143,9 +143,10 @@ Inference speeds will vary greatly depending on the GPU and the model used.
|
||||
|
||||
With the [rocm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many discrete AMD GPUs.
|
||||
|
||||
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time |
|
||||
| -------- | --------------------- | ------------------------- |
|
||||
| AMD 780M | ~ 14 ms | 320: ~ 30 ms 640: ~ 60 ms |
|
||||
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time |
|
||||
| --------- | --------------------- | ------------------------- |
|
||||
| AMD 780M | ~ 14 ms | 320: ~ 25 ms 640: ~ 50 ms |
|
||||
| AMD 8700G | | 320: ~ 20 ms 640: ~ 40 ms |
|
||||
|
||||
## Community Supported Detectors
|
||||
|
||||
|
||||
@@ -26,3 +26,7 @@ This is a fork (with fixed errors and new features) of [original Double Take](ht
|
||||
## [Frigate telegram](https://github.com/OldTyT/frigate-telegram)
|
||||
|
||||
[Frigate telegram](https://github.com/OldTyT/frigate-telegram) makes it possible to send events from Frigate to Telegram. Events are sent as a message with a text description, video, and thumbnail.
|
||||
|
||||
## [Periscope](https://github.com/maksz42/periscope)
|
||||
|
||||
[Periscope](https://github.com/maksz42/periscope) is a lightweight Android app that turns old devices into live viewers for Frigate. It works on Android 2.2 and above, including Android TV. It supports authentication and HTTPS.
|
||||
|
||||
@@ -17,6 +17,15 @@ const config: Config = {
|
||||
markdown: {
|
||||
mermaid: true,
|
||||
},
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en'],
|
||||
localeConfigs: {
|
||||
en: {
|
||||
label: 'English',
|
||||
}
|
||||
},
|
||||
},
|
||||
themeConfig: {
|
||||
algolia: {
|
||||
appId: 'WIURGBNBPY',
|
||||
@@ -82,6 +91,16 @@ const config: Config = {
|
||||
label: 'Demo',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
type: 'localeDropdown',
|
||||
position: 'right',
|
||||
dropdownItemsAfter: [
|
||||
{
|
||||
label: '简体中文(社区翻译)',
|
||||
href: 'https://docs.frigate-cn.video',
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/blakeblackshear/frigate',
|
||||
label: 'GitHub',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation } from '@docusaurus/router';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
export default function LanguageAlert() {
|
||||
const [showAlert, setShowAlert] = useState(false);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const userLanguage = navigator?.language || 'en';
|
||||
const isChineseUser = userLanguage.includes('zh');
|
||||
setShowAlert(isChineseUser);
|
||||
|
||||
}, [pathname]);
|
||||
|
||||
if (!showAlert) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.alert}>
|
||||
<span>检测到您的主要语言为中文,您可以访问由中文社区翻译的</span>
|
||||
<a href={'https://docs.frigate-cn.video'+pathname}>中文文档</a>
|
||||
<span> 以获得更好的体验</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.alert {
|
||||
padding: 12px;
|
||||
background: #fff8e6;
|
||||
border-bottom: 1px solid #ffd166;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.alert a {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import NavbarLayout from '@theme/Navbar/Layout';
|
||||
import NavbarContent from '@theme/Navbar/Content';
|
||||
import LanguageAlert from '../../components/LanguageAlert';
|
||||
|
||||
export default function Navbar() {
|
||||
return (
|
||||
<>
|
||||
<NavbarLayout>
|
||||
<NavbarContent />
|
||||
</NavbarLayout>
|
||||
<LanguageAlert />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Vendored
+2
@@ -2926,6 +2926,8 @@ paths:
|
||||
tags:
|
||||
- Media
|
||||
summary: Recording Clip
|
||||
description: >-
|
||||
For iOS devices, use the master.m3u8 HLS link instead of clip.mp4. Safari does not reliably process progressive mp4 files.
|
||||
operationId: recording_clip__camera_name__start__start_ts__end__end_ts__clip_mp4_get
|
||||
parameters:
|
||||
- name: camera_name
|
||||
|
||||
+5
-5
@@ -74,7 +74,7 @@ def go2rtc_streams():
|
||||
)
|
||||
stream_data = r.json()
|
||||
for data in stream_data.values():
|
||||
for producer in data.get("producers", []):
|
||||
for producer in data.get("producers") or []:
|
||||
producer["url"] = clean_camera_user_pass(producer.get("url", ""))
|
||||
return JSONResponse(content=stream_data)
|
||||
|
||||
@@ -131,7 +131,7 @@ def metrics(request: Request):
|
||||
@router.get("/config")
|
||||
def config(request: Request):
|
||||
config_obj: FrigateConfig = request.app.frigate_config
|
||||
config: dict[str, dict[str, any]] = config_obj.model_dump(
|
||||
config: dict[str, dict[str, Any]] = config_obj.model_dump(
|
||||
mode="json", warnings="none", exclude_none=True
|
||||
)
|
||||
|
||||
@@ -158,7 +158,7 @@ def config(request: Request):
|
||||
camera_dict["zones"][zone_name]["color"] = zone.color
|
||||
|
||||
# remove go2rtc stream passwords
|
||||
go2rtc: dict[str, any] = config_obj.go2rtc.model_dump(
|
||||
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
|
||||
mode="json", warnings="none", exclude_none=True
|
||||
)
|
||||
for stream_name, stream in go2rtc.get("streams", {}).items():
|
||||
@@ -648,7 +648,7 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
models: dict[any, any] = request.app.frigate_config.plus_api.get_models()
|
||||
models: dict[Any, Any] = request.app.frigate_config.plus_api.get_models()
|
||||
|
||||
if not models["list"]:
|
||||
return JSONResponse(
|
||||
@@ -801,7 +801,7 @@ def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
|
||||
count = 0
|
||||
start = 0
|
||||
end = 0
|
||||
hours: dict[str, list[dict[str, any]]] = {}
|
||||
hours: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
for t in timeline:
|
||||
if count == 0:
|
||||
|
||||
+14
-5
@@ -202,9 +202,15 @@ async def get_current_user(request: Request):
|
||||
|
||||
def require_role(required_roles: List[str]):
|
||||
async def role_checker(request: Request):
|
||||
proxy_config: ProxyConfig = request.app.frigate_config.proxy
|
||||
|
||||
# Get role from header (could be comma-separated)
|
||||
role_header = request.headers.get("remote-role")
|
||||
roles = [r.strip() for r in role_header.split(",")] if role_header else []
|
||||
roles = (
|
||||
[r.strip() for r in role_header.split(proxy_config.separator)]
|
||||
if role_header
|
||||
else []
|
||||
)
|
||||
|
||||
# Check if we have any roles
|
||||
if not roles:
|
||||
@@ -261,14 +267,17 @@ def auth(request: Request):
|
||||
|
||||
role_header = proxy_config.header_map.role
|
||||
role = (
|
||||
request.headers.get(role_header, default="viewer")
|
||||
request.headers.get(role_header, default=proxy_config.default_role)
|
||||
if role_header
|
||||
else "viewer"
|
||||
else proxy_config.default_role
|
||||
)
|
||||
|
||||
# if comma-separated with "admin", use "admin", else "viewer"
|
||||
# if comma-separated with "admin", use "admin", else use default role
|
||||
success_response.headers["remote-role"] = (
|
||||
"admin" if role and "admin" in role else "viewer"
|
||||
"admin"
|
||||
if role
|
||||
and "admin" in [r.strip() for r in role.split(proxy_config.separator)]
|
||||
else proxy_config.default_role
|
||||
)
|
||||
|
||||
return success_response
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Object classification APIs."""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
from fastapi import APIRouter, Depends, Request, UploadFile
|
||||
@@ -59,7 +59,7 @@ def reclassify_face(request: Request, body: dict = None):
|
||||
content={"message": "Face recognition is not enabled.", "success": False},
|
||||
)
|
||||
|
||||
json: dict[str, any] = body or {}
|
||||
json: dict[str, Any] = body or {}
|
||||
training_file = os.path.join(
|
||||
FACE_DIR, f"train/{sanitize_filename(json.get('training_file', ''))}"
|
||||
)
|
||||
@@ -92,7 +92,7 @@ def train_face(request: Request, name: str, body: dict = None):
|
||||
content={"message": "Face recognition is not enabled.", "success": False},
|
||||
)
|
||||
|
||||
json: dict[str, any] = body or {}
|
||||
json: dict[str, Any] = body or {}
|
||||
training_file_name = sanitize_filename(json.get("training_file", ""))
|
||||
training_file = os.path.join(FACE_DIR, f"train/{training_file_name}")
|
||||
event_id = json.get("event_id")
|
||||
@@ -120,8 +120,7 @@ def train_face(request: Request, name: str, body: dict = None):
|
||||
)
|
||||
|
||||
sanitized_name = sanitize_filename(name)
|
||||
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||
new_name = f"{sanitized_name}-{rand_id}.webp"
|
||||
new_name = f"{sanitized_name}-{datetime.datetime.now().timestamp()}.webp"
|
||||
new_file_folder = os.path.join(FACE_DIR, f"{sanitized_name}")
|
||||
|
||||
if not os.path.exists(new_file_folder):
|
||||
@@ -248,7 +247,7 @@ def deregister_faces(request: Request, name: str, body: dict = None):
|
||||
content={"message": "Face recognition is not enabled.", "success": False},
|
||||
)
|
||||
|
||||
json: dict[str, any] = body or {}
|
||||
json: dict[str, Any] = body or {}
|
||||
list_of_ids = json.get("ids", "")
|
||||
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
|
||||
+48
-16
@@ -1,5 +1,6 @@
|
||||
"""Image and video apis."""
|
||||
|
||||
import asyncio
|
||||
import glob
|
||||
import logging
|
||||
import math
|
||||
@@ -8,6 +9,7 @@ import subprocess as sp
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path as FilePath
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
import cv2
|
||||
@@ -88,7 +90,7 @@ def imagestream(
|
||||
camera_name: str,
|
||||
fps: int,
|
||||
height: int,
|
||||
draw_options: dict[str, any],
|
||||
draw_options: dict[str, Any],
|
||||
):
|
||||
while True:
|
||||
# max out at specified FPS
|
||||
@@ -110,9 +112,12 @@ def imagestream(
|
||||
@router.get("/{camera_name}/ptz/info")
|
||||
async def camera_ptz_info(request: Request, camera_name: str):
|
||||
if camera_name in request.app.frigate_config.cameras:
|
||||
return JSONResponse(
|
||||
content=await request.app.onvif.get_camera_info(camera_name),
|
||||
# Schedule get_camera_info in the OnvifController's event loop
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
request.app.onvif.get_camera_info(camera_name), request.app.onvif.loop
|
||||
)
|
||||
result = future.result()
|
||||
return JSONResponse(content=result)
|
||||
else:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Camera not found"},
|
||||
@@ -537,7 +542,10 @@ def recordings(
|
||||
return JSONResponse(content=list(recordings))
|
||||
|
||||
|
||||
@router.get("/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4")
|
||||
@router.get(
|
||||
"/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4",
|
||||
description="For iOS devices, use the master.m3u8 HLS link instead of clip.mp4. Safari does not reliably process progressive mp4 files.",
|
||||
)
|
||||
def recording_clip(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
@@ -585,10 +593,12 @@ def recording_clip(
|
||||
clip: Recordings
|
||||
for clip in recordings:
|
||||
file.write(f"file '{clip.path}'\n")
|
||||
|
||||
# if this is the starting clip, add an inpoint
|
||||
if clip.start_time < start_ts:
|
||||
file.write(f"inpoint {int(start_ts - clip.start_time)}\n")
|
||||
# if this is the ending clip, add an outpoint
|
||||
|
||||
# if this is the ending clip and end trim is enabled, add an outpoint
|
||||
if clip.end_time > end_ts:
|
||||
file.write(f"outpoint {int(end_ts - clip.start_time)}\n")
|
||||
|
||||
@@ -630,10 +640,18 @@ def recording_clip(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/vod/{camera_name}/start/{start_ts}/end/{end_ts}")
|
||||
@router.get(
|
||||
"/vod/{camera_name}/start/{start_ts}/end/{end_ts}",
|
||||
description="Returns an HLS playlist for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.",
|
||||
)
|
||||
def vod_ts(camera_name: str, start_ts: float, end_ts: float):
|
||||
recordings = (
|
||||
Recordings.select(Recordings.path, Recordings.duration, Recordings.end_time)
|
||||
Recordings.select(
|
||||
Recordings.path,
|
||||
Recordings.duration,
|
||||
Recordings.end_time,
|
||||
Recordings.start_time,
|
||||
)
|
||||
.where(
|
||||
Recordings.start_time.between(start_ts, end_ts)
|
||||
| Recordings.end_time.between(start_ts, end_ts)
|
||||
@@ -653,14 +671,19 @@ def vod_ts(camera_name: str, start_ts: float, end_ts: float):
|
||||
clip = {"type": "source", "path": recording.path}
|
||||
duration = int(recording.duration * 1000)
|
||||
|
||||
# Determine if we need to end the last clip early
|
||||
# adjust start offset if start_ts is after recording.start_time
|
||||
if start_ts > recording.start_time:
|
||||
inpoint = int((start_ts - recording.start_time) * 1000)
|
||||
clip["clipFrom"] = inpoint
|
||||
duration -= inpoint
|
||||
|
||||
# adjust end if recording.end_time is after end_ts
|
||||
if recording.end_time > end_ts:
|
||||
duration -= int((recording.end_time - end_ts) * 1000)
|
||||
|
||||
if duration == 0:
|
||||
# this means the segment starts right at the end of the requested time range
|
||||
# and it does not need to be included
|
||||
continue
|
||||
if duration <= 0:
|
||||
# skip if the clip has no valid duration
|
||||
continue
|
||||
|
||||
if 0 < duration < max_duration_ms:
|
||||
clip["keyFrameDurations"] = [duration]
|
||||
@@ -694,7 +717,10 @@ def vod_ts(camera_name: str, start_ts: float, end_ts: float):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/vod/{year_month}/{day}/{hour}/{camera_name}")
|
||||
@router.get(
|
||||
"/vod/{year_month}/{day}/{hour}/{camera_name}",
|
||||
description="Returns an HLS playlist for the specified date-time on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.",
|
||||
)
|
||||
def vod_hour_no_timezone(year_month: str, day: int, hour: int, camera_name: str):
|
||||
"""VOD for specific hour. Uses the default timezone (UTC)."""
|
||||
return vod_hour(
|
||||
@@ -702,7 +728,10 @@ def vod_hour_no_timezone(year_month: str, day: int, hour: int, camera_name: str)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}")
|
||||
@router.get(
|
||||
"/vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}",
|
||||
description="Returns an HLS playlist for the specified date-time (with timezone) on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.",
|
||||
)
|
||||
def vod_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str):
|
||||
parts = year_month.split("-")
|
||||
start_date = (
|
||||
@@ -716,7 +745,10 @@ def vod_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: st
|
||||
return vod_ts(camera_name, start_ts, end_ts)
|
||||
|
||||
|
||||
@router.get("/vod/event/{event_id}")
|
||||
@router.get(
|
||||
"/vod/event/{event_id}",
|
||||
description="Returns an HLS playlist for the specified object. Append /master.m3u8 or /index.m3u8 for HLS playback.",
|
||||
)
|
||||
def vod_event(event_id: str):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
@@ -902,7 +934,7 @@ def event_thumbnail(
|
||||
elif extension == "webp":
|
||||
quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), 60]
|
||||
|
||||
_, img = cv2.imencode(f".{img}", thumbnail, quality_params)
|
||||
_, img = cv2.imencode(f".{extension}", thumbnail, quality_params)
|
||||
thumbnail_bytes = img.tobytes()
|
||||
|
||||
return Response(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from fastapi import APIRouter, Request
|
||||
@@ -41,7 +42,7 @@ def register_notifications(request: Request, body: dict = None):
|
||||
else:
|
||||
username = "admin"
|
||||
|
||||
json: dict[str, any] = body or {}
|
||||
json: dict[str, Any] = body or {}
|
||||
sub = json.get("sub")
|
||||
|
||||
if not sub:
|
||||
|
||||
+14
-2
@@ -6,6 +6,7 @@ import secrets
|
||||
import shutil
|
||||
from multiprocessing import Queue
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import psutil
|
||||
@@ -44,6 +45,7 @@ from frigate.embeddings import EmbeddingsContext, manage_embeddings
|
||||
from frigate.events.audio import AudioProcessor
|
||||
from frigate.events.cleanup import EventCleanup
|
||||
from frigate.events.maintainer import EventProcessor
|
||||
from frigate.log import _stop_logging
|
||||
from frigate.models import (
|
||||
Event,
|
||||
Export,
|
||||
@@ -438,7 +440,7 @@ class FrigateApp:
|
||||
|
||||
def start_camera_processors(self) -> None:
|
||||
for name, config in self.config.cameras.items():
|
||||
if not self.config.cameras[name].enabled:
|
||||
if not self.config.cameras[name].enabled_in_config:
|
||||
logger.info(f"Camera processor not started for disabled camera {name}")
|
||||
continue
|
||||
|
||||
@@ -467,7 +469,7 @@ class FrigateApp:
|
||||
shm_frame_count = self.shm_frame_count()
|
||||
|
||||
for name, config in self.config.cameras.items():
|
||||
if not self.config.cameras[name].enabled:
|
||||
if not self.config.cameras[name].enabled_in_config:
|
||||
logger.info(f"Capture process not started for disabled camera {name}")
|
||||
continue
|
||||
|
||||
@@ -684,6 +686,9 @@ class FrigateApp:
|
||||
def stop(self) -> None:
|
||||
logger.info("Stopping...")
|
||||
|
||||
# used by the docker healthcheck
|
||||
Path("/dev/shm/.frigate-is-stopping").touch()
|
||||
|
||||
self.stop_event.set()
|
||||
|
||||
# set an end_time on entries without an end_time before exiting
|
||||
@@ -699,6 +704,10 @@ class FrigateApp:
|
||||
self.audio_process.terminate()
|
||||
self.audio_process.join()
|
||||
|
||||
# stop the onvif controller
|
||||
if self.onvif_controller:
|
||||
self.onvif_controller.close()
|
||||
|
||||
# ensure the capture processes are done
|
||||
for camera, metrics in self.camera_metrics.items():
|
||||
capture_process = metrics.capture_process
|
||||
@@ -767,4 +776,7 @@ class FrigateApp:
|
||||
shm.close()
|
||||
shm.unlink()
|
||||
|
||||
# exit the mp Manager process
|
||||
_stop_logging()
|
||||
|
||||
os._exit(os.EX_OK)
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"""Manage camera activity and updating listeners."""
|
||||
|
||||
from collections import Counter
|
||||
from typing import Callable
|
||||
from typing import Any, Callable
|
||||
|
||||
from frigate.config.config import FrigateConfig
|
||||
|
||||
|
||||
class CameraActivityManager:
|
||||
def __init__(
|
||||
self, config: FrigateConfig, publish: Callable[[str, any], None]
|
||||
self, config: FrigateConfig, publish: Callable[[str, Any], None]
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.publish = publish
|
||||
self.last_camera_activity: dict[str, dict[str, any]] = {}
|
||||
self.last_camera_activity: dict[str, dict[str, Any]] = {}
|
||||
self.camera_all_object_counts: dict[str, Counter] = {}
|
||||
self.camera_active_object_counts: dict[str, Counter] = {}
|
||||
self.zone_all_object_counts: dict[str, Counter] = {}
|
||||
@@ -39,8 +39,8 @@ class CameraActivityManager:
|
||||
else camera_config.objects.track
|
||||
)
|
||||
|
||||
def update_activity(self, new_activity: dict[str, dict[str, any]]) -> None:
|
||||
all_objects: list[dict[str, any]] = []
|
||||
def update_activity(self, new_activity: dict[str, dict[str, Any]]) -> None:
|
||||
all_objects: list[dict[str, Any]] = []
|
||||
|
||||
for camera in new_activity.keys():
|
||||
new_objects = new_activity[camera].get("objects", [])
|
||||
@@ -93,7 +93,7 @@ class CameraActivityManager:
|
||||
self.last_camera_activity = new_activity
|
||||
|
||||
def compare_camera_activity(
|
||||
self, camera: str, new_activity: dict[str, any]
|
||||
self, camera: str, new_activity: dict[str, Any]
|
||||
) -> None:
|
||||
all_objects = Counter(
|
||||
obj["label"].replace("-verified", "") for obj in new_activity
|
||||
|
||||
@@ -172,6 +172,7 @@ class CameraState:
|
||||
# draw any attributes
|
||||
for attribute in obj["current_attributes"]:
|
||||
box = attribute["box"]
|
||||
box_area = int((box[2] - box[0]) * (box[3] - box[1]))
|
||||
draw_box_with_label(
|
||||
frame_copy,
|
||||
box[0],
|
||||
@@ -179,7 +180,7 @@ class CameraState:
|
||||
box[2],
|
||||
box[3],
|
||||
attribute["label"],
|
||||
f"{attribute['score']:.0%}",
|
||||
f"{attribute['score']:.0%} {str(box_area)}",
|
||||
thickness=thickness,
|
||||
color=color,
|
||||
)
|
||||
@@ -239,7 +240,7 @@ class CameraState:
|
||||
self,
|
||||
frame_name: str,
|
||||
frame_time: float,
|
||||
current_detections: dict[str, dict[str, any]],
|
||||
current_detections: dict[str, dict[str, Any]],
|
||||
motion_boxes: list[tuple[int, int, int, int]],
|
||||
regions: list[tuple[int, int, int, int]],
|
||||
):
|
||||
@@ -282,9 +283,13 @@ class CameraState:
|
||||
}
|
||||
new_obj.thumbnail_data = thumbnail_data
|
||||
tracked_objects[id].thumbnail_data = thumbnail_data
|
||||
self.best_objects[new_obj.obj_data["label"]] = new_obj
|
||||
object_type = new_obj.obj_data["label"]
|
||||
self.best_objects[object_type] = new_obj
|
||||
|
||||
# call event handlers
|
||||
for c in self.callbacks["snapshot"]:
|
||||
c(self.name, self.best_objects[object_type], frame_name)
|
||||
|
||||
for c in self.callbacks["start"]:
|
||||
c(self.name, new_obj, frame_name)
|
||||
|
||||
@@ -337,7 +342,7 @@ class CameraState:
|
||||
|
||||
# TODO: can i switch to looking this up and only changing when an event ends?
|
||||
# maintain best objects
|
||||
camera_activity: dict[str, list[any]] = {
|
||||
camera_activity: dict[str, list[Any]] = {
|
||||
"motion": len(motion_boxes) > 0,
|
||||
"objects": [],
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import multiprocessing as mp
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import zmq
|
||||
|
||||
@@ -18,7 +18,7 @@ class ConfigPublisher:
|
||||
self.socket.bind(SOCKET_PUB_SUB)
|
||||
self.stop_event: MpEvent = mp.Event()
|
||||
|
||||
def publish(self, topic: str, payload: any) -> None:
|
||||
def publish(self, topic: str, payload: Any) -> None:
|
||||
"""There is no communication back to the processes."""
|
||||
self.socket.send_string(topic, flags=zmq.SNDMORE)
|
||||
self.socket.send_pyobj(payload)
|
||||
@@ -40,7 +40,7 @@ class ConfigSubscriber:
|
||||
self.socket.setsockopt_string(zmq.SUBSCRIBE, topic)
|
||||
self.socket.connect(SOCKET_PUB_SUB)
|
||||
|
||||
def check_for_update(self) -> Optional[tuple[str, any]]:
|
||||
def check_for_update(self) -> Optional[tuple[str, Any]]:
|
||||
"""Returns updated config or None if no update."""
|
||||
try:
|
||||
topic = self.socket.recv_string(flags=zmq.NOBLOCK)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Facilitates communication between processes."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from .zmq_proxy import Publisher, Subscriber
|
||||
|
||||
@@ -35,10 +35,10 @@ class DetectionSubscriber(Subscriber):
|
||||
|
||||
def check_for_update(
|
||||
self, timeout: float = None
|
||||
) -> Optional[tuple[DetectionTypeEnum, any]]:
|
||||
) -> Optional[tuple[DetectionTypeEnum, Any]]:
|
||||
return super().check_for_update(timeout)
|
||||
|
||||
def _return_object(self, topic: str, payload: any) -> any:
|
||||
def _return_object(self, topic: str, payload: Any) -> Any:
|
||||
if payload is None:
|
||||
return (None, None)
|
||||
return (DetectionTypeEnum[topic[len(self.topic_base) :]], payload)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Facilitates communication between processes."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Callable
|
||||
from typing import Any, Callable
|
||||
|
||||
import zmq
|
||||
|
||||
@@ -58,7 +58,7 @@ class EmbeddingsRequestor:
|
||||
self.socket = self.context.socket(zmq.REQ)
|
||||
self.socket.connect(SOCKET_REP_REQ)
|
||||
|
||||
def send_data(self, topic: str, data: any) -> str:
|
||||
def send_data(self, topic: str, data: Any) -> str:
|
||||
"""Sends data and then waits for reply."""
|
||||
try:
|
||||
self.socket.send_json((topic, data))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from .zmq_proxy import Publisher, Subscriber
|
||||
|
||||
@@ -27,7 +28,7 @@ class EventMetadataPublisher(Publisher):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
def publish(self, topic: EventMetadataTypeEnum, payload: any) -> None:
|
||||
def publish(self, topic: EventMetadataTypeEnum, payload: Any) -> None:
|
||||
super().publish(payload, topic.value)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Facilitates communication between processes."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||
|
||||
from .zmq_proxy import Publisher, Subscriber
|
||||
@@ -14,7 +16,7 @@ class EventUpdatePublisher(Publisher):
|
||||
super().__init__("update")
|
||||
|
||||
def publish(
|
||||
self, payload: tuple[EventTypeEnum, EventStateEnum, str, str, dict[str, any]]
|
||||
self, payload: tuple[EventTypeEnum, EventStateEnum, str, str, dict[str, Any]]
|
||||
) -> None:
|
||||
super().publish(payload)
|
||||
|
||||
@@ -37,7 +39,7 @@ class EventEndPublisher(Publisher):
|
||||
super().__init__("finalized")
|
||||
|
||||
def publish(
|
||||
self, payload: tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]
|
||||
self, payload: tuple[EventTypeEnum, EventStateEnum, str, dict[str, Any]]
|
||||
) -> None:
|
||||
super().publish(payload)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import multiprocessing as mp
|
||||
import threading
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Callable
|
||||
from typing import Any, Callable
|
||||
|
||||
import zmq
|
||||
|
||||
@@ -63,7 +63,7 @@ class InterProcessRequestor:
|
||||
self.socket = self.context.socket(zmq.REQ)
|
||||
self.socket.connect(SOCKET_REP_REQ)
|
||||
|
||||
def send_data(self, topic: str, data: any) -> any:
|
||||
def send_data(self, topic: str, data: Any) -> Any:
|
||||
"""Sends data and then waits for reply."""
|
||||
try:
|
||||
self.socket.send_json((topic, data))
|
||||
|
||||
@@ -213,6 +213,8 @@ class MqttClient(Communicator): # type: ignore[misc]
|
||||
"motion_contour_area",
|
||||
"birdseye",
|
||||
"birdseye_mode",
|
||||
"review_alerts",
|
||||
"review_detections",
|
||||
]
|
||||
|
||||
for name in self.config.cameras.keys():
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Callable
|
||||
|
||||
from py_vapid import Vapid01
|
||||
from pywebpush import WebPusher
|
||||
from titlecase import titlecase
|
||||
|
||||
from frigate.comms.base_communicator import Communicator
|
||||
from frigate.comms.config_updater import ConfigSubscriber
|
||||
@@ -173,7 +174,12 @@ class WebPushClient(Communicator): # type: ignore[misc]
|
||||
return
|
||||
self.send_alert(decoded)
|
||||
elif topic == "notification_test":
|
||||
if not self.config.notifications.enabled:
|
||||
if not self.config.notifications.enabled and not any(
|
||||
cam.notifications.enabled for cam in self.config.cameras.values()
|
||||
):
|
||||
logger.debug(
|
||||
"No cameras have notifications enabled, test notification not sent"
|
||||
)
|
||||
return
|
||||
self.send_notification_test()
|
||||
|
||||
@@ -303,6 +309,9 @@ class WebPushClient(Communicator): # type: ignore[misc]
|
||||
and len(payload["before"]["data"]["zones"])
|
||||
== len(payload["after"]["data"]["zones"])
|
||||
):
|
||||
logger.debug(
|
||||
f"Skipping notification for {camera} - message is an update and important fields don't have an update"
|
||||
)
|
||||
return
|
||||
|
||||
self.last_camera_notification_time[camera] = current_time
|
||||
@@ -317,14 +326,16 @@ class WebPushClient(Communicator): # type: ignore[misc]
|
||||
|
||||
sorted_objects.update(payload["after"]["data"]["sub_labels"])
|
||||
|
||||
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()}"
|
||||
title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {titlecase(', '.join(payload['after']['data']['zones']).replace('_', ' '))}"
|
||||
message = f"Detected on {titlecase(camera.replace('_', ' '))}"
|
||||
image = f"{payload['after']['thumb_path'].replace('/media/frigate', '')}"
|
||||
|
||||
# if event is ongoing open to live view otherwise open to recordings view
|
||||
direct_url = f"/review?id={reviewId}" if state == "end" else f"/#{camera}"
|
||||
ttl = 3600 if state == "end" else 0
|
||||
|
||||
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
|
||||
|
||||
for user in self.web_pushers:
|
||||
self.send_push_notification(
|
||||
user=user,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import json
|
||||
import threading
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import zmq
|
||||
|
||||
@@ -58,7 +58,7 @@ class Publisher:
|
||||
self.socket = self.context.socket(zmq.PUB)
|
||||
self.socket.connect(SOCKET_PUB)
|
||||
|
||||
def publish(self, payload: any, sub_topic: str = "") -> None:
|
||||
def publish(self, payload: Any, sub_topic: str = "") -> None:
|
||||
"""Publish message."""
|
||||
self.socket.send_string(f"{self.topic}{sub_topic} {json.dumps(payload)}")
|
||||
|
||||
@@ -81,7 +81,7 @@ class Subscriber:
|
||||
|
||||
def check_for_update(
|
||||
self, timeout: float = FAST_QUEUE_TIMEOUT
|
||||
) -> Optional[tuple[str, any]]:
|
||||
) -> Optional[tuple[str, Any]]:
|
||||
"""Returns message or None if no update."""
|
||||
try:
|
||||
has_update, _, _ = zmq.select([self.socket], [], [], timeout)
|
||||
@@ -98,5 +98,5 @@ class Subscriber:
|
||||
self.socket.close()
|
||||
self.context.destroy()
|
||||
|
||||
def _return_object(self, topic: str, payload: any) -> any:
|
||||
def _return_object(self, topic: str, payload: Any) -> Any:
|
||||
return payload
|
||||
|
||||
@@ -78,7 +78,13 @@ class FaceRecognitionConfig(FrigateBaseModel):
|
||||
le=1.0,
|
||||
)
|
||||
min_area: int = Field(
|
||||
default=500, title="Min area of face box to consider running face recognition."
|
||||
default=750, title="Min area of face box to consider running face recognition."
|
||||
)
|
||||
min_faces: int = Field(
|
||||
default=1,
|
||||
gt=0,
|
||||
le=6,
|
||||
title="Min face attempts for the sub label to be applied to the person object.",
|
||||
)
|
||||
save_attempts: int = Field(
|
||||
default=100, ge=0, title="Number of face attempts to save in the train tab."
|
||||
@@ -91,7 +97,7 @@ class FaceRecognitionConfig(FrigateBaseModel):
|
||||
class CameraFaceRecognitionConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=False, title="Enable face recognition.")
|
||||
min_area: int = Field(
|
||||
default=500, title="Min area of face box to consider running face recognition."
|
||||
default=750, title="Min area of face box to consider running face recognition."
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
+48
-30
@@ -299,6 +299,22 @@ def verify_motion_and_detect(camera_config: CameraConfig) -> ValueError | None:
|
||||
)
|
||||
|
||||
|
||||
def verify_objects_track(
|
||||
camera_config: CameraConfig, enabled_objects: list[str]
|
||||
) -> None:
|
||||
"""Verify that a user has not specified an object to track that is not in the labelmap."""
|
||||
valid_objects = [
|
||||
obj for obj in camera_config.objects.track if obj in enabled_objects
|
||||
]
|
||||
|
||||
if len(valid_objects) != len(camera_config.objects.track):
|
||||
invalid_objects = set(camera_config.objects.track) - set(valid_objects)
|
||||
logger.warning(
|
||||
f"{camera_config.name} is configured to track {list(invalid_objects)} objects, which are not supported by the current model."
|
||||
)
|
||||
camera_config.objects.track = valid_objects
|
||||
|
||||
|
||||
def verify_lpr_and_face(
|
||||
frigate_config: FrigateConfig, camera_config: CameraConfig
|
||||
) -> ValueError | None:
|
||||
@@ -471,6 +487,37 @@ class FrigateConfig(FrigateBaseModel):
|
||||
exclude_unset=True,
|
||||
)
|
||||
|
||||
for key, detector in self.detectors.items():
|
||||
adapter = TypeAdapter(DetectorConfig)
|
||||
model_dict = (
|
||||
detector
|
||||
if isinstance(detector, dict)
|
||||
else detector.model_dump(warnings="none")
|
||||
)
|
||||
detector_config: BaseDetectorConfig = adapter.validate_python(model_dict)
|
||||
|
||||
# users should not set model themselves
|
||||
if detector_config.model:
|
||||
detector_config.model = None
|
||||
|
||||
model_config = self.model.model_dump(exclude_unset=True, warnings="none")
|
||||
|
||||
if detector_config.model_path:
|
||||
model_config["path"] = detector_config.model_path
|
||||
|
||||
if "path" not in model_config:
|
||||
if detector_config.type == "cpu":
|
||||
model_config["path"] = "/cpu_model.tflite"
|
||||
elif detector_config.type == "edgetpu":
|
||||
model_config["path"] = "/edgetpu_model.tflite"
|
||||
|
||||
model = ModelConfig.model_validate(model_config)
|
||||
model.check_and_load_plus_model(self.plus_api, detector_config.type)
|
||||
model.compute_model_hash()
|
||||
labelmap_objects = model.merged_labelmap.values()
|
||||
detector_config.model = model
|
||||
self.detectors[key] = detector_config
|
||||
|
||||
for name, camera in self.cameras.items():
|
||||
modified_global_config = global_config.copy()
|
||||
|
||||
@@ -644,6 +691,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
verify_required_zones_exist(camera_config)
|
||||
verify_autotrack_zones(camera_config)
|
||||
verify_motion_and_detect(camera_config)
|
||||
verify_objects_track(camera_config, labelmap_objects)
|
||||
verify_lpr_and_face(self, camera_config)
|
||||
|
||||
self.objects.parse_all_objects(self.cameras)
|
||||
@@ -655,36 +703,6 @@ class FrigateConfig(FrigateBaseModel):
|
||||
"Frigate+ is configured but clean snapshots are not enabled, submissions to Frigate+ will not be possible./"
|
||||
)
|
||||
|
||||
for key, detector in self.detectors.items():
|
||||
adapter = TypeAdapter(DetectorConfig)
|
||||
model_dict = (
|
||||
detector
|
||||
if isinstance(detector, dict)
|
||||
else detector.model_dump(warnings="none")
|
||||
)
|
||||
detector_config: BaseDetectorConfig = adapter.validate_python(model_dict)
|
||||
|
||||
# users should not set model themselves
|
||||
if detector_config.model:
|
||||
detector_config.model = None
|
||||
|
||||
model_config = self.model.model_dump(exclude_unset=True, warnings="none")
|
||||
|
||||
if detector_config.model_path:
|
||||
model_config["path"] = detector_config.model_path
|
||||
|
||||
if "path" not in model_config:
|
||||
if detector_config.type == "cpu":
|
||||
model_config["path"] = "/cpu_model.tflite"
|
||||
elif detector_config.type == "edgetpu":
|
||||
model_config["path"] = "/edgetpu_model.tflite"
|
||||
|
||||
model = ModelConfig.model_validate(model_config)
|
||||
model.check_and_load_plus_model(self.plus_api, detector_config.type)
|
||||
model.compute_model_hash()
|
||||
detector_config.model = model
|
||||
self.detectors[key] = detector_config
|
||||
|
||||
return self
|
||||
|
||||
@field_validator("cameras")
|
||||
|
||||
+15
-1
@@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from .base import FrigateBaseModel
|
||||
from .env import EnvString
|
||||
@@ -30,3 +30,17 @@ class ProxyConfig(FrigateBaseModel):
|
||||
default=None,
|
||||
title="Secret value for proxy authentication.",
|
||||
)
|
||||
default_role: Optional[str] = Field(
|
||||
default="viewer", title="Default role for proxy users."
|
||||
)
|
||||
separator: Optional[str] = Field(
|
||||
default=",",
|
||||
title="The character used to separate values in a mapped header.",
|
||||
)
|
||||
|
||||
@field_validator("separator", mode="before")
|
||||
@classmethod
|
||||
def validate_separator_length(cls, v):
|
||||
if v is not None and len(v) != 1:
|
||||
raise ValueError("Separator must be exactly one character")
|
||||
return v
|
||||
|
||||
@@ -10,7 +10,7 @@ import random
|
||||
import re
|
||||
import string
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -25,7 +25,7 @@ from frigate.comms.event_metadata_updater import (
|
||||
from frigate.const import CLIPS_DIR
|
||||
from frigate.embeddings.onnx.lpr_embedding import LPR_EMBEDDING_SIZE
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.builtin import EventsPerSecond
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
|
||||
from frigate.util.image import area
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -36,8 +36,10 @@ WRITE_DEBUG_IMAGES = False
|
||||
class LicensePlateProcessingMixin:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.plate_rec_speed = InferenceSpeed(self.metrics.alpr_speed)
|
||||
self.plates_rec_second = EventsPerSecond()
|
||||
self.plates_rec_second.start()
|
||||
self.plate_det_speed = InferenceSpeed(self.metrics.yolov9_lpr_speed)
|
||||
self.plates_det_second = EventsPerSecond()
|
||||
self.plates_det_second.start()
|
||||
self.event_metadata_publisher = EventMetadataPublisher()
|
||||
@@ -79,7 +81,12 @@ class LicensePlateProcessingMixin:
|
||||
resized_image,
|
||||
)
|
||||
|
||||
outputs = self.model_runner.detection_model([normalized_image])[0]
|
||||
try:
|
||||
outputs = self.model_runner.detection_model([normalized_image])[0]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LPR box detection model: {e}")
|
||||
return []
|
||||
|
||||
outputs = outputs[0, :, :]
|
||||
|
||||
if False:
|
||||
@@ -115,7 +122,11 @@ class LicensePlateProcessingMixin:
|
||||
norm_img = norm_img[np.newaxis, :]
|
||||
norm_images.append(norm_img)
|
||||
|
||||
outputs = self.model_runner.classification_model(norm_images)
|
||||
try:
|
||||
outputs = self.model_runner.classification_model(norm_images)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LPR classification model: {e}")
|
||||
return
|
||||
|
||||
return self._process_classification_output(images, outputs)
|
||||
|
||||
@@ -152,7 +163,10 @@ class LicensePlateProcessingMixin:
|
||||
norm_image = norm_image[np.newaxis, :]
|
||||
norm_images.append(norm_image)
|
||||
|
||||
outputs = self.model_runner.recognition_model(norm_images)
|
||||
try:
|
||||
outputs = self.model_runner.recognition_model(norm_images)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LPR recognition model: {e}")
|
||||
return self.ctc_decoder(outputs)
|
||||
|
||||
def _process_license_plate(
|
||||
@@ -968,7 +982,11 @@ class LicensePlateProcessingMixin:
|
||||
|
||||
Return the dimensions of the detected plate as [x1, y1, x2, y2].
|
||||
"""
|
||||
predictions = self.model_runner.yolov9_detection_model(input)
|
||||
try:
|
||||
predictions = self.model_runner.yolov9_detection_model(input)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running YOLOv9 license plate detection model: {e}")
|
||||
return None
|
||||
|
||||
confidence_threshold = self.lpr_config.detection_threshold
|
||||
|
||||
@@ -1141,22 +1159,6 @@ class LicensePlateProcessingMixin:
|
||||
# 5. Return True if previous plate scores higher
|
||||
return prev_score > curr_score
|
||||
|
||||
def __update_yolov9_metrics(self, duration: float) -> None:
|
||||
"""
|
||||
Update inference metrics.
|
||||
"""
|
||||
self.metrics.yolov9_lpr_speed.value = (
|
||||
self.metrics.yolov9_lpr_speed.value * 9 + duration
|
||||
) / 10
|
||||
|
||||
def __update_lpr_metrics(self, duration: float) -> None:
|
||||
"""
|
||||
Update inference metrics.
|
||||
"""
|
||||
self.metrics.alpr_speed.value = (
|
||||
self.metrics.alpr_speed.value * 9 + duration
|
||||
) / 10
|
||||
|
||||
def _generate_plate_event(self, camera: str, plate: str, plate_score: float) -> str:
|
||||
"""Generate a unique ID for a plate event based on camera and text."""
|
||||
now = datetime.datetime.now().timestamp()
|
||||
@@ -1179,7 +1181,7 @@ class LicensePlateProcessingMixin:
|
||||
return event_id
|
||||
|
||||
def lpr_process(
|
||||
self, obj_data: dict[str, any], frame: np.ndarray, dedicated_lpr: bool = False
|
||||
self, obj_data: dict[str, Any], frame: np.ndarray, dedicated_lpr: bool = False
|
||||
):
|
||||
"""Look for license plates in image."""
|
||||
self.metrics.alpr_pps.value = self.plates_rec_second.eps()
|
||||
@@ -1212,7 +1214,7 @@ class LicensePlateProcessingMixin:
|
||||
f"{camera}: YOLOv9 LPD inference time: {(datetime.datetime.now().timestamp() - yolov9_start) * 1000:.2f} ms"
|
||||
)
|
||||
self.plates_det_second.update()
|
||||
self.__update_yolov9_metrics(
|
||||
self.plate_det_speed.update(
|
||||
datetime.datetime.now().timestamp() - yolov9_start
|
||||
)
|
||||
|
||||
@@ -1270,7 +1272,7 @@ class LicensePlateProcessingMixin:
|
||||
)
|
||||
return
|
||||
|
||||
license_plate: Optional[dict[str, any]] = None
|
||||
license_plate: Optional[dict[str, Any]] = None
|
||||
|
||||
if "license_plate" not in self.config.cameras[camera].objects.track:
|
||||
logger.debug(f"{camera}: Running manual license_plate detection.")
|
||||
@@ -1281,6 +1283,10 @@ class LicensePlateProcessingMixin:
|
||||
return
|
||||
|
||||
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
|
||||
|
||||
# apply motion mask
|
||||
rgb[self.config.cameras[camera].motion.mask == 0] = [0, 0, 0]
|
||||
|
||||
left, top, right, bottom = car_box
|
||||
car = rgb[top:bottom, left:right]
|
||||
|
||||
@@ -1299,7 +1305,7 @@ class LicensePlateProcessingMixin:
|
||||
f"{camera}: YOLOv9 LPD inference time: {(datetime.datetime.now().timestamp() - yolov9_start) * 1000:.2f} ms"
|
||||
)
|
||||
self.plates_det_second.update()
|
||||
self.__update_yolov9_metrics(
|
||||
self.plate_det_speed.update(
|
||||
datetime.datetime.now().timestamp() - yolov9_start
|
||||
)
|
||||
|
||||
@@ -1335,7 +1341,7 @@ class LicensePlateProcessingMixin:
|
||||
return
|
||||
|
||||
if obj_data.get("label") in ["car", "motorcycle"]:
|
||||
attributes: list[dict[str, any]] = obj_data.get(
|
||||
attributes: list[dict[str, Any]] = obj_data.get(
|
||||
"current_attributes", []
|
||||
)
|
||||
for attr in attributes:
|
||||
@@ -1413,7 +1419,7 @@ class LicensePlateProcessingMixin:
|
||||
camera, id, license_plate_frame
|
||||
)
|
||||
self.plates_rec_second.update()
|
||||
self.__update_lpr_metrics(datetime.datetime.now().timestamp() - start)
|
||||
self.plate_rec_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
|
||||
if license_plates:
|
||||
for plate, confidence, text_area in zip(license_plates, confidences, areas):
|
||||
@@ -1546,6 +1552,12 @@ class LicensePlateProcessingMixin:
|
||||
(base64.b64encode(encoded_img).decode("ASCII"), id, camera),
|
||||
)
|
||||
|
||||
if id not in self.detected_license_plates:
|
||||
if camera not in self.camera_current_cars:
|
||||
self.camera_current_cars[camera] = []
|
||||
|
||||
self.camera_current_cars[camera].append(id)
|
||||
|
||||
self.detected_license_plates[id] = {
|
||||
"plate": top_plate,
|
||||
"char_confidences": top_char_confidences,
|
||||
@@ -1555,13 +1567,29 @@ class LicensePlateProcessingMixin:
|
||||
"last_seen": current_time if dedicated_lpr else None,
|
||||
}
|
||||
|
||||
def handle_request(self, topic, request_data) -> dict[str, any] | None:
|
||||
def handle_request(self, topic, request_data) -> dict[str, Any] | None:
|
||||
return
|
||||
|
||||
def expire_object(self, object_id: str):
|
||||
def lpr_expire(self, object_id: str, camera: str):
|
||||
if object_id in self.detected_license_plates:
|
||||
self.detected_license_plates.pop(object_id)
|
||||
|
||||
if object_id in self.camera_current_cars.get(camera, []):
|
||||
self.camera_current_cars[camera].remove(object_id)
|
||||
|
||||
if len(self.camera_current_cars[camera]) == 0:
|
||||
self.requestor.send_data(
|
||||
"tracked_object_update",
|
||||
json.dumps(
|
||||
{
|
||||
"type": TrackedObjectUpdateTypesEnum.lpr,
|
||||
"name": None,
|
||||
"plate": None,
|
||||
"camera": camera,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CTCDecoder:
|
||||
"""
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
@@ -25,7 +26,7 @@ class PostProcessorApi(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def process_data(
|
||||
self, data: dict[str, any], data_type: PostProcessDataEnum
|
||||
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
||||
) -> None:
|
||||
"""Processes the data of data type.
|
||||
Args:
|
||||
@@ -38,7 +39,7 @@ class PostProcessorApi(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def handle_request(self, request_data: dict[str, any]) -> dict[str, any] | None:
|
||||
def handle_request(self, request_data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Handle metadata requests.
|
||||
Args:
|
||||
request_data (dict): containing data about requested change to process.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -36,7 +37,7 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
|
||||
sub_label_publisher: EventMetadataPublisher,
|
||||
metrics: DataProcessorMetrics,
|
||||
model_runner: LicensePlateModelRunner,
|
||||
detected_license_plates: dict[str, dict[str, any]],
|
||||
detected_license_plates: dict[str, dict[str, Any]],
|
||||
):
|
||||
self.requestor = requestor
|
||||
self.detected_license_plates = detected_license_plates
|
||||
@@ -47,7 +48,7 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
|
||||
super().__init__(config, metrics, model_runner)
|
||||
|
||||
def process_data(
|
||||
self, data: dict[str, any], data_type: PostProcessDataEnum
|
||||
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
||||
) -> None:
|
||||
"""Look for license plates in recording stream image
|
||||
Args:
|
||||
@@ -214,7 +215,7 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
|
||||
logger.debug(f"Post processing plate: {event_id}, {frame_time}")
|
||||
self.lpr_process(keyframe_obj_data, frame)
|
||||
|
||||
def handle_request(self, topic, request_data) -> dict[str, any] | None:
|
||||
def handle_request(self, topic, request_data) -> dict[str, Any] | None:
|
||||
if topic == EmbeddingsRequestEnum.reprocess_plate.value:
|
||||
event = request_data["event"]
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -24,7 +25,7 @@ class RealTimeProcessorApi(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def process_frame(self, obj_data: dict[str, any], frame: np.ndarray) -> None:
|
||||
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None:
|
||||
"""Processes the frame with object data.
|
||||
Args:
|
||||
obj_data (dict): containing data about focused object in frame.
|
||||
@@ -37,8 +38,8 @@ class RealTimeProcessorApi(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def handle_request(
|
||||
self, topic: str, request_data: dict[str, any]
|
||||
) -> dict[str, any] | None:
|
||||
self, topic: str, request_data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Handle metadata requests.
|
||||
Args:
|
||||
topic (str): topic that dictates what work is requested.
|
||||
@@ -50,10 +51,11 @@ class RealTimeProcessorApi(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def expire_object(self, object_id: str) -> None:
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
"""Handle objects that are no longer detected.
|
||||
Args:
|
||||
object_id (str): id of object that is no longer detected.
|
||||
camera (str): name of camera that object was detected on.
|
||||
|
||||
Returns:
|
||||
None.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -35,8 +36,8 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
||||
super().__init__(config, metrics)
|
||||
self.interpreter: Interpreter = None
|
||||
self.sub_label_publisher = sub_label_publisher
|
||||
self.tensor_input_details: dict[str, any] = None
|
||||
self.tensor_output_details: dict[str, any] = None
|
||||
self.tensor_input_details: dict[str, Any] = None
|
||||
self.tensor_output_details: dict[str, Any] = None
|
||||
self.detected_birds: dict[str, float] = {}
|
||||
self.labelmap: dict[int, str] = {}
|
||||
|
||||
@@ -96,6 +97,9 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
||||
line = f.readline()
|
||||
|
||||
def process_frame(self, obj_data, frame):
|
||||
if not self.interpreter:
|
||||
return
|
||||
|
||||
if obj_data["label"] != "bird":
|
||||
return
|
||||
|
||||
@@ -152,6 +156,6 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
||||
def handle_request(self, topic, request_data):
|
||||
return None
|
||||
|
||||
def expire_object(self, object_id):
|
||||
def expire_object(self, object_id, camera):
|
||||
if object_id in self.detected_birds:
|
||||
self.detected_birds.pop(object_id)
|
||||
|
||||
@@ -5,10 +5,8 @@ import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import string
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -27,7 +25,7 @@ from frigate.data_processing.common.face.model import (
|
||||
FaceRecognizer,
|
||||
)
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.builtin import EventsPerSecond
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
|
||||
from frigate.util.image import area
|
||||
|
||||
from ..types import DataProcessorMetrics
|
||||
@@ -56,8 +54,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.face_detector: cv2.FaceDetectorYN = None
|
||||
self.requires_face_detection = "face" not in self.config.objects.all_objects
|
||||
self.person_face_history: dict[str, list[tuple[str, float, int]]] = {}
|
||||
self.camera_current_people: dict[str, list[str]] = {}
|
||||
self.recognizer: FaceRecognizer | None = None
|
||||
self.faces_per_second = EventsPerSecond()
|
||||
self.inference_speed = InferenceSpeed(self.metrics.face_rec_speed)
|
||||
|
||||
download_path = os.path.join(MODEL_CACHE_DIR, "facedet")
|
||||
self.model_files = {
|
||||
@@ -155,11 +155,9 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
def __update_metrics(self, duration: float) -> None:
|
||||
self.faces_per_second.update()
|
||||
self.metrics.face_rec_speed.value = (
|
||||
self.metrics.face_rec_speed.value * 9 + duration
|
||||
) / 10
|
||||
self.inference_speed.update(duration)
|
||||
|
||||
def process_frame(self, obj_data: dict[str, any], frame: np.ndarray):
|
||||
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray):
|
||||
"""Look for faces in image."""
|
||||
self.metrics.face_rec_fps.value = self.faces_per_second.eps()
|
||||
camera = obj_data["camera"]
|
||||
@@ -200,7 +198,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
logger.debug("Not processing due to hitting max rec attempts.")
|
||||
return
|
||||
|
||||
face: Optional[dict[str, any]] = None
|
||||
face: Optional[dict[str, Any]] = None
|
||||
|
||||
if self.requires_face_detection:
|
||||
logger.debug("Running manual face detection.")
|
||||
@@ -223,6 +221,13 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
max(0, face_box[0]) : min(frame.shape[1], face_box[2]),
|
||||
]
|
||||
|
||||
# check that face is correct size
|
||||
if area(face_box) < self.config.cameras[camera].face_recognition.min_area:
|
||||
logger.debug(
|
||||
f"Detected face that is smaller than the min_area {face} < {self.config.cameras[camera].face_recognition.min_area}"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
face_frame = cv2.cvtColor(face_frame, cv2.COLOR_RGB2BGR)
|
||||
except Exception:
|
||||
@@ -233,7 +238,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
logger.debug("No attributes to parse.")
|
||||
return
|
||||
|
||||
attributes: list[dict[str, any]] = obj_data.get("current_attributes", [])
|
||||
attributes: list[dict[str, Any]] = obj_data.get("current_attributes", [])
|
||||
for attr in attributes:
|
||||
if attr.get("label") != "face":
|
||||
continue
|
||||
@@ -285,6 +290,11 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
if id not in self.person_face_history:
|
||||
self.person_face_history[id] = []
|
||||
|
||||
if camera not in self.camera_current_people:
|
||||
self.camera_current_people[camera] = []
|
||||
|
||||
self.camera_current_people[camera].append(id)
|
||||
|
||||
self.person_face_history[id].append(
|
||||
(sub_label, score, face_frame.shape[0] * face_frame.shape[1])
|
||||
)
|
||||
@@ -292,6 +302,9 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.person_face_history[id]
|
||||
)
|
||||
|
||||
if len(self.person_face_history[id]) < self.face_config.min_faces:
|
||||
weighted_sub_label = "unknown"
|
||||
|
||||
self.requestor.send_data(
|
||||
"tracked_object_update",
|
||||
json.dumps(
|
||||
@@ -314,7 +327,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - start)
|
||||
|
||||
def handle_request(self, topic, request_data) -> dict[str, any] | None:
|
||||
def handle_request(self, topic, request_data) -> dict[str, Any] | None:
|
||||
if topic == EmbeddingsRequestEnum.clear_face_classifier.value:
|
||||
self.recognizer.clear()
|
||||
elif topic == EmbeddingsRequestEnum.recognize_face.value:
|
||||
@@ -343,11 +356,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
return {"success": True, "score": score, "face_name": sub_label}
|
||||
elif topic == EmbeddingsRequestEnum.register_face.value:
|
||||
rand_id = "".join(
|
||||
random.choices(string.ascii_lowercase + string.digits, k=6)
|
||||
)
|
||||
label = request_data["face_name"]
|
||||
id = f"{label}-{rand_id}"
|
||||
|
||||
if request_data.get("cropped"):
|
||||
thumbnail = request_data["image"]
|
||||
@@ -376,7 +385,9 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
# write face to library
|
||||
folder = os.path.join(FACE_DIR, label)
|
||||
file = os.path.join(folder, f"{id}.webp")
|
||||
file = os.path.join(
|
||||
folder, f"{label}_{datetime.datetime.now().timestamp()}.webp"
|
||||
)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
|
||||
# save face image
|
||||
@@ -425,10 +436,25 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
shutil.move(current_file, new_file)
|
||||
|
||||
def expire_object(self, object_id: str):
|
||||
def expire_object(self, object_id: str, camera: str):
|
||||
if object_id in self.person_face_history:
|
||||
self.person_face_history.pop(object_id)
|
||||
|
||||
if object_id in self.camera_current_people.get(camera, []):
|
||||
self.camera_current_people[camera].remove(object_id)
|
||||
|
||||
if len(self.camera_current_people[camera]) == 0:
|
||||
self.requestor.send_data(
|
||||
"tracked_object_update",
|
||||
json.dumps(
|
||||
{
|
||||
"type": TrackedObjectUpdateTypesEnum.face,
|
||||
"name": None,
|
||||
"camera": camera,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def weighted_average(
|
||||
self, results_list: list[tuple[str, float, int]], max_weight: int = 4000
|
||||
):
|
||||
@@ -445,17 +471,22 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
if not results_list:
|
||||
return None, 0.0
|
||||
|
||||
weighted_scores = {}
|
||||
total_weights = {}
|
||||
counts: dict[str, int] = {}
|
||||
weighted_scores: dict[str, int] = {}
|
||||
total_weights: dict[str, int] = {}
|
||||
|
||||
for name, score, face_area in results_list:
|
||||
if name == "unknown":
|
||||
continue
|
||||
|
||||
if name not in weighted_scores:
|
||||
counts[name] = 0
|
||||
weighted_scores[name] = 0.0
|
||||
total_weights[name] = 0.0
|
||||
|
||||
# increase count
|
||||
counts[name] += 1
|
||||
|
||||
# Capped weight based on face area
|
||||
weight = min(face_area, max_weight)
|
||||
|
||||
@@ -468,6 +499,12 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
return None, 0.0
|
||||
|
||||
best_name = max(weighted_scores, key=weighted_scores.get)
|
||||
|
||||
# If the best name has the same number of results as another name, we are not confident it is a correct result
|
||||
for name, count in counts.items():
|
||||
if name != best_name and counts[best_name] == count:
|
||||
return None, 0.0
|
||||
|
||||
weighted_average = weighted_scores[best_name] / total_weights[best_name]
|
||||
|
||||
return best_name, weighted_average
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Handle processing images for face detection and recognition."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -28,7 +29,7 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess
|
||||
sub_label_publisher: EventMetadataPublisher,
|
||||
metrics: DataProcessorMetrics,
|
||||
model_runner: LicensePlateModelRunner,
|
||||
detected_license_plates: dict[str, dict[str, any]],
|
||||
detected_license_plates: dict[str, dict[str, Any]],
|
||||
):
|
||||
self.requestor = requestor
|
||||
self.detected_license_plates = detected_license_plates
|
||||
@@ -36,20 +37,21 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess
|
||||
self.lpr_config = config.lpr
|
||||
self.config = config
|
||||
self.sub_label_publisher = sub_label_publisher
|
||||
self.camera_current_cars: dict[str, list[str]] = {}
|
||||
super().__init__(config, metrics)
|
||||
|
||||
def process_frame(
|
||||
self,
|
||||
obj_data: dict[str, any],
|
||||
obj_data: dict[str, Any],
|
||||
frame: np.ndarray,
|
||||
dedicated_lpr: bool | None = False,
|
||||
):
|
||||
"""Look for license plates in image."""
|
||||
self.lpr_process(obj_data, frame, dedicated_lpr)
|
||||
|
||||
def handle_request(self, topic, request_data) -> dict[str, any] | None:
|
||||
def handle_request(self, topic, request_data) -> dict[str, Any] | None:
|
||||
return
|
||||
|
||||
def expire_object(self, object_id: str):
|
||||
if object_id in self.detected_license_plates:
|
||||
self.detected_license_plates.pop(object_id)
|
||||
def expire_object(self, object_id: str, camera: str):
|
||||
"""Expire lpr objects."""
|
||||
self.lpr_expire(object_id, camera)
|
||||
|
||||
@@ -7,7 +7,9 @@ from multiprocessing.sharedctypes import Synchronized
|
||||
|
||||
class DataProcessorMetrics:
|
||||
image_embeddings_speed: Synchronized
|
||||
image_embeddings_eps: Synchronized
|
||||
text_embeddings_speed: Synchronized
|
||||
text_embeddings_eps: Synchronized
|
||||
face_rec_speed: Synchronized
|
||||
face_rec_fps: Synchronized
|
||||
alpr_speed: Synchronized
|
||||
@@ -16,15 +18,15 @@ class DataProcessorMetrics:
|
||||
yolov9_lpr_pps: Synchronized
|
||||
|
||||
def __init__(self):
|
||||
self.image_embeddings_speed = mp.Value("d", 0.01)
|
||||
self.image_embeddings_speed = mp.Value("d", 0.0)
|
||||
self.image_embeddings_eps = mp.Value("d", 0.0)
|
||||
self.text_embeddings_speed = mp.Value("d", 0.01)
|
||||
self.text_embeddings_speed = mp.Value("d", 0.0)
|
||||
self.text_embeddings_eps = mp.Value("d", 0.0)
|
||||
self.face_rec_speed = mp.Value("d", 0.01)
|
||||
self.face_rec_speed = mp.Value("d", 0.0)
|
||||
self.face_rec_fps = mp.Value("d", 0.0)
|
||||
self.alpr_speed = mp.Value("d", 0.01)
|
||||
self.alpr_speed = mp.Value("d", 0.0)
|
||||
self.alpr_pps = mp.Value("d", 0.0)
|
||||
self.yolov9_lpr_speed = mp.Value("d", 0.01)
|
||||
self.yolov9_lpr_speed = mp.Value("d", 0.0)
|
||||
self.yolov9_lpr_pps = mp.Value("d", 0.0)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional, Tuple
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import requests
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -126,6 +126,9 @@ class ModelConfig(BaseModel):
|
||||
if not self.path or not self.path.startswith("plus://"):
|
||||
return
|
||||
|
||||
# ensure that model cache dir exists
|
||||
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
||||
|
||||
model_id = self.path[7:]
|
||||
self.path = os.path.join(MODEL_CACHE_DIR, model_id)
|
||||
model_info_path = f"{self.path}.json"
|
||||
@@ -144,7 +147,7 @@ class ModelConfig(BaseModel):
|
||||
json.dump(model_info, f)
|
||||
else:
|
||||
with open(model_info_path, "r") as f:
|
||||
model_info: dict[str, any] = json.load(f)
|
||||
model_info: dict[str, Any] = json.load(f)
|
||||
|
||||
if detector and detector not in model_info["supportedDetectors"]:
|
||||
raise ValueError(f"Model does not support detector type of {detector}")
|
||||
|
||||
@@ -8,17 +8,6 @@ from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from hailo_platform import (
|
||||
HEF,
|
||||
FormatType,
|
||||
HailoSchedulingAlgorithm,
|
||||
VDevice,
|
||||
)
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
from pydantic import Field
|
||||
from typing_extensions import Literal
|
||||
|
||||
@@ -102,6 +91,18 @@ class HailoAsyncInference:
|
||||
output_type: Optional[Dict[str, str]] = None,
|
||||
send_original_frame: bool = False,
|
||||
) -> None:
|
||||
# when importing hailo it activates the driver
|
||||
# which leaves processes running even though it may not be used.
|
||||
try:
|
||||
from hailo_platform import (
|
||||
HEF,
|
||||
FormatType,
|
||||
HailoSchedulingAlgorithm,
|
||||
VDevice,
|
||||
)
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
self.input_store = input_store
|
||||
self.output_store = output_store
|
||||
|
||||
@@ -112,24 +113,19 @@ class HailoAsyncInference:
|
||||
self.target = VDevice(params)
|
||||
self.infer_model = self.target.create_infer_model(hef_path)
|
||||
self.infer_model.set_batch_size(batch_size)
|
||||
|
||||
if input_type is not None:
|
||||
self._set_input_type(input_type)
|
||||
self.infer_model.input().set_format_type(getattr(FormatType, input_type))
|
||||
|
||||
if output_type is not None:
|
||||
self._set_output_type(output_type)
|
||||
for output_name, output_type in output_type.items():
|
||||
self.infer_model.output(output_name).set_format_type(
|
||||
getattr(FormatType, output_type)
|
||||
)
|
||||
|
||||
self.output_type = output_type
|
||||
self.send_original_frame = send_original_frame
|
||||
|
||||
def _set_input_type(self, input_type: Optional[str] = None) -> None:
|
||||
self.infer_model.input().set_format_type(getattr(FormatType, input_type))
|
||||
|
||||
def _set_output_type(
|
||||
self, output_type_dict: Optional[Dict[str, str]] = None
|
||||
) -> None:
|
||||
for output_name, output_type in output_type_dict.items():
|
||||
self.infer_model.output(output_name).set_format_type(
|
||||
getattr(FormatType, output_type)
|
||||
)
|
||||
|
||||
def callback(
|
||||
self,
|
||||
completion_info,
|
||||
@@ -349,11 +345,17 @@ class HailoDetector(DetectionApi):
|
||||
request_id = self.input_store.put(tensor_input)
|
||||
|
||||
try:
|
||||
_, infer_results = self.response_store.get(request_id, timeout=10.0)
|
||||
_, infer_results = self.response_store.get(request_id, timeout=1.0)
|
||||
except TimeoutError:
|
||||
logger.error(
|
||||
f"Timeout waiting for inference results for request {request_id}"
|
||||
)
|
||||
|
||||
if not self.inference_thread.is_alive():
|
||||
raise RuntimeError(
|
||||
"HailoRT inference thread has stopped, restart required."
|
||||
)
|
||||
|
||||
return np.zeros((20, 6), dtype=np.float32)
|
||||
|
||||
if isinstance(infer_results, list) and len(infer_results) == 1:
|
||||
|
||||
@@ -3,11 +3,9 @@ import os
|
||||
|
||||
import numpy as np
|
||||
import openvino as ov
|
||||
import openvino.properties as props
|
||||
from pydantic import Field
|
||||
from typing_extensions import Literal
|
||||
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.detectors.detection_api import DetectionApi
|
||||
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
|
||||
from frigate.util.model import (
|
||||
@@ -49,10 +47,6 @@ class OvDetector(DetectionApi):
|
||||
logger.error(f"OpenVino model file {detector_config.model.path} not found.")
|
||||
raise FileNotFoundError
|
||||
|
||||
os.makedirs(os.path.join(MODEL_CACHE_DIR, "openvino"), exist_ok=True)
|
||||
self.ov_core.set_property(
|
||||
{props.cache_dir: os.path.join(MODEL_CACHE_DIR, "openvino")}
|
||||
)
|
||||
self.interpreter = self.ov_core.compile_model(
|
||||
model=detector_config.model.path, device_name=detector_config.device
|
||||
)
|
||||
|
||||
@@ -5,12 +5,12 @@ import json
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import threading
|
||||
from types import FrameType
|
||||
from typing import Optional, Union
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import regex
|
||||
from pathvalidate import ValidationError, sanitize_filename
|
||||
from setproctitle import setproctitle
|
||||
|
||||
@@ -190,7 +190,7 @@ class EmbeddingsContext:
|
||||
|
||||
return results
|
||||
|
||||
def register_face(self, face_name: str, image_data: bytes) -> dict[str, any]:
|
||||
def register_face(self, face_name: str, image_data: bytes) -> dict[str, Any]:
|
||||
return self.requestor.send_data(
|
||||
EmbeddingsRequestEnum.register_face.value,
|
||||
{
|
||||
@@ -199,7 +199,7 @@ class EmbeddingsContext:
|
||||
},
|
||||
)
|
||||
|
||||
def recognize_face(self, image_data: bytes) -> dict[str, any]:
|
||||
def recognize_face(self, image_data: bytes) -> dict[str, Any]:
|
||||
return self.requestor.send_data(
|
||||
EmbeddingsRequestEnum.recognize_face.value,
|
||||
{
|
||||
@@ -217,7 +217,7 @@ class EmbeddingsContext:
|
||||
|
||||
return self.db.execute_sql(sql_query).fetchall()
|
||||
|
||||
def reprocess_face(self, face_file: str) -> dict[str, any]:
|
||||
def reprocess_face(self, face_file: str) -> dict[str, Any]:
|
||||
return self.requestor.send_data(
|
||||
EmbeddingsRequestEnum.reprocess_face.value, {"image_file": face_file}
|
||||
)
|
||||
@@ -235,7 +235,7 @@ class EmbeddingsContext:
|
||||
if os.path.isfile(file_path):
|
||||
os.unlink(file_path)
|
||||
|
||||
if len(os.listdir(folder)) == 0:
|
||||
if face != "train" and len(os.listdir(folder)) == 0:
|
||||
os.rmdir(folder)
|
||||
|
||||
self.requestor.send_data(
|
||||
@@ -243,7 +243,7 @@ class EmbeddingsContext:
|
||||
)
|
||||
|
||||
def rename_face(self, old_name: str, new_name: str) -> None:
|
||||
valid_name_pattern = r"^[a-zA-Z0-9\s_-]{1,50}$"
|
||||
valid_name_pattern = r"^[\p{L}\p{N}\s'_-]{1,50}$"
|
||||
|
||||
try:
|
||||
sanitized_old_name = sanitize_filename(old_name, replacement_text="_")
|
||||
@@ -251,9 +251,9 @@ class EmbeddingsContext:
|
||||
except ValidationError as e:
|
||||
raise ValueError(f"Invalid face name: {str(e)}")
|
||||
|
||||
if not re.match(valid_name_pattern, old_name):
|
||||
if not regex.match(valid_name_pattern, old_name):
|
||||
raise ValueError(f"Invalid old face name: {old_name}")
|
||||
if not re.match(valid_name_pattern, new_name):
|
||||
if not regex.match(valid_name_pattern, new_name):
|
||||
raise ValueError(f"Invalid new face name: {new_name}")
|
||||
if sanitized_old_name != old_name:
|
||||
raise ValueError(f"Old face name contains invalid characters: {old_name}")
|
||||
@@ -284,10 +284,10 @@ class EmbeddingsContext:
|
||||
{"id": event_id, "description": description},
|
||||
)
|
||||
|
||||
def reprocess_plate(self, event: dict[str, any]) -> dict[str, any]:
|
||||
def reprocess_plate(self, event: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.requestor.send_data(
|
||||
EmbeddingsRequestEnum.reprocess_plate.value, {"event": event}
|
||||
)
|
||||
|
||||
def reindex_embeddings(self) -> dict[str, any]:
|
||||
def reindex_embeddings(self) -> dict[str, Any]:
|
||||
return self.requestor.send_data(EmbeddingsRequestEnum.reindex.value, {})
|
||||
|
||||
@@ -21,7 +21,7 @@ from frigate.data_processing.types import DataProcessorMetrics
|
||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
from frigate.models import Event
|
||||
from frigate.types import ModelStatusTypesEnum
|
||||
from frigate.util.builtin import EventsPerSecond, serialize
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize
|
||||
from frigate.util.path import get_event_thumbnail_bytes
|
||||
|
||||
from .onnx.jina_v1_embedding import JinaV1ImageEmbedding, JinaV1TextEmbedding
|
||||
@@ -75,8 +75,10 @@ class Embeddings:
|
||||
self.metrics = metrics
|
||||
self.requestor = InterProcessRequestor()
|
||||
|
||||
self.image_inference_speed = InferenceSpeed(self.metrics.image_embeddings_speed)
|
||||
self.image_eps = EventsPerSecond()
|
||||
self.image_eps.start()
|
||||
self.text_inference_speed = InferenceSpeed(self.metrics.text_embeddings_speed)
|
||||
self.text_eps = EventsPerSecond()
|
||||
self.text_eps.start()
|
||||
|
||||
@@ -183,10 +185,7 @@ class Embeddings:
|
||||
(event_id, serialize(embedding)),
|
||||
)
|
||||
|
||||
duration = datetime.datetime.now().timestamp() - start
|
||||
self.metrics.image_embeddings_speed.value = (
|
||||
self.metrics.image_embeddings_speed.value * 9 + duration
|
||||
) / 10
|
||||
self.image_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
self.image_eps.update()
|
||||
|
||||
return embedding
|
||||
@@ -220,9 +219,7 @@ class Embeddings:
|
||||
)
|
||||
|
||||
duration = datetime.datetime.now().timestamp() - start
|
||||
self.metrics.text_embeddings_speed.value = (
|
||||
self.metrics.text_embeddings_speed.value * 9 + (duration / len(ids))
|
||||
) / 10
|
||||
self.text_inference_speed.update(duration / len(ids))
|
||||
|
||||
return embeddings
|
||||
|
||||
@@ -241,10 +238,7 @@ class Embeddings:
|
||||
(event_id, serialize(embedding)),
|
||||
)
|
||||
|
||||
duration = datetime.datetime.now().timestamp() - start
|
||||
self.metrics.text_embeddings_speed.value = (
|
||||
self.metrics.text_embeddings_speed.value * 9 + duration
|
||||
) / 10
|
||||
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
self.text_eps.update()
|
||||
|
||||
return embedding
|
||||
@@ -276,10 +270,7 @@ class Embeddings:
|
||||
items,
|
||||
)
|
||||
|
||||
duration = datetime.datetime.now().timestamp() - start
|
||||
self.metrics.text_embeddings_speed.value = (
|
||||
self.metrics.text_embeddings_speed.value * 9 + (duration / len(ids))
|
||||
) / 10
|
||||
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
|
||||
return embeddings
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import os
|
||||
import threading
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -104,7 +104,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
self.embeddings_responder = EmbeddingsResponder()
|
||||
self.frame_manager = SharedMemoryFrameManager()
|
||||
|
||||
self.detected_license_plates: dict[str, dict[str, any]] = {}
|
||||
self.detected_license_plates: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# model runners to share between realtime and post processors
|
||||
if self.config.lpr.enabled:
|
||||
@@ -159,7 +159,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
)
|
||||
|
||||
self.stop_event = stop_event
|
||||
self.tracked_events: dict[str, list[any]] = {}
|
||||
self.tracked_events: dict[str, list[Any]] = {}
|
||||
self.early_request_sent: dict[str, bool] = {}
|
||||
self.genai_client = get_genai_client(config)
|
||||
|
||||
@@ -190,7 +190,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
def _process_requests(self) -> None:
|
||||
"""Process embeddings requests"""
|
||||
|
||||
def _handle_request(topic: str, data: dict[str, any]) -> str:
|
||||
def _handle_request(topic: str, data: dict[str, Any]) -> str:
|
||||
try:
|
||||
# First handle the embedding-specific topics when semantic search is enabled
|
||||
if self.config.semantic_search.enabled:
|
||||
@@ -359,7 +359,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
|
||||
# expire in realtime processors
|
||||
for processor in self.realtime_processors:
|
||||
processor.expire_object(event_id)
|
||||
processor.expire_object(event_id, camera)
|
||||
|
||||
if updated_db:
|
||||
try:
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
@@ -59,7 +60,7 @@ class BaseEmbedding(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _preprocess_inputs(self, raw_inputs: any) -> any:
|
||||
def _preprocess_inputs(self, raw_inputs: Any) -> Any:
|
||||
pass
|
||||
|
||||
def _process_image(self, image, output: str = "RGB") -> Image.Image:
|
||||
@@ -74,7 +75,7 @@ class BaseEmbedding(ABC):
|
||||
|
||||
return image
|
||||
|
||||
def _postprocess_outputs(self, outputs: any) -> any:
|
||||
def _postprocess_outputs(self, outputs: Any) -> Any:
|
||||
return outputs
|
||||
|
||||
def __call__(
|
||||
@@ -84,7 +85,7 @@ class BaseEmbedding(ABC):
|
||||
processed = self._preprocess_inputs(inputs)
|
||||
input_names = self.runner.get_input_names()
|
||||
onnx_inputs = {name: [] for name in input_names}
|
||||
input: dict[str, any]
|
||||
input: dict[str, Any]
|
||||
for input in processed:
|
||||
for key, value in input.items():
|
||||
if key in input_names:
|
||||
|
||||
@@ -23,10 +23,7 @@ FACENET_INPUT_SIZE = 160
|
||||
|
||||
|
||||
class FaceNetEmbedding(BaseEmbedding):
|
||||
def __init__(
|
||||
self,
|
||||
device: str = "AUTO",
|
||||
):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
model_name="facedet",
|
||||
model_file="facenet.tflite",
|
||||
@@ -34,7 +31,6 @@ class FaceNetEmbedding(BaseEmbedding):
|
||||
"facenet.tflite": "https://github.com/NickM-27/facenet-onnx/releases/download/v1.0/facenet.tflite",
|
||||
},
|
||||
)
|
||||
self.device = device
|
||||
self.download_path = os.path.join(MODEL_CACHE_DIR, self.model_name)
|
||||
self.tokenizer = None
|
||||
self.feature_extractor = None
|
||||
@@ -113,10 +109,7 @@ class FaceNetEmbedding(BaseEmbedding):
|
||||
|
||||
|
||||
class ArcfaceEmbedding(BaseEmbedding):
|
||||
def __init__(
|
||||
self,
|
||||
device: str = "AUTO",
|
||||
):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
model_name="facedet",
|
||||
model_file="arcface.onnx",
|
||||
@@ -124,7 +117,6 @@ class ArcfaceEmbedding(BaseEmbedding):
|
||||
"arcface.onnx": "https://github.com/NickM-27/facenet-onnx/releases/download/v1.0/arcface.onnx",
|
||||
},
|
||||
)
|
||||
self.device = device
|
||||
self.download_path = os.path.join(MODEL_CACHE_DIR, self.model_name)
|
||||
self.tokenizer = None
|
||||
self.feature_extractor = None
|
||||
@@ -154,7 +146,7 @@ class ArcfaceEmbedding(BaseEmbedding):
|
||||
|
||||
self.runner = ONNXModelRunner(
|
||||
os.path.join(self.download_path, self.model_file),
|
||||
self.device,
|
||||
"GPU",
|
||||
)
|
||||
|
||||
def _preprocess_inputs(self, raw_inputs):
|
||||
|
||||
@@ -36,11 +36,12 @@ class JinaV1TextEmbedding(BaseEmbedding):
|
||||
requestor: InterProcessRequestor,
|
||||
device: str = "AUTO",
|
||||
):
|
||||
HF_ENDPOINT = os.environ.get("HF_ENDPOINT", "https://huggingface.co")
|
||||
super().__init__(
|
||||
model_name="jinaai/jina-clip-v1",
|
||||
model_file="text_model_fp16.onnx",
|
||||
download_urls={
|
||||
"text_model_fp16.onnx": "https://huggingface.co/jinaai/jina-clip-v1/resolve/main/onnx/text_model_fp16.onnx",
|
||||
"text_model_fp16.onnx": f"{HF_ENDPOINT}/jinaai/jina-clip-v1/resolve/main/onnx/text_model_fp16.onnx",
|
||||
},
|
||||
)
|
||||
self.tokenizer_file = "tokenizer"
|
||||
@@ -156,12 +157,13 @@ class JinaV1ImageEmbedding(BaseEmbedding):
|
||||
if model_size == "large"
|
||||
else "vision_model_quantized.onnx"
|
||||
)
|
||||
HF_ENDPOINT = os.environ.get("HF_ENDPOINT", "https://huggingface.co")
|
||||
super().__init__(
|
||||
model_name="jinaai/jina-clip-v1",
|
||||
model_file=model_file,
|
||||
download_urls={
|
||||
model_file: f"https://huggingface.co/jinaai/jina-clip-v1/resolve/main/onnx/{model_file}",
|
||||
"preprocessor_config.json": "https://huggingface.co/jinaai/jina-clip-v1/resolve/main/preprocessor_config.json",
|
||||
model_file: f"{HF_ENDPOINT}/jinaai/jina-clip-v1/resolve/main/onnx/{model_file}",
|
||||
"preprocessor_config.json": f"{HF_ENDPOINT}/jinaai/jina-clip-v1/resolve/main/preprocessor_config.json",
|
||||
},
|
||||
)
|
||||
self.requestor = requestor
|
||||
|
||||
@@ -34,12 +34,13 @@ class JinaV2Embedding(BaseEmbedding):
|
||||
model_file = (
|
||||
"model_fp16.onnx" if model_size == "large" else "model_quantized.onnx"
|
||||
)
|
||||
HF_ENDPOINT = os.environ.get("HF_ENDPOINT", "https://huggingface.co")
|
||||
super().__init__(
|
||||
model_name="jinaai/jina-clip-v2",
|
||||
model_file=model_file,
|
||||
download_urls={
|
||||
model_file: f"https://huggingface.co/jinaai/jina-clip-v2/resolve/main/onnx/{model_file}",
|
||||
"preprocessor_config.json": "https://huggingface.co/jinaai/jina-clip-v2/resolve/main/preprocessor_config.json",
|
||||
model_file: f"{HF_ENDPOINT}/jinaai/jina-clip-v2/resolve/main/onnx/{model_file}",
|
||||
"preprocessor_config.json": f"{HF_ENDPOINT}/jinaai/jina-clip-v2/resolve/main/preprocessor_config.json",
|
||||
},
|
||||
)
|
||||
self.tokenizer_file = "tokenizer"
|
||||
|
||||
@@ -6,7 +6,7 @@ import random
|
||||
import string
|
||||
import threading
|
||||
import time
|
||||
from typing import Tuple
|
||||
from typing import Any, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -126,7 +126,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
|
||||
self.config = camera
|
||||
self.camera_metrics = camera_metrics
|
||||
self.detections: dict[dict[str, any]] = {}
|
||||
self.detections: dict[dict[str, Any]] = {}
|
||||
self.stop_event = stop_event
|
||||
self.detector = AudioTfl(stop_event, self.config.audio.num_threads)
|
||||
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
|
||||
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
import threading
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import CLIPS_DIR
|
||||
@@ -29,7 +30,7 @@ class EventCleanup(threading.Thread):
|
||||
self.db = db
|
||||
self.camera_keys = list(self.config.cameras.keys())
|
||||
self.removed_camera_labels: list[str] = None
|
||||
self.camera_labels: dict[str, dict[str, any]] = {}
|
||||
self.camera_labels: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def get_removed_camera_labels(self) -> list[Event]:
|
||||
"""Get a list of distinct labels for removed cameras."""
|
||||
|
||||
+11
-5
@@ -1,3 +1,4 @@
|
||||
# In log.py
|
||||
import atexit
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
@@ -6,6 +7,7 @@ import sys
|
||||
import threading
|
||||
from collections import deque
|
||||
from logging.handlers import QueueHandler, QueueListener
|
||||
from queue import Queue
|
||||
from typing import Deque, Optional
|
||||
|
||||
from frigate.util.builtin import clean_camera_user_pass
|
||||
@@ -32,12 +34,14 @@ LOG_HANDLER.addFilter(
|
||||
)
|
||||
|
||||
log_listener: Optional[QueueListener] = None
|
||||
log_queue: Optional[Queue] = None
|
||||
manager = None
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
global log_listener
|
||||
|
||||
log_queue: mp.Queue = mp.Queue()
|
||||
global log_listener, log_queue, manager
|
||||
manager = mp.Manager()
|
||||
log_queue = manager.Queue()
|
||||
log_listener = QueueListener(log_queue, LOG_HANDLER, respect_handler_level=True)
|
||||
|
||||
atexit.register(_stop_logging)
|
||||
@@ -53,11 +57,13 @@ def setup_logging() -> None:
|
||||
|
||||
|
||||
def _stop_logging() -> None:
|
||||
global log_listener
|
||||
|
||||
global log_listener, manager
|
||||
if log_listener is not None:
|
||||
log_listener.stop()
|
||||
log_listener = None
|
||||
if manager is not None:
|
||||
manager.shutdown()
|
||||
manager = None
|
||||
|
||||
|
||||
# When a multiprocessing.Process exits, python tries to flush stdout and stderr. However, if the
|
||||
|
||||
@@ -10,7 +10,7 @@ import queue
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -542,10 +542,10 @@ class BirdsEyeFrameManager:
|
||||
self,
|
||||
cameras_to_add: list[str],
|
||||
coefficient: float,
|
||||
) -> tuple[any]:
|
||||
) -> tuple[Any]:
|
||||
"""Calculate the optimal layout for 2+ cameras."""
|
||||
|
||||
def map_layout(camera_layout: list[list[any]], row_height: int):
|
||||
def map_layout(camera_layout: list[list[Any]], row_height: int):
|
||||
"""Map the calculated layout."""
|
||||
candidate_layout = []
|
||||
starting_x = 0
|
||||
@@ -588,7 +588,7 @@ class BirdsEyeFrameManager:
|
||||
return max_width, y, candidate_layout
|
||||
|
||||
canvas_aspect_x, canvas_aspect_y = self.canvas.get_aspect(coefficient)
|
||||
camera_layout: list[list[any]] = []
|
||||
camera_layout: list[list[Any]] = []
|
||||
camera_layout.append([])
|
||||
starting_x = 0
|
||||
x = starting_x
|
||||
@@ -786,7 +786,7 @@ class Birdseye:
|
||||
def write_data(
|
||||
self,
|
||||
camera: str,
|
||||
current_tracked_objects: list[dict[str, any]],
|
||||
current_tracked_objects: list[dict[str, Any]],
|
||||
motion_boxes: list[list[int]],
|
||||
frame_time: float,
|
||||
frame: np.ndarray,
|
||||
|
||||
@@ -111,7 +111,7 @@ def output_frames(
|
||||
move_preview_frames("cache")
|
||||
|
||||
for camera, cam_config in config.cameras.items():
|
||||
if not cam_config.enabled:
|
||||
if not cam_config.enabled_in_config:
|
||||
continue
|
||||
|
||||
jsmpeg_cameras[camera] = JsmpegCamera(cam_config, stop_event, websocket_server)
|
||||
|
||||
@@ -8,6 +8,7 @@ import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -255,7 +256,7 @@ class PreviewRecorder:
|
||||
|
||||
def should_write_frame(
|
||||
self,
|
||||
current_tracked_objects: list[dict[str, any]],
|
||||
current_tracked_objects: list[dict[str, Any]],
|
||||
motion_boxes: list[list[int]],
|
||||
frame_time: float,
|
||||
) -> bool:
|
||||
@@ -315,7 +316,7 @@ class PreviewRecorder:
|
||||
|
||||
def write_data(
|
||||
self,
|
||||
current_tracked_objects: list[dict[str, any]],
|
||||
current_tracked_objects: list[dict[str, Any]],
|
||||
motion_boxes: list[list[int]],
|
||||
frame_time: float,
|
||||
frame: np.ndarray,
|
||||
@@ -402,6 +403,7 @@ class PreviewRecorder:
|
||||
self.reset_frame_cache(frame_time)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.config_subscriber.stop()
|
||||
self.requestor.stop()
|
||||
|
||||
|
||||
|
||||
+80
-63
@@ -3,12 +3,11 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from functools import partial
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -61,7 +60,7 @@ class PtzMotionEstimator:
|
||||
|
||||
def motion_estimator(
|
||||
self,
|
||||
detections: list[dict[str, any]],
|
||||
detections: list[dict[str, Any]],
|
||||
frame_name: str,
|
||||
frame_time: float,
|
||||
camera: str,
|
||||
@@ -169,7 +168,12 @@ class PtzAutoTrackerThread(threading.Thread):
|
||||
continue
|
||||
|
||||
if camera_config.onvif.autotracking.enabled:
|
||||
self.ptz_autotracker.camera_maintenance(camera)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.ptz_autotracker.camera_maintenance(camera),
|
||||
self.ptz_autotracker.onvif.loop,
|
||||
)
|
||||
# Wait for the coroutine to complete
|
||||
future.result()
|
||||
else:
|
||||
# disabled dynamically by mqtt
|
||||
if self.ptz_autotracker.tracked_object.get(camera):
|
||||
@@ -219,9 +223,13 @@ class PtzAutoTracker:
|
||||
camera_config.onvif.autotracking.enabled
|
||||
and camera_config.onvif.autotracking.enabled_in_config
|
||||
):
|
||||
self._autotracker_setup(camera_config, camera)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._autotracker_setup(camera_config, camera), self.onvif.loop
|
||||
)
|
||||
# Wait for the coroutine to complete
|
||||
future.result()
|
||||
|
||||
def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
|
||||
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
|
||||
logger.debug(f"{camera}: Autotracker init")
|
||||
|
||||
self.object_types[camera] = camera_config.onvif.autotracking.track
|
||||
@@ -242,8 +250,8 @@ class PtzAutoTracker:
|
||||
self.intercept[camera] = None
|
||||
self.move_coefficients[camera] = []
|
||||
|
||||
self.move_queues[camera] = queue.Queue()
|
||||
self.move_queue_locks[camera] = threading.Lock()
|
||||
self.move_queues[camera] = asyncio.Queue()
|
||||
self.move_queue_locks[camera] = asyncio.Lock()
|
||||
|
||||
# handle onvif constructor failing due to no connection
|
||||
if camera not in self.onvif.cams:
|
||||
@@ -255,7 +263,7 @@ class PtzAutoTracker:
|
||||
return
|
||||
|
||||
if not self.onvif.cams[camera]["init"]:
|
||||
if not asyncio.run(self.onvif._init_onvif(camera)):
|
||||
if not await self.onvif._init_onvif(camera):
|
||||
logger.warning(
|
||||
f"Disabling autotracking for {camera}: Unable to initialize onvif"
|
||||
)
|
||||
@@ -271,7 +279,7 @@ class PtzAutoTracker:
|
||||
self.ptz_metrics[camera].autotracker_enabled.value = False
|
||||
return
|
||||
|
||||
move_status_supported = self.onvif.get_service_capabilities(camera)
|
||||
move_status_supported = await self.onvif.get_service_capabilities(camera)
|
||||
|
||||
if not (
|
||||
isinstance(move_status_supported, bool) and move_status_supported
|
||||
@@ -287,15 +295,12 @@ class PtzAutoTracker:
|
||||
return
|
||||
|
||||
if self.onvif.cams[camera]["init"]:
|
||||
self.onvif.get_camera_status(camera)
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
# movement thread per camera
|
||||
self.move_threads[camera] = threading.Thread(
|
||||
name=f"ptz_move_thread_{camera}",
|
||||
target=partial(self._process_move_queue, camera),
|
||||
# movement queue with asyncio on OnvifController loop
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._process_move_queue(camera), self.onvif.loop
|
||||
)
|
||||
self.move_threads[camera].daemon = True
|
||||
self.move_threads[camera].start()
|
||||
|
||||
if camera_config.onvif.autotracking.movement_weights:
|
||||
if len(camera_config.onvif.autotracking.movement_weights) == 6:
|
||||
@@ -330,7 +335,7 @@ class PtzAutoTracker:
|
||||
)
|
||||
|
||||
if camera_config.onvif.autotracking.calibrate_on_startup:
|
||||
self._calibrate_camera(camera)
|
||||
await self._calibrate_camera(camera)
|
||||
|
||||
self.ptz_metrics[camera].tracking_active.clear()
|
||||
self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False)
|
||||
@@ -349,7 +354,7 @@ class PtzAutoTracker:
|
||||
self.config.cameras[camera].onvif.autotracking.movement_weights,
|
||||
)
|
||||
|
||||
def _calibrate_camera(self, camera):
|
||||
async def _calibrate_camera(self, camera):
|
||||
# move the camera from the preset in steps and measure the time it takes to move that amount
|
||||
# this will allow us to predict movement times with a simple linear regression
|
||||
# start with 0 so we can determine a baseline (to be used as the intercept in the regression calc)
|
||||
@@ -373,25 +378,25 @@ class PtzAutoTracker:
|
||||
|
||||
for i in range(2):
|
||||
# absolute move to 0 - fully zoomed out
|
||||
self.onvif._zoom_absolute(
|
||||
await self.onvif._zoom_absolute(
|
||||
camera,
|
||||
self.onvif.cams[camera]["absolute_zoom_range"]["XRange"]["Min"],
|
||||
1,
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
self.onvif.get_camera_status(camera)
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
|
||||
|
||||
self.onvif._zoom_absolute(
|
||||
await self.onvif._zoom_absolute(
|
||||
camera,
|
||||
self.onvif.cams[camera]["absolute_zoom_range"]["XRange"]["Max"],
|
||||
1,
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
self.onvif.get_camera_status(camera)
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
zoom_in_values.append(self.ptz_metrics[camera].zoom_level.value)
|
||||
|
||||
@@ -400,7 +405,7 @@ class PtzAutoTracker:
|
||||
== ZoomingModeEnum.relative
|
||||
):
|
||||
# relative move to -0.01
|
||||
self.onvif._move_relative(
|
||||
await self.onvif._move_relative(
|
||||
camera,
|
||||
0,
|
||||
0,
|
||||
@@ -409,13 +414,13 @@ class PtzAutoTracker:
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
self.onvif.get_camera_status(camera)
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
|
||||
|
||||
zoom_start_time = time.time()
|
||||
# relative move to 0.01
|
||||
self.onvif._move_relative(
|
||||
await self.onvif._move_relative(
|
||||
camera,
|
||||
0,
|
||||
0,
|
||||
@@ -424,13 +429,13 @@ class PtzAutoTracker:
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
self.onvif.get_camera_status(camera)
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
zoom_stop_time = time.time()
|
||||
|
||||
full_relative_start_time = time.time()
|
||||
|
||||
self.onvif._move_relative(
|
||||
await self.onvif._move_relative(
|
||||
camera,
|
||||
-1,
|
||||
-1,
|
||||
@@ -439,11 +444,11 @@ class PtzAutoTracker:
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
self.onvif.get_camera_status(camera)
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
full_relative_stop_time = time.time()
|
||||
|
||||
self.onvif._move_relative(
|
||||
await self.onvif._move_relative(
|
||||
camera,
|
||||
1,
|
||||
1,
|
||||
@@ -452,7 +457,7 @@ class PtzAutoTracker:
|
||||
)
|
||||
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
self.onvif.get_camera_status(camera)
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
self.zoom_time[camera] = (
|
||||
full_relative_stop_time - full_relative_start_time
|
||||
@@ -471,7 +476,7 @@ class PtzAutoTracker:
|
||||
self.ptz_metrics[camera].max_zoom.value = 1
|
||||
self.ptz_metrics[camera].min_zoom.value = 0
|
||||
|
||||
self.onvif._move_to_preset(
|
||||
await self.onvif._move_to_preset(
|
||||
camera,
|
||||
self.config.cameras[camera].onvif.autotracking.return_preset.lower(),
|
||||
)
|
||||
@@ -480,18 +485,18 @@ class PtzAutoTracker:
|
||||
|
||||
# Wait until the camera finishes moving
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
self.onvif.get_camera_status(camera)
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
for step in range(num_steps):
|
||||
pan = step_sizes[step]
|
||||
tilt = step_sizes[step]
|
||||
|
||||
start_time = time.time()
|
||||
self.onvif._move_relative(camera, pan, tilt, 0, 1)
|
||||
await self.onvif._move_relative(camera, pan, tilt, 0, 1)
|
||||
|
||||
# Wait until the camera finishes moving
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
self.onvif.get_camera_status(camera)
|
||||
await self.onvif.get_camera_status(camera)
|
||||
stop_time = time.time()
|
||||
|
||||
self.move_metrics[camera].append(
|
||||
@@ -503,7 +508,7 @@ class PtzAutoTracker:
|
||||
}
|
||||
)
|
||||
|
||||
self.onvif._move_to_preset(
|
||||
await self.onvif._move_to_preset(
|
||||
camera,
|
||||
self.config.cameras[camera].onvif.autotracking.return_preset.lower(),
|
||||
)
|
||||
@@ -512,7 +517,7 @@ class PtzAutoTracker:
|
||||
|
||||
# Wait until the camera finishes moving
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
self.onvif.get_camera_status(camera)
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
logger.info(
|
||||
f"Calibration for {camera} in progress: {round((step / num_steps) * 100)}% complete"
|
||||
@@ -709,18 +714,17 @@ class PtzAutoTracker:
|
||||
centroid_distance < self.tracked_object_metrics[camera]["distance"]
|
||||
)
|
||||
|
||||
def _process_move_queue(self, camera):
|
||||
camera_config = self.config.cameras[camera]
|
||||
camera_config.frame_shape[1]
|
||||
camera_config.frame_shape[0]
|
||||
async def _process_move_queue(self, camera):
|
||||
move_queue = self.move_queues[camera]
|
||||
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
move_data = self.move_queues[camera].get(True, 0.1)
|
||||
except queue.Empty:
|
||||
# Asynchronously wait for move data with a timeout
|
||||
move_data = await asyncio.wait_for(move_queue.get(), timeout=0.1)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
with self.move_queue_locks[camera]:
|
||||
async with self.move_queue_locks[camera]:
|
||||
frame_time, pan, tilt, zoom = move_data
|
||||
|
||||
# if we're receiving move requests during a PTZ move, ignore them
|
||||
@@ -729,8 +733,6 @@ class PtzAutoTracker:
|
||||
self.ptz_metrics[camera].start_time.value,
|
||||
self.ptz_metrics[camera].stop_time.value,
|
||||
):
|
||||
# instead of dequeueing this might be a good place to preemptively move based
|
||||
# on an estimate - for fast moving objects, etc.
|
||||
logger.debug(
|
||||
f"{camera}: Move queue: PTZ moving, dequeueing move request - frame time: {frame_time}, final pan: {pan}, final tilt: {tilt}, final zoom: {zoom}"
|
||||
)
|
||||
@@ -741,25 +743,24 @@ class PtzAutoTracker:
|
||||
self.config.cameras[camera].onvif.autotracking.zooming
|
||||
== ZoomingModeEnum.relative
|
||||
):
|
||||
self.onvif._move_relative(camera, pan, tilt, zoom, 1)
|
||||
|
||||
await self.onvif._move_relative(camera, pan, tilt, zoom, 1)
|
||||
else:
|
||||
if pan != 0 or tilt != 0:
|
||||
self.onvif._move_relative(camera, pan, tilt, 0, 1)
|
||||
await self.onvif._move_relative(camera, pan, tilt, 0, 1)
|
||||
|
||||
# Wait until the camera finishes moving
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
self.onvif.get_camera_status(camera)
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
if (
|
||||
zoom > 0
|
||||
and self.ptz_metrics[camera].zoom_level.value != zoom
|
||||
):
|
||||
self.onvif._zoom_absolute(camera, zoom, 1)
|
||||
await self.onvif._zoom_absolute(camera, zoom, 1)
|
||||
|
||||
# Wait until the camera finishes moving
|
||||
while not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
self.onvif.get_camera_status(camera)
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
if self.config.cameras[camera].onvif.autotracking.movement_weights:
|
||||
logger.debug(
|
||||
@@ -796,6 +797,10 @@ class PtzAutoTracker:
|
||||
# calculate new coefficients if we have enough data
|
||||
self._calculate_move_coefficients(camera)
|
||||
|
||||
# Clean up the queue on exit
|
||||
while not move_queue.empty():
|
||||
await move_queue.get()
|
||||
|
||||
def _enqueue_move(self, camera, frame_time, pan, tilt, zoom):
|
||||
def split_value(value, suppress_diff=True):
|
||||
clipped = np.clip(value, -1, 1)
|
||||
@@ -824,7 +829,9 @@ class PtzAutoTracker:
|
||||
f"{camera}: Enqueue movement for frame time: {frame_time} pan: {pan}, tilt: {tilt}, zoom: {zoom}"
|
||||
)
|
||||
move_data = (frame_time, pan, tilt, zoom)
|
||||
self.move_queues[camera].put(move_data)
|
||||
self.onvif.loop.call_soon_threadsafe(
|
||||
self.move_queues[camera].put_nowait, move_data
|
||||
)
|
||||
|
||||
# reset values to not split up large movements
|
||||
pan = 0
|
||||
@@ -1102,6 +1109,7 @@ class PtzAutoTracker:
|
||||
camera_height = camera_config.frame_shape[0]
|
||||
camera_fps = camera_config.detect.fps
|
||||
predicted_movement_time = 0
|
||||
zoom_distance = 0
|
||||
|
||||
average_velocity = np.zeros((4,))
|
||||
predicted_box = obj.obj_data["box"]
|
||||
@@ -1164,7 +1172,20 @@ class PtzAutoTracker:
|
||||
zoom_predicted_movement_time = 0
|
||||
|
||||
if np.any(average_velocity):
|
||||
zoom_predicted_movement_time = abs(zoom) * self.zoom_time[camera]
|
||||
# Calculate the intended change in zoom level
|
||||
zoom_change = (1 - abs(zoom)) * (1 if zoom >= 0 else -1)
|
||||
|
||||
# Calculate new zoom level and clamp to [0, 1]
|
||||
new_zoom = max(
|
||||
0, min(1, self.ptz_metrics[camera].zoom_level.value + zoom_change)
|
||||
)
|
||||
|
||||
# Calculate the actual zoom distance
|
||||
zoom_distance = abs(
|
||||
new_zoom - self.ptz_metrics[camera].zoom_level.value
|
||||
)
|
||||
|
||||
zoom_predicted_movement_time = zoom_distance * self.zoom_time[camera]
|
||||
|
||||
zoom_predicted_box = (
|
||||
predicted_box
|
||||
@@ -1181,7 +1202,7 @@ class PtzAutoTracker:
|
||||
tilt = (0.5 - (centroid_y / camera_height)) * 2
|
||||
|
||||
logger.debug(
|
||||
f"{camera}: Zoom amount: {zoom}, zoom predicted time: {zoom_predicted_movement_time}, zoom predicted box: {tuple(zoom_predicted_box)}"
|
||||
f"{camera}: Zoom amount: {zoom}, zoom distance: {zoom_distance}, zoom predicted time: {zoom_predicted_movement_time}, zoom predicted box: {tuple(zoom_predicted_box)}"
|
||||
)
|
||||
|
||||
self._enqueue_move(camera, obj.obj_data["frame_time"], pan, tilt, zoom)
|
||||
@@ -1420,7 +1441,7 @@ class PtzAutoTracker:
|
||||
** (1 / self.zoom_factor[camera])
|
||||
}
|
||||
|
||||
def camera_maintenance(self, camera):
|
||||
async def camera_maintenance(self, camera):
|
||||
# bail and don't check anything if we're calibrating or tracking an object
|
||||
if (
|
||||
not self.autotracker_init[camera]
|
||||
@@ -1437,7 +1458,7 @@ class PtzAutoTracker:
|
||||
self._autotracker_setup(self.config.cameras[camera], camera)
|
||||
# regularly update camera status
|
||||
if not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
self.onvif.get_camera_status(camera)
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
# return to preset if tracking is over
|
||||
if (
|
||||
@@ -1455,22 +1476,18 @@ class PtzAutoTracker:
|
||||
self.tracked_object[camera] = None
|
||||
self.tracked_object_history[camera].clear()
|
||||
|
||||
# empty move queue
|
||||
while not self.move_queues[camera].empty():
|
||||
self.move_queues[camera].get()
|
||||
|
||||
self.ptz_metrics[camera].motor_stopped.wait()
|
||||
logger.debug(
|
||||
f"{camera}: Time is {self.ptz_metrics[camera].frame_time.value}, returning to preset: {autotracker_config.return_preset}"
|
||||
)
|
||||
self.onvif._move_to_preset(
|
||||
await self.onvif._move_to_preset(
|
||||
camera,
|
||||
autotracker_config.return_preset.lower(),
|
||||
)
|
||||
|
||||
# update stored zoom level from preset
|
||||
if not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
self.onvif.get_camera_status(camera)
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
self.ptz_metrics[camera].tracking_active.clear()
|
||||
self.dispatcher.publish(
|
||||
|
||||
+144
-80
@@ -2,10 +2,12 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from enum import Enum
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy
|
||||
from onvif import ONVIFCamera, ONVIFError, ONVIFService
|
||||
@@ -39,27 +41,56 @@ class OnvifController:
|
||||
def __init__(
|
||||
self, config: FrigateConfig, ptz_metrics: dict[str, PTZMetrics]
|
||||
) -> None:
|
||||
self.cams: dict[str, ONVIFCamera] = {}
|
||||
self.cams: dict[str, dict] = {}
|
||||
self.failed_cams: dict[str, dict] = {}
|
||||
self.max_retries = 5
|
||||
self.reset_timeout = 900 # 15 minutes
|
||||
|
||||
self.config = config
|
||||
self.ptz_metrics = ptz_metrics
|
||||
|
||||
# Create a dedicated event loop and run it in a separate thread
|
||||
self.loop = asyncio.new_event_loop()
|
||||
self.loop_thread = threading.Thread(target=self._run_event_loop, daemon=True)
|
||||
self.loop_thread.start()
|
||||
|
||||
self.camera_configs = {}
|
||||
for cam_name, cam in config.cameras.items():
|
||||
if not cam.enabled:
|
||||
continue
|
||||
|
||||
if cam.onvif.host:
|
||||
result = self._create_onvif_camera(cam_name, cam)
|
||||
if result:
|
||||
self.cams[cam_name] = result
|
||||
self.camera_configs[cam_name] = cam
|
||||
|
||||
def _create_onvif_camera(self, cam_name: str, cam) -> dict | None:
|
||||
"""Create an ONVIF camera instance and handle failures."""
|
||||
asyncio.run_coroutine_threadsafe(self._init_cameras(), self.loop)
|
||||
|
||||
def _run_event_loop(self) -> None:
|
||||
"""Run the event loop in a separate thread."""
|
||||
asyncio.set_event_loop(self.loop)
|
||||
try:
|
||||
return {
|
||||
self.loop.run_forever()
|
||||
except Exception as e:
|
||||
logger.error(f"Onvif event loop terminated unexpectedly: {e}")
|
||||
|
||||
async def _init_cameras(self) -> None:
|
||||
"""Initialize all configured cameras."""
|
||||
for cam_name in self.camera_configs:
|
||||
await self._init_single_camera(cam_name)
|
||||
|
||||
async def _init_single_camera(self, cam_name: str) -> bool:
|
||||
"""Initialize a single camera by name.
|
||||
|
||||
Args:
|
||||
cam_name: The name of the camera to initialize
|
||||
|
||||
Returns:
|
||||
bool: True if initialization succeeded, False otherwise
|
||||
"""
|
||||
if cam_name not in self.camera_configs:
|
||||
logger.error(f"No configuration found for camera {cam_name}")
|
||||
return False
|
||||
|
||||
cam = self.camera_configs[cam_name]
|
||||
try:
|
||||
self.cams[cam_name] = {
|
||||
"onvif": ONVIFCamera(
|
||||
cam.onvif.host,
|
||||
cam.onvif.port,
|
||||
@@ -74,7 +105,8 @@ class OnvifController:
|
||||
"features": [],
|
||||
"presets": {},
|
||||
}
|
||||
except ONVIFError as e:
|
||||
return True
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.error(f"Failed to create ONVIF camera instance for {cam_name}: {e}")
|
||||
# track initial failures
|
||||
self.failed_cams[cam_name] = {
|
||||
@@ -82,7 +114,7 @@ class OnvifController:
|
||||
"last_error": str(e),
|
||||
"last_attempt": time.time(),
|
||||
}
|
||||
return None
|
||||
return False
|
||||
|
||||
async def _init_onvif(self, camera_name: str) -> bool:
|
||||
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
||||
@@ -100,7 +132,7 @@ class OnvifController:
|
||||
# this will fire an exception if camera is not a ptz
|
||||
capabilities = onvif.get_definition("ptz")
|
||||
logger.debug(f"Onvif capabilities for {camera_name}: {capabilities}")
|
||||
except (ONVIFError, Fault, TransportError) as e:
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.error(
|
||||
f"Unable to get Onvif capabilities for camera: {camera_name}: {e}"
|
||||
)
|
||||
@@ -109,7 +141,7 @@ class OnvifController:
|
||||
try:
|
||||
profiles = await media.GetProfiles()
|
||||
logger.debug(f"Onvif profiles for {camera_name}: {profiles}")
|
||||
except (ONVIFError, Fault, TransportError) as e:
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.error(
|
||||
f"Unable to get Onvif media profiles for camera: {camera_name}: {e}"
|
||||
)
|
||||
@@ -240,12 +272,12 @@ class OnvifController:
|
||||
logger.debug(
|
||||
f"{camera_name}: Relative move request after deleting zoom: {move_request}"
|
||||
)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
self.config.cameras[
|
||||
camera_name
|
||||
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported"
|
||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
|
||||
)
|
||||
|
||||
if move_request.Speed is None:
|
||||
@@ -263,7 +295,7 @@ class OnvifController:
|
||||
# setup existing presets
|
||||
try:
|
||||
presets: list[dict] = await ptz.GetPresets({"ProfileToken": profile.token})
|
||||
except ONVIFError as e:
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.warning(f"Unable to get presets from camera: {camera_name}: {e}")
|
||||
presets = []
|
||||
|
||||
@@ -295,7 +327,7 @@ class OnvifController:
|
||||
self.cams[camera_name]["relative_zoom_range"] = (
|
||||
ptz_config.Spaces.RelativeZoomTranslationSpace[0]
|
||||
)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
== ZoomingModeEnum.relative
|
||||
@@ -304,7 +336,7 @@ class OnvifController:
|
||||
camera_name
|
||||
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported"
|
||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported. Exception: {e}"
|
||||
)
|
||||
|
||||
if configs.DefaultAbsoluteZoomPositionSpace:
|
||||
@@ -319,13 +351,13 @@ class OnvifController:
|
||||
ptz_config.Spaces.AbsoluteZoomPositionSpace[0]
|
||||
)
|
||||
self.cams[camera_name]["zoom_limits"] = configs.ZoomLimits
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
if self.config.cameras[camera_name].onvif.autotracking.zooming:
|
||||
self.config.cameras[
|
||||
camera_name
|
||||
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported"
|
||||
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
|
||||
)
|
||||
|
||||
# set relative pan/tilt space for autotracker
|
||||
@@ -344,25 +376,23 @@ class OnvifController:
|
||||
self.cams[camera_name]["init"] = True
|
||||
return True
|
||||
|
||||
def _stop(self, camera_name: str) -> None:
|
||||
async def _stop(self, camera_name: str) -> None:
|
||||
move_request = self.cams[camera_name]["move_request"]
|
||||
asyncio.run(
|
||||
self.cams[camera_name]["ptz"].Stop(
|
||||
{
|
||||
"ProfileToken": move_request.ProfileToken,
|
||||
"PanTilt": True,
|
||||
"Zoom": True,
|
||||
}
|
||||
)
|
||||
await self.cams[camera_name]["ptz"].Stop(
|
||||
{
|
||||
"ProfileToken": move_request.ProfileToken,
|
||||
"PanTilt": True,
|
||||
"Zoom": True,
|
||||
}
|
||||
)
|
||||
self.cams[camera_name]["active"] = False
|
||||
|
||||
def _move(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||
async def _move(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||
if self.cams[camera_name]["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, stopping..."
|
||||
)
|
||||
self._stop(camera_name)
|
||||
await self._stop(camera_name)
|
||||
|
||||
if "pt" not in self.cams[camera_name]["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF pan/tilt movement.")
|
||||
@@ -391,11 +421,11 @@ class OnvifController:
|
||||
}
|
||||
|
||||
try:
|
||||
asyncio.run(self.cams[camera_name]["ptz"].ContinuousMove(move_request))
|
||||
except ONVIFError as e:
|
||||
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.warning(f"Onvif sending move request to {camera_name} failed: {e}")
|
||||
|
||||
def _move_relative(self, camera_name: str, pan, tilt, zoom, speed) -> None:
|
||||
async def _move_relative(self, camera_name: str, pan, tilt, zoom, speed) -> None:
|
||||
if "pt-r-fov" not in self.cams[camera_name]["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
|
||||
return
|
||||
@@ -464,7 +494,7 @@ class OnvifController:
|
||||
}
|
||||
move_request.Translation.Zoom.x = zoom
|
||||
|
||||
asyncio.run(self.cams[camera_name]["ptz"].RelativeMove(move_request))
|
||||
await self.cams[camera_name]["ptz"].RelativeMove(move_request)
|
||||
|
||||
# reset after the move request
|
||||
move_request.Translation.PanTilt.x = 0
|
||||
@@ -479,7 +509,7 @@ class OnvifController:
|
||||
|
||||
self.cams[camera_name]["active"] = False
|
||||
|
||||
def _move_to_preset(self, camera_name: str, preset: str) -> None:
|
||||
async def _move_to_preset(self, camera_name: str, preset: str) -> None:
|
||||
if preset not in self.cams[camera_name]["presets"]:
|
||||
logger.error(f"{preset} is not a valid preset for {camera_name}")
|
||||
return
|
||||
@@ -489,23 +519,22 @@ class OnvifController:
|
||||
self.ptz_metrics[camera_name].stop_time.value = 0
|
||||
move_request = self.cams[camera_name]["move_request"]
|
||||
preset_token = self.cams[camera_name]["presets"][preset]
|
||||
asyncio.run(
|
||||
self.cams[camera_name]["ptz"].GotoPreset(
|
||||
{
|
||||
"ProfileToken": move_request.ProfileToken,
|
||||
"PresetToken": preset_token,
|
||||
}
|
||||
)
|
||||
|
||||
await self.cams[camera_name]["ptz"].GotoPreset(
|
||||
{
|
||||
"ProfileToken": move_request.ProfileToken,
|
||||
"PresetToken": preset_token,
|
||||
}
|
||||
)
|
||||
|
||||
self.cams[camera_name]["active"] = False
|
||||
|
||||
def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||
async def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||
if self.cams[camera_name]["active"]:
|
||||
logger.warning(
|
||||
f"{camera_name} is already performing an action, stopping..."
|
||||
)
|
||||
self._stop(camera_name)
|
||||
await self._stop(camera_name)
|
||||
|
||||
if "zoom" not in self.cams[camera_name]["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF zooming.")
|
||||
@@ -519,9 +548,9 @@ class OnvifController:
|
||||
elif command == OnvifCommandEnum.zoom_out:
|
||||
move_request.Velocity = {"Zoom": {"x": -0.5}}
|
||||
|
||||
asyncio.run(self.cams[camera_name]["ptz"].ContinuousMove(move_request))
|
||||
await self.cams[camera_name]["ptz"].ContinuousMove(move_request)
|
||||
|
||||
def _zoom_absolute(self, camera_name: str, zoom, speed) -> None:
|
||||
async def _zoom_absolute(self, camera_name: str, zoom, speed) -> None:
|
||||
if "zoom-a" not in self.cams[camera_name]["features"]:
|
||||
logger.error(f"{camera_name} does not support ONVIF AbsoluteMove zooming.")
|
||||
return
|
||||
@@ -560,19 +589,20 @@ class OnvifController:
|
||||
|
||||
logger.debug(f"{camera_name}: Absolute zoom: {zoom}")
|
||||
|
||||
asyncio.run(self.cams[camera_name]["ptz"].AbsoluteMove(move_request))
|
||||
await self.cams[camera_name]["ptz"].AbsoluteMove(move_request)
|
||||
|
||||
self.cams[camera_name]["active"] = False
|
||||
|
||||
def handle_command(
|
||||
async def handle_command_async(
|
||||
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
|
||||
) -> None:
|
||||
"""Handle ONVIF commands asynchronously"""
|
||||
if camera_name not in self.cams.keys():
|
||||
logger.error(f"ONVIF is not configured for {camera_name}")
|
||||
return
|
||||
|
||||
if not self.cams[camera_name]["init"]:
|
||||
if not asyncio.run(self._init_onvif(camera_name)):
|
||||
if not await self._init_onvif(camera_name):
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -580,23 +610,44 @@ class OnvifController:
|
||||
# already init
|
||||
return
|
||||
elif command == OnvifCommandEnum.stop:
|
||||
self._stop(camera_name)
|
||||
await self._stop(camera_name)
|
||||
elif command == OnvifCommandEnum.preset:
|
||||
self._move_to_preset(camera_name, param)
|
||||
await self._move_to_preset(camera_name, param)
|
||||
elif command == OnvifCommandEnum.move_relative:
|
||||
_, pan, tilt = param.split("_")
|
||||
self._move_relative(camera_name, float(pan), float(tilt), 0, 1)
|
||||
await self._move_relative(camera_name, float(pan), float(tilt), 0, 1)
|
||||
elif (
|
||||
command == OnvifCommandEnum.zoom_in
|
||||
or command == OnvifCommandEnum.zoom_out
|
||||
):
|
||||
self._zoom(camera_name, command)
|
||||
await self._zoom(camera_name, command)
|
||||
else:
|
||||
self._move(camera_name, command)
|
||||
except ONVIFError as e:
|
||||
await self._move(camera_name, command)
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.error(f"Unable to handle onvif command: {e}")
|
||||
|
||||
async def get_camera_info(self, camera_name: str) -> dict[str, any]:
|
||||
def handle_command(
|
||||
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
|
||||
) -> None:
|
||||
"""
|
||||
Handle ONVIF commands by scheduling them in the event loop.
|
||||
This is the synchronous interface that schedules async work.
|
||||
"""
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.handle_command_async(camera_name, command, param), self.loop
|
||||
)
|
||||
|
||||
try:
|
||||
# Wait with a timeout to prevent blocking indefinitely
|
||||
future.result(timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Command {command} timed out for camera {camera_name}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error executing command {command} for camera {camera_name}: {e}"
|
||||
)
|
||||
|
||||
async def get_camera_info(self, camera_name: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get ptz capabilities and presets, attempting to reconnect if ONVIF is configured
|
||||
but not initialized.
|
||||
@@ -609,26 +660,23 @@ class OnvifController:
|
||||
)
|
||||
return {}
|
||||
|
||||
if camera_name not in self.cams and (
|
||||
if camera_name not in self.cams.keys() and (
|
||||
camera_name not in self.config.cameras
|
||||
or not self.config.cameras[camera_name].onvif.host
|
||||
):
|
||||
logger.debug(f"ONVIF is not configured for {camera_name}")
|
||||
return {}
|
||||
|
||||
if camera_name in self.cams and self.cams[camera_name]["init"]:
|
||||
if camera_name in self.cams.keys() and self.cams[camera_name]["init"]:
|
||||
return {
|
||||
"name": camera_name,
|
||||
"features": self.cams[camera_name]["features"],
|
||||
"presets": list(self.cams[camera_name]["presets"].keys()),
|
||||
}
|
||||
|
||||
if camera_name not in self.cams and camera_name in self.config.cameras:
|
||||
cam = self.config.cameras[camera_name]
|
||||
result = self._create_onvif_camera(camera_name, cam)
|
||||
if result:
|
||||
self.cams[camera_name] = result
|
||||
else:
|
||||
if camera_name not in self.cams.keys() and camera_name in self.config.cameras:
|
||||
success = await self._init_single_camera(camera_name)
|
||||
if not success:
|
||||
return {}
|
||||
|
||||
# Reset retry count after timeout
|
||||
@@ -681,23 +729,21 @@ class OnvifController:
|
||||
logger.debug(f"Could not initialize ONVIF for {camera_name}")
|
||||
return {}
|
||||
|
||||
def get_service_capabilities(self, camera_name: str) -> None:
|
||||
async def get_service_capabilities(self, camera_name: str) -> None:
|
||||
if camera_name not in self.cams.keys():
|
||||
logger.error(f"ONVIF is not configured for {camera_name}")
|
||||
return {}
|
||||
|
||||
if not self.cams[camera_name]["init"]:
|
||||
asyncio.run(self._init_onvif(camera_name))
|
||||
await self._init_onvif(camera_name)
|
||||
|
||||
service_capabilities_request = self.cams[camera_name][
|
||||
"service_capabilities_request"
|
||||
]
|
||||
try:
|
||||
service_capabilities = asyncio.run(
|
||||
self.cams[camera_name]["ptz"].GetServiceCapabilities(
|
||||
service_capabilities_request
|
||||
)
|
||||
)
|
||||
service_capabilities = await self.cams[camera_name][
|
||||
"ptz"
|
||||
].GetServiceCapabilities(service_capabilities_request)
|
||||
|
||||
logger.debug(
|
||||
f"Onvif service capabilities for {camera_name}: {service_capabilities}"
|
||||
@@ -705,25 +751,24 @@ class OnvifController:
|
||||
|
||||
# MoveStatus is required for autotracking - should return "true" if supported
|
||||
return find_by_key(vars(service_capabilities), "MoveStatus")
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Camera {camera_name} does not support the ONVIF GetServiceCapabilities method. Autotracking will not function correctly and must be disabled in your config."
|
||||
f"Camera {camera_name} does not support the ONVIF GetServiceCapabilities method. Autotracking will not function correctly and must be disabled in your config. Exception: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
def get_camera_status(self, camera_name: str) -> None:
|
||||
async def get_camera_status(self, camera_name: str) -> None:
|
||||
if camera_name not in self.cams.keys():
|
||||
logger.error(f"ONVIF is not configured for {camera_name}")
|
||||
return {}
|
||||
return
|
||||
|
||||
if not self.cams[camera_name]["init"]:
|
||||
asyncio.run(self._init_onvif(camera_name))
|
||||
if not await self._init_onvif(camera_name):
|
||||
return
|
||||
|
||||
status_request = self.cams[camera_name]["status_request"]
|
||||
try:
|
||||
status = asyncio.run(
|
||||
self.cams[camera_name]["ptz"].GetStatus(status_request)
|
||||
)
|
||||
status = await self.cams[camera_name]["ptz"].GetStatus(status_request)
|
||||
except Exception:
|
||||
pass # We're unsupported, that'll be reported in the next check.
|
||||
|
||||
@@ -807,3 +852,22 @@ class OnvifController:
|
||||
camera_name
|
||||
].frame_time.value
|
||||
logger.warning(f"Camera {camera_name} is still in ONVIF 'MOVING' status.")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Gracefully shut down the ONVIF controller."""
|
||||
if not hasattr(self, "loop") or self.loop.is_closed():
|
||||
logger.debug("ONVIF controller already closed")
|
||||
return
|
||||
|
||||
logger.info("Exiting ONVIF controller...")
|
||||
|
||||
def stop_and_cleanup():
|
||||
try:
|
||||
self.loop.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error during loop cleanup: {e}")
|
||||
|
||||
# Schedule stop and cleanup in the loop thread
|
||||
self.loop.call_soon_threadsafe(stop_and_cleanup)
|
||||
|
||||
self.loop_thread.join()
|
||||
|
||||
@@ -69,7 +69,7 @@ class RecordingCleanup(threading.Thread):
|
||||
now - datetime.timedelta(days=config.record.detections.retain.days)
|
||||
).timestamp()
|
||||
expired_reviews: ReviewSegment = (
|
||||
ReviewSegment.select(ReviewSegment.id)
|
||||
ReviewSegment.select(ReviewSegment.id, ReviewSegment.thumb_path)
|
||||
.where(ReviewSegment.camera == config.name)
|
||||
.where(
|
||||
(
|
||||
@@ -84,6 +84,10 @@ class RecordingCleanup(threading.Thread):
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
thumbs_to_delete = list(map(lambda x: x[1], expired_reviews))
|
||||
for thumb_path in thumbs_to_delete:
|
||||
Path(thumb_path).unlink(missing_ok=True)
|
||||
|
||||
max_deletes = 100000
|
||||
deleted_reviews_list = list(map(lambda x: x[0], expired_reviews))
|
||||
for i in range(0, len(deleted_reviews_list), max_deletes):
|
||||
|
||||
@@ -242,7 +242,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
|
||||
async def validate_and_move_segment(
|
||||
self, camera: str, reviews: list[ReviewSegment], recording: dict[str, any]
|
||||
self, camera: str, reviews: list[ReviewSegment], recording: dict[str, Any]
|
||||
) -> None:
|
||||
cache_path: str = recording["cache_path"]
|
||||
start_time: datetime.datetime = recording["start_time"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Maintain review segments in db."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -9,7 +10,7 @@ import sys
|
||||
import threading
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -119,21 +120,23 @@ class PendingReviewSegment:
|
||||
)
|
||||
|
||||
def get_data(self, ended: bool) -> dict:
|
||||
return {
|
||||
ReviewSegment.id.name: self.id,
|
||||
ReviewSegment.camera.name: self.camera,
|
||||
ReviewSegment.start_time.name: self.start_time,
|
||||
ReviewSegment.end_time.name: self.last_update if ended else None,
|
||||
ReviewSegment.severity.name: self.severity.value,
|
||||
ReviewSegment.thumb_path.name: self.frame_path,
|
||||
ReviewSegment.data.name: {
|
||||
"detections": list(set(self.detections.keys())),
|
||||
"objects": list(set(self.detections.values())),
|
||||
"sub_labels": list(self.sub_labels.values()),
|
||||
"zones": self.zones,
|
||||
"audio": list(self.audio),
|
||||
},
|
||||
}.copy()
|
||||
return copy.deepcopy(
|
||||
{
|
||||
ReviewSegment.id.name: self.id,
|
||||
ReviewSegment.camera.name: self.camera,
|
||||
ReviewSegment.start_time.name: self.start_time,
|
||||
ReviewSegment.end_time.name: self.last_update if ended else None,
|
||||
ReviewSegment.severity.name: self.severity.value,
|
||||
ReviewSegment.thumb_path.name: self.frame_path,
|
||||
ReviewSegment.data.name: {
|
||||
"detections": list(set(self.detections.keys())),
|
||||
"objects": list(set(self.detections.values())),
|
||||
"sub_labels": list(self.sub_labels.values()),
|
||||
"zones": self.zones,
|
||||
"audio": list(self.audio),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ReviewSegmentMaintainer(threading.Thread):
|
||||
@@ -153,7 +156,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.all)
|
||||
|
||||
# manual events
|
||||
self.indefinite_events: dict[str, dict[str, any]] = {}
|
||||
self.indefinite_events: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# ensure dirs
|
||||
Path(os.path.join(CLIPS_DIR, "review")).mkdir(exist_ok=True)
|
||||
@@ -191,7 +194,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
camera_config: CameraConfig,
|
||||
frame,
|
||||
objects: list[TrackedObject],
|
||||
prev_data: dict[str, any],
|
||||
prev_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update segment."""
|
||||
if frame is not None:
|
||||
@@ -216,7 +219,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
def _publish_segment_end(
|
||||
self,
|
||||
segment: PendingReviewSegment,
|
||||
prev_data: dict[str, any],
|
||||
prev_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""End segment."""
|
||||
final_data = segment.get_data(ended=True)
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
import threading
|
||||
import time
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
@@ -33,12 +33,12 @@ class StatsEmitter(threading.Thread):
|
||||
self.stats_tracking = stats_tracking
|
||||
self.stop_event = stop_event
|
||||
self.hwaccel_errors: list[str] = []
|
||||
self.stats_history: list[dict[str, any]] = []
|
||||
self.stats_history: list[dict[str, Any]] = []
|
||||
|
||||
# create communication for stats
|
||||
self.requestor = InterProcessRequestor()
|
||||
|
||||
def get_latest_stats(self) -> dict[str, any]:
|
||||
def get_latest_stats(self) -> dict[str, Any]:
|
||||
"""Get latest stats."""
|
||||
if len(self.stats_history) > 0:
|
||||
return self.stats_history[-1]
|
||||
@@ -51,12 +51,12 @@ class StatsEmitter(threading.Thread):
|
||||
|
||||
def get_stats_history(
|
||||
self, keys: Optional[list[str]] = None
|
||||
) -> list[dict[str, any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get stats history."""
|
||||
if not keys:
|
||||
return self.stats_history
|
||||
|
||||
selected_stats: list[dict[str, any]] = []
|
||||
selected_stats: list[dict[str, Any]] = []
|
||||
|
||||
for s in self.stats_history:
|
||||
selected = {}
|
||||
|
||||
@@ -39,7 +39,7 @@ class TestConfig(unittest.TestCase):
|
||||
"description": "Fine tuned model",
|
||||
"trainDate": "2023-04-28T23:22:01.262Z",
|
||||
"type": "ssd",
|
||||
"supportedDetectors": ["edgetpu"],
|
||||
"supportedDetectors": ["cpu", "edgetpu"],
|
||||
"width": 320,
|
||||
"height": 320,
|
||||
"inputShape": "nhwc",
|
||||
|
||||
+8
-7
@@ -5,6 +5,7 @@ import queue
|
||||
import threading
|
||||
from multiprocessing import Queue
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.events.maintainer import EventStateEnum, EventTypeEnum
|
||||
@@ -27,7 +28,7 @@ class TimelineProcessor(threading.Thread):
|
||||
self.config = config
|
||||
self.queue = queue
|
||||
self.stop_event = stop_event
|
||||
self.pre_event_cache: dict[str, list[dict[str, any]]] = {}
|
||||
self.pre_event_cache: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
@@ -55,9 +56,9 @@ class TimelineProcessor(threading.Thread):
|
||||
|
||||
def insert_or_save(
|
||||
self,
|
||||
entry: dict[str, any],
|
||||
prev_event_data: dict[any, any],
|
||||
event_data: dict[any, any],
|
||||
entry: dict[str, Any],
|
||||
prev_event_data: dict[Any, Any],
|
||||
event_data: dict[Any, Any],
|
||||
) -> None:
|
||||
"""Insert into db or cache."""
|
||||
id = entry[Timeline.source_id]
|
||||
@@ -81,8 +82,8 @@ class TimelineProcessor(threading.Thread):
|
||||
self,
|
||||
camera: str,
|
||||
event_type: str,
|
||||
prev_event_data: dict[any, any],
|
||||
event_data: dict[any, any],
|
||||
prev_event_data: dict[Any, Any],
|
||||
event_data: dict[Any, Any],
|
||||
) -> bool:
|
||||
"""Handle object detection."""
|
||||
save = False
|
||||
@@ -153,7 +154,7 @@ class TimelineProcessor(threading.Thread):
|
||||
self,
|
||||
camera: str,
|
||||
event_type: str,
|
||||
event_data: dict[any, any],
|
||||
event_data: dict[Any, Any],
|
||||
) -> bool:
|
||||
if event_type != "new":
|
||||
return False
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from frigate.config import DetectConfig
|
||||
|
||||
@@ -10,6 +11,6 @@ class ObjectTracker(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def match_and_update(
|
||||
self, frame_name: str, frame_time: float, detections: list[dict[str, any]]
|
||||
self, frame_name: str, frame_time: float, detections: list[dict[str, Any]]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from typing import Sequence
|
||||
from typing import Any, Sequence
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -460,7 +460,7 @@ class NorfairTracker(ObjectTracker):
|
||||
self.match_and_update(frame_name, frame_time, detections=detections)
|
||||
|
||||
def match_and_update(
|
||||
self, frame_name: str, frame_time: float, detections: list[dict[str, any]]
|
||||
self, frame_name: str, frame_time: float, detections: list[dict[str, Any]]
|
||||
):
|
||||
# Group detections by object type
|
||||
detections_by_type = {}
|
||||
|
||||
@@ -7,6 +7,7 @@ import threading
|
||||
from collections import defaultdict
|
||||
from enum import Enum
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -70,7 +71,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
self.event_end_subscriber = EventEndSubscriber()
|
||||
self.sub_label_subscriber = EventMetadataSubscriber(EventMetadataTypeEnum.all)
|
||||
|
||||
self.camera_activity: dict[str, dict[str, any]] = {}
|
||||
self.camera_activity: dict[str, dict[str, Any]] = {}
|
||||
self.ongoing_manual_events: dict[str, str] = {}
|
||||
|
||||
# {
|
||||
@@ -248,7 +249,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
def should_mqtt_snapshot(self, camera, obj: TrackedObject):
|
||||
# object never changed position
|
||||
if obj.obj_data["position_changes"] == 0:
|
||||
if obj.is_stationary():
|
||||
return False
|
||||
|
||||
# if there are required zones and there is no overlap
|
||||
@@ -301,7 +302,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
return {}
|
||||
|
||||
def get_current_frame(
|
||||
self, camera: str, draw_options: dict[str, any] = {}
|
||||
self, camera: str, draw_options: dict[str, Any] = {}
|
||||
) -> np.ndarray | None:
|
||||
if camera == "birdseye":
|
||||
return self.frame_manager.get(
|
||||
|
||||
@@ -5,7 +5,7 @@ import math
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from statistics import median
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -18,6 +18,7 @@ from frigate.config import (
|
||||
)
|
||||
from frigate.const import CLIPS_DIR, THUMB_DIR
|
||||
from frigate.review.types import SeverityEnum
|
||||
from frigate.util.builtin import sanitize_float
|
||||
from frigate.util.image import (
|
||||
area,
|
||||
calculate_region,
|
||||
@@ -38,7 +39,7 @@ class TrackedObject:
|
||||
camera_config: CameraConfig,
|
||||
ui_config: UIConfig,
|
||||
frame_cache,
|
||||
obj_data: dict[str, any],
|
||||
obj_data: dict[str, Any],
|
||||
):
|
||||
# set the score history then remove as it is not part of object state
|
||||
self.score_history = obj_data["score_history"]
|
||||
@@ -154,7 +155,7 @@ class TrackedObject:
|
||||
"attributes": obj_data["attributes"],
|
||||
"current_estimated_speed": self.current_estimated_speed,
|
||||
"velocity_angle": self.velocity_angle,
|
||||
"path_data": self.path_data,
|
||||
"path_data": self.path_data.copy(),
|
||||
"recognized_license_plate": obj_data.get(
|
||||
"recognized_license_plate"
|
||||
),
|
||||
@@ -202,6 +203,11 @@ class TrackedObject:
|
||||
)
|
||||
)
|
||||
|
||||
# users can configure speed zones incorrectly, so sanitize speed_magnitude
|
||||
# and velocity_angle in case the values come back as inf or NaN
|
||||
speed_magnitude = sanitize_float(speed_magnitude)
|
||||
self.velocity_angle = sanitize_float(self.velocity_angle)
|
||||
|
||||
if self.ui_config.unit_system == "metric":
|
||||
self.current_estimated_speed = (
|
||||
speed_magnitude * 3.6
|
||||
@@ -378,22 +384,22 @@ class TrackedObject:
|
||||
"current_estimated_speed": self.current_estimated_speed,
|
||||
"average_estimated_speed": self.average_estimated_speed,
|
||||
"velocity_angle": self.velocity_angle,
|
||||
"path_data": self.path_data,
|
||||
"path_data": self.path_data.copy(),
|
||||
"recognized_license_plate": self.obj_data.get("recognized_license_plate"),
|
||||
}
|
||||
|
||||
return event
|
||||
|
||||
def is_active(self):
|
||||
def is_active(self) -> bool:
|
||||
return not self.is_stationary()
|
||||
|
||||
def is_stationary(self):
|
||||
def is_stationary(self) -> bool:
|
||||
return (
|
||||
self.obj_data["motionless_count"]
|
||||
> self.camera_config.detect.stationary.threshold
|
||||
)
|
||||
|
||||
def get_thumbnail(self, ext: str):
|
||||
def get_thumbnail(self, ext: str) -> bytes | None:
|
||||
img_bytes = self.get_img_bytes(
|
||||
ext, timestamp=False, bounding_box=False, crop=True, height=175
|
||||
)
|
||||
@@ -404,7 +410,7 @@ class TrackedObject:
|
||||
_, img = cv2.imencode(f".{ext}", np.zeros((175, 175, 3), np.uint8))
|
||||
return img.tobytes()
|
||||
|
||||
def get_clean_png(self):
|
||||
def get_clean_png(self) -> bytes | None:
|
||||
if self.thumbnail_data is None:
|
||||
return None
|
||||
|
||||
@@ -433,7 +439,7 @@ class TrackedObject:
|
||||
crop=False,
|
||||
height: int | None = None,
|
||||
quality: int | None = None,
|
||||
):
|
||||
) -> bytes | None:
|
||||
if self.thumbnail_data is None:
|
||||
return None
|
||||
|
||||
@@ -474,6 +480,7 @@ class TrackedObject:
|
||||
# draw any attributes
|
||||
for attribute in self.thumbnail_data["attributes"]:
|
||||
box = attribute["box"]
|
||||
box_area = int((box[2] - box[0]) * (box[3] - box[1]))
|
||||
draw_box_with_label(
|
||||
best_frame,
|
||||
box[0],
|
||||
@@ -481,7 +488,7 @@ class TrackedObject:
|
||||
box[2],
|
||||
box[3],
|
||||
attribute["label"],
|
||||
f"{attribute['score']:.0%}",
|
||||
f"{attribute['score']:.0%} {str(box_area)}",
|
||||
thickness=thickness,
|
||||
color=color,
|
||||
)
|
||||
@@ -621,7 +628,7 @@ class TrackedObjectAttribute:
|
||||
self.ratio = raw_data[4]
|
||||
self.region = raw_data[5]
|
||||
|
||||
def get_tracking_data(self) -> dict[str, any]:
|
||||
def get_tracking_data(self) -> dict[str, Any]:
|
||||
"""Return data saved to the object."""
|
||||
return {
|
||||
"label": self.label,
|
||||
@@ -629,7 +636,7 @@ class TrackedObjectAttribute:
|
||||
"box": self.box,
|
||||
}
|
||||
|
||||
def find_best_object(self, objects: list[dict[str, any]]) -> Optional[str]:
|
||||
def find_best_object(self, objects: list[dict[str, Any]]) -> Optional[str]:
|
||||
"""Find the best attribute for each object and return its ID."""
|
||||
best_object_area = None
|
||||
best_object_id = None
|
||||
@@ -649,8 +656,9 @@ class TrackedObjectAttribute:
|
||||
best_object_id = obj["id"]
|
||||
best_object_label = obj["label"]
|
||||
else:
|
||||
if best_object_label == "car" and obj["label"] == "car":
|
||||
# if multiple cars are overlapping with the same label then the label will not be assigned
|
||||
if best_object_label == obj["label"]:
|
||||
# if multiple objects of the same type are overlapping
|
||||
# then the attribute will not be assigned
|
||||
return None
|
||||
elif object_area < best_object_area:
|
||||
# if a car and person are overlapping then assign the label to the smaller object (which should be the person)
|
||||
|
||||
+36
-7
@@ -4,6 +4,7 @@ import ast
|
||||
import copy
|
||||
import datetime
|
||||
import logging
|
||||
import math
|
||||
import multiprocessing as mp
|
||||
import queue
|
||||
import re
|
||||
@@ -11,6 +12,7 @@ import shlex
|
||||
import struct
|
||||
import urllib.parse
|
||||
from collections.abc import Mapping
|
||||
from multiprocessing.sharedctypes import Synchronized
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Tuple, Union
|
||||
from zoneinfo import ZoneInfoNotFoundError
|
||||
@@ -26,16 +28,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventsPerSecond:
|
||||
def __init__(self, max_events=1000, last_n_seconds=10):
|
||||
def __init__(self, max_events=1000, last_n_seconds=10) -> None:
|
||||
self._start = None
|
||||
self._max_events = max_events
|
||||
self._last_n_seconds = last_n_seconds
|
||||
self._timestamps = []
|
||||
|
||||
def start(self):
|
||||
def start(self) -> None:
|
||||
self._start = datetime.datetime.now().timestamp()
|
||||
|
||||
def update(self):
|
||||
def update(self) -> None:
|
||||
now = datetime.datetime.now().timestamp()
|
||||
if self._start is None:
|
||||
self._start = now
|
||||
@@ -45,7 +47,7 @@ class EventsPerSecond:
|
||||
self._timestamps = self._timestamps[(1 - self._max_events) :]
|
||||
self.expire_timestamps(now)
|
||||
|
||||
def eps(self):
|
||||
def eps(self) -> float:
|
||||
now = datetime.datetime.now().timestamp()
|
||||
if self._start is None:
|
||||
self._start = now
|
||||
@@ -58,12 +60,29 @@ class EventsPerSecond:
|
||||
return len(self._timestamps) / seconds
|
||||
|
||||
# remove aged out timestamps
|
||||
def expire_timestamps(self, now):
|
||||
def expire_timestamps(self, now: float) -> None:
|
||||
threshold = now - self._last_n_seconds
|
||||
while self._timestamps and self._timestamps[0] < threshold:
|
||||
del self._timestamps[0]
|
||||
|
||||
|
||||
class InferenceSpeed:
|
||||
def __init__(self, metric: Synchronized) -> None:
|
||||
self.__metric = metric
|
||||
self.__initialized = False
|
||||
|
||||
def update(self, inference_time: float) -> None:
|
||||
if not self.__initialized:
|
||||
self.__metric.value = inference_time
|
||||
self.__initialized = True
|
||||
return
|
||||
|
||||
self.__metric.value = (self.__metric.value * 9 + inference_time) / 10
|
||||
|
||||
def current(self) -> float:
|
||||
return self.__metric.value
|
||||
|
||||
|
||||
def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dict:
|
||||
"""
|
||||
:param dct1: First dict to merge
|
||||
@@ -138,7 +157,7 @@ def load_labels(path: Optional[str], encoding="utf-8", prefill=91):
|
||||
return labels
|
||||
|
||||
|
||||
def get_tz_modifiers(tz_name: str) -> Tuple[str, str, int]:
|
||||
def get_tz_modifiers(tz_name: str) -> Tuple[str, str, float]:
|
||||
seconds_offset = (
|
||||
datetime.datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds()
|
||||
)
|
||||
@@ -151,7 +170,7 @@ def get_tz_modifiers(tz_name: str) -> Tuple[str, str, int]:
|
||||
|
||||
def to_relative_box(
|
||||
width: int, height: int, box: Tuple[int, int, int, int]
|
||||
) -> Tuple[int, int, int, int]:
|
||||
) -> Tuple[int | float, int | float, int | float, int | float]:
|
||||
return (
|
||||
box[0] / width, # x
|
||||
box[1] / height, # y
|
||||
@@ -169,6 +188,9 @@ def update_yaml_from_url(file_path, url):
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
query_string = urllib.parse.parse_qs(parsed_url.query, keep_blank_values=True)
|
||||
|
||||
# Filter out empty keys but keep blank values for non-empty keys
|
||||
query_string = {k: v for k, v in query_string.items() if k}
|
||||
|
||||
for key_path_str, new_value_list in query_string.items():
|
||||
key_path = key_path_str.split(".")
|
||||
for i in range(len(key_path)):
|
||||
@@ -378,3 +400,10 @@ def serialize(
|
||||
def deserialize(bytes_data: bytes) -> list[float]:
|
||||
"""Deserializes a compact "raw bytes" format into a list of floats"""
|
||||
return list(struct.unpack("%sf" % (len(bytes_data) // 4), bytes_data))
|
||||
|
||||
|
||||
def sanitize_float(value):
|
||||
"""Replace NaN or inf with 0.0."""
|
||||
if isinstance(value, (int, float)) and not math.isfinite(value):
|
||||
return 0.0
|
||||
return value
|
||||
|
||||
+10
-10
@@ -4,7 +4,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Optional, Union
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
@@ -37,7 +37,7 @@ def migrate_frigate_config(config_file: str):
|
||||
yaml = YAML()
|
||||
yaml.indent(mapping=2, sequence=4, offset=2)
|
||||
with open(config_file, "r") as f:
|
||||
config: dict[str, dict[str, any]] = yaml.load(f)
|
||||
config: dict[str, dict[str, Any]] = yaml.load(f)
|
||||
|
||||
if config is None:
|
||||
logger.error(f"Failed to load config at {config_file}")
|
||||
@@ -94,7 +94,7 @@ def migrate_frigate_config(config_file: str):
|
||||
logger.info("Finished frigate config migration...")
|
||||
|
||||
|
||||
def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
||||
def migrate_014(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""Handle migrating frigate config to 0.14"""
|
||||
# migrate record.events.required_zones to review.alerts.required_zones
|
||||
new_config = config.copy()
|
||||
@@ -142,7 +142,7 @@ def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
||||
del new_config["rtmp"]
|
||||
|
||||
for name, camera in config.get("cameras", {}).items():
|
||||
camera_config: dict[str, dict[str, any]] = camera.copy()
|
||||
camera_config: dict[str, dict[str, Any]] = camera.copy()
|
||||
required_zones = (
|
||||
camera_config.get("record", {}).get("events", {}).get("required_zones", [])
|
||||
)
|
||||
@@ -181,7 +181,7 @@ def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
||||
return new_config
|
||||
|
||||
|
||||
def migrate_015_0(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
||||
def migrate_015_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""Handle migrating frigate config to 0.15-0"""
|
||||
new_config = config.copy()
|
||||
|
||||
@@ -232,9 +232,9 @@ def migrate_015_0(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]
|
||||
del new_config["record"]["events"]
|
||||
|
||||
for name, camera in config.get("cameras", {}).items():
|
||||
camera_config: dict[str, dict[str, any]] = camera.copy()
|
||||
camera_config: dict[str, dict[str, Any]] = camera.copy()
|
||||
|
||||
record_events: dict[str, any] = camera_config.get("record", {}).get("events")
|
||||
record_events: dict[str, Any] = camera_config.get("record", {}).get("events")
|
||||
|
||||
if record_events:
|
||||
alerts_retention = {"retain": {}}
|
||||
@@ -281,7 +281,7 @@ def migrate_015_0(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]
|
||||
return new_config
|
||||
|
||||
|
||||
def migrate_015_1(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
||||
def migrate_015_1(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""Handle migrating frigate config to 0.15-1"""
|
||||
new_config = config.copy()
|
||||
|
||||
@@ -296,7 +296,7 @@ def migrate_015_1(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]
|
||||
return new_config
|
||||
|
||||
|
||||
def migrate_016_0(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
||||
def migrate_016_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""Handle migrating frigate config to 0.16-0"""
|
||||
new_config = config.copy()
|
||||
|
||||
@@ -307,7 +307,7 @@ def migrate_016_0(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]
|
||||
new_config["detect"] = detect_config
|
||||
|
||||
for name, camera in config.get("cameras", {}).items():
|
||||
camera_config: dict[str, dict[str, any]] = camera.copy()
|
||||
camera_config: dict[str, dict[str, Any]] = camera.copy()
|
||||
|
||||
live_config = camera_config.get("live", {})
|
||||
if "stream_name" in live_config:
|
||||
|
||||
@@ -8,7 +8,7 @@ from abc import ABC, abstractmethod
|
||||
from multiprocessing import resource_tracker as _mprt
|
||||
from multiprocessing import shared_memory as _mpshm
|
||||
from string import printable
|
||||
from typing import AnyStr, Optional
|
||||
from typing import Any, AnyStr, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -766,7 +766,7 @@ class FrameManager(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def write(self, name: str) -> memoryview:
|
||||
def write(self, name: str) -> Optional[memoryview]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -847,7 +847,7 @@ class SharedMemoryFrameManager(FrameManager):
|
||||
self.shm_store[name] = shm
|
||||
return shm.buf
|
||||
|
||||
def write(self, name: str) -> memoryview:
|
||||
def write(self, name: str) -> Optional[memoryview]:
|
||||
try:
|
||||
if name in self.shm_store:
|
||||
shm = self.shm_store[name]
|
||||
@@ -944,7 +944,7 @@ def get_image_from_recording(
|
||||
relative_frame_time: float,
|
||||
codec: str,
|
||||
height: Optional[int] = None,
|
||||
) -> Optional[any]:
|
||||
) -> Optional[Any]:
|
||||
"""retrieve a frame from given time in recording file."""
|
||||
|
||||
ffmpeg_cmd = [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -284,7 +285,7 @@ def post_process_yolox(
|
||||
|
||||
def get_ort_providers(
|
||||
force_cpu: bool = False, device: str = "AUTO", requires_fp16: bool = False
|
||||
) -> tuple[list[str], list[dict[str, any]]]:
|
||||
) -> tuple[list[str], list[dict[str, Any]]]:
|
||||
if force_cpu:
|
||||
return (
|
||||
["CPUExecutionProvider"],
|
||||
@@ -340,7 +341,6 @@ def get_ort_providers(
|
||||
providers.append(provider)
|
||||
options.append(
|
||||
{
|
||||
"arena_extend_strategy": "kSameAsRequested",
|
||||
"cache_dir": os.path.join(MODEL_CACHE_DIR, "openvino/ort"),
|
||||
"device_type": device,
|
||||
}
|
||||
|
||||
+11
-10
@@ -4,6 +4,7 @@ import datetime
|
||||
import logging
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -38,7 +39,7 @@ def get_camera_regions_grid(
|
||||
name: str,
|
||||
detect: DetectConfig,
|
||||
min_region_size: int,
|
||||
) -> list[list[dict[str, any]]]:
|
||||
) -> list[list[dict[str, Any]]]:
|
||||
"""Build a grid of expected region sizes for a camera."""
|
||||
# get grid from db if available
|
||||
try:
|
||||
@@ -163,10 +164,10 @@ def get_cluster_region_from_grid(frame_shape, min_region, cluster, boxes, region
|
||||
|
||||
|
||||
def get_region_from_grid(
|
||||
frame_shape: tuple[int],
|
||||
frame_shape: tuple[int, int],
|
||||
cluster: list[int],
|
||||
min_region: int,
|
||||
region_grid: list[list[dict[str, any]]],
|
||||
region_grid: list[list[dict[str, Any]]],
|
||||
) -> list[int]:
|
||||
"""Get a region for a box based on the region grid."""
|
||||
box = calculate_region(
|
||||
@@ -446,9 +447,9 @@ def get_cluster_region(frame_shape, min_region, cluster, boxes):
|
||||
|
||||
|
||||
def get_startup_regions(
|
||||
frame_shape: tuple[int],
|
||||
frame_shape: tuple[int, int],
|
||||
region_min_size: int,
|
||||
region_grid: list[list[dict[str, any]]],
|
||||
region_grid: list[list[dict[str, Any]]],
|
||||
) -> list[list[int]]:
|
||||
"""Get a list of regions to run on startup."""
|
||||
# return 8 most popular regions for the camera
|
||||
@@ -480,12 +481,12 @@ def get_startup_regions(
|
||||
|
||||
|
||||
def reduce_detections(
|
||||
frame_shape: tuple[int],
|
||||
all_detections: list[tuple[any]],
|
||||
) -> list[tuple[any]]:
|
||||
frame_shape: tuple[int, int],
|
||||
all_detections: list[tuple[Any]],
|
||||
) -> list[tuple[Any]]:
|
||||
"""Take a list of detections and reduce overlaps to create a list of confident detections."""
|
||||
|
||||
def reduce_overlapping_detections(detections: list[tuple[any]]) -> list[tuple[any]]:
|
||||
def reduce_overlapping_detections(detections: list[tuple[Any]]) -> list[tuple[Any]]:
|
||||
"""apply non-maxima suppression to suppress weak, overlapping bounding boxes."""
|
||||
detected_object_groups = defaultdict(lambda: [])
|
||||
for detection in detections:
|
||||
@@ -524,7 +525,7 @@ def reduce_detections(
|
||||
# set the detections list to only include top objects
|
||||
return selected_objects
|
||||
|
||||
def get_consolidated_object_detections(detections: list[tuple[any]]):
|
||||
def get_consolidated_object_detections(detections: list[tuple[Any]]):
|
||||
"""Drop detections that overlap too much."""
|
||||
detected_object_groups = defaultdict(lambda: [])
|
||||
for detection in detections:
|
||||
|
||||
+17
-11
@@ -9,7 +9,7 @@ import signal
|
||||
import subprocess as sp
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import psutil
|
||||
@@ -230,7 +230,7 @@ def is_vaapi_amd_driver() -> bool:
|
||||
return any("AMD Radeon Graphics" in line for line in output)
|
||||
|
||||
|
||||
def get_amd_gpu_stats() -> dict[str, str]:
|
||||
def get_amd_gpu_stats() -> Optional[dict[str, str]]:
|
||||
"""Get stats using radeontop."""
|
||||
radeontop_command = ["radeontop", "-d", "-", "-l", "1"]
|
||||
|
||||
@@ -256,7 +256,7 @@ def get_amd_gpu_stats() -> dict[str, str]:
|
||||
return results
|
||||
|
||||
|
||||
def get_intel_gpu_stats(sriov: bool) -> dict[str, str]:
|
||||
def get_intel_gpu_stats(sriov: bool) -> Optional[dict[str, str]]:
|
||||
"""Get stats using intel_gpu_top."""
|
||||
|
||||
def get_stats_manually(output: str) -> dict[str, str]:
|
||||
@@ -304,7 +304,7 @@ def get_intel_gpu_stats(sriov: bool) -> dict[str, str]:
|
||||
]
|
||||
|
||||
if sriov:
|
||||
intel_gpu_top_command += ["-d", "drm:/dev/dri/card0"]
|
||||
intel_gpu_top_command += ["-d", "sriov"]
|
||||
|
||||
p = sp.run(
|
||||
intel_gpu_top_command,
|
||||
@@ -382,7 +382,7 @@ def get_intel_gpu_stats(sriov: bool) -> dict[str, str]:
|
||||
return results
|
||||
|
||||
|
||||
def get_rockchip_gpu_stats() -> dict[str, str]:
|
||||
def get_rockchip_gpu_stats() -> Optional[dict[str, str]]:
|
||||
"""Get GPU stats using rk."""
|
||||
try:
|
||||
with open("/sys/kernel/debug/rkrga/load", "r") as f:
|
||||
@@ -403,12 +403,18 @@ def get_rockchip_gpu_stats() -> dict[str, str]:
|
||||
return {"gpu": average_load, "mem": "-"}
|
||||
|
||||
|
||||
def get_rockchip_npu_stats() -> dict[str, str]:
|
||||
def get_rockchip_npu_stats() -> Optional[dict[str, float | str]]:
|
||||
"""Get NPU stats using rk."""
|
||||
try:
|
||||
with open("/sys/kernel/debug/rknpu/load", "r") as f:
|
||||
npu_output = f.read()
|
||||
core_loads = re.findall(r"Core\d+:\s*(\d+)%", npu_output)
|
||||
|
||||
if "Core0:" in npu_output:
|
||||
# multi core NPU
|
||||
core_loads = re.findall(r"Core\d+:\s*(\d+)%", npu_output)
|
||||
else:
|
||||
# single core NPU
|
||||
core_loads = re.findall(r"NPU load:\s+(\d+)%", npu_output)
|
||||
except FileNotFoundError:
|
||||
core_loads = None
|
||||
|
||||
@@ -488,7 +494,7 @@ def get_nvidia_gpu_stats() -> dict[int, dict]:
|
||||
return results
|
||||
|
||||
|
||||
def get_jetson_stats() -> dict[int, dict]:
|
||||
def get_jetson_stats() -> Optional[dict[int, dict]]:
|
||||
results = {}
|
||||
|
||||
try:
|
||||
@@ -531,7 +537,7 @@ def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess:
|
||||
return sp.run(ffprobe_cmd, capture_output=True)
|
||||
|
||||
|
||||
def get_nvidia_driver_info() -> dict[str, any]:
|
||||
def get_nvidia_driver_info() -> dict[str, Any]:
|
||||
"""Get general hardware info for nvidia GPU."""
|
||||
results = {}
|
||||
try:
|
||||
@@ -590,8 +596,8 @@ def auto_detect_hwaccel() -> str:
|
||||
|
||||
async def get_video_properties(
|
||||
ffmpeg, url: str, get_duration: bool = False
|
||||
) -> dict[str, any]:
|
||||
async def calculate_duration(video: Optional[any]) -> float:
|
||||
) -> dict[str, Any]:
|
||||
async def calculate_duration(video: Optional[Any]) -> float:
|
||||
duration = None
|
||||
|
||||
if video is not None:
|
||||
|
||||
+5
-3
@@ -184,7 +184,7 @@ class CameraWatchdog(threading.Thread):
|
||||
self.capture_thread = None
|
||||
self.ffmpeg_detect_process = None
|
||||
self.logpipe = LogPipe(f"ffmpeg.{self.camera_name}.detect")
|
||||
self.ffmpeg_other_processes: list[dict[str, any]] = []
|
||||
self.ffmpeg_other_processes: list[dict[str, Any]] = []
|
||||
self.camera_fps = camera_fps
|
||||
self.skipped_fps = skipped_fps
|
||||
self.ffmpeg_pid = ffmpeg_pid
|
||||
@@ -371,7 +371,9 @@ class CameraWatchdog(threading.Thread):
|
||||
p["logpipe"].close()
|
||||
self.ffmpeg_other_processes.clear()
|
||||
|
||||
def get_latest_segment_datetime(self, latest_segment: datetime.datetime) -> int:
|
||||
def get_latest_segment_datetime(
|
||||
self, latest_segment: datetime.datetime
|
||||
) -> datetime.datetime:
|
||||
"""Checks if ffmpeg is still writing recording segments to cache."""
|
||||
cache_files = sorted(
|
||||
[
|
||||
@@ -859,7 +861,7 @@ def process_frames(
|
||||
detections[obj["id"]] = {**obj, "attributes": []}
|
||||
|
||||
# find the best object for each attribute to be assigned to
|
||||
all_objects: list[dict[str, any]] = object_tracker.tracked_objects.values()
|
||||
all_objects: list[dict[str, Any]] = object_tracker.tracked_objects.values()
|
||||
for attributes in attribute_detections.values():
|
||||
for attribute in attributes:
|
||||
filtered_objects = filter(
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Notebooks
|
||||
|
||||
## YOLO-NAS Pretrained
|
||||
|
||||
You can build and download a compatible model with pre-trained weights using [Google Colab](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb).
|
||||
|
||||
> [!WARNING]
|
||||
> The pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html
|
||||
|
||||
The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired. By default, YOLO_NAS_S is built with YOLO_NAS_M and YOLO_NAS_L sizes also being available for export.
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
[tool.ruff]
|
||||
[tool.ruff.lint]
|
||||
ignore = ["E501","E711","E712"]
|
||||
extend-select = ["I"]
|
||||
extend-select = ["I"]
|
||||
|
||||
Generated
+201
-11
@@ -25,7 +25,7 @@
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
@@ -54,7 +54,7 @@
|
||||
"nosleep.js": "^0.12.0",
|
||||
"react": "^18.3.1",
|
||||
"react-apexcharts": "^1.4.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-day-picker": "^9.7.0",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
@@ -268,6 +268,12 @@
|
||||
"integrity": "sha512-U9DBDe5fxHmbwQww9rFxMLNI2Wlg7DhPzI7AVFpq8GehiUP7+NwuMPXpP4zAd52sgkxtOqOeMjgE5g0ZLnQZ0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz",
|
||||
"integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
|
||||
@@ -1244,6 +1250,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz",
|
||||
@@ -1346,6 +1370,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
||||
@@ -1440,6 +1482,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
|
||||
@@ -1663,6 +1723,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz",
|
||||
@@ -1700,6 +1778,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz",
|
||||
@@ -1803,6 +1899,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.3.tgz",
|
||||
@@ -1940,6 +2054,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz",
|
||||
@@ -1997,12 +2129,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz",
|
||||
"integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2014,6 +2146,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz",
|
||||
@@ -2161,6 +2308,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
|
||||
@@ -4395,11 +4560,18 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns-jalali": {
|
||||
"version": "4.1.0-0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
|
||||
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns-tz": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
|
||||
@@ -7196,16 +7368,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "8.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
||||
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
|
||||
"version": "9.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.7.0.tgz",
|
||||
"integrity": "sha512-urlK4C9XJZVpQ81tmVgd2O7lZ0VQldZeHzNejbwLWZSkzHH498KnArT0EHNfKBOWwKc935iMLGZdxXPRISzUxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "1.2.0",
|
||||
"date-fns": "4.1.0",
|
||||
"date-fns-jalali": "4.1.0-0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/gpbl"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"date-fns": "^2.28.0 || ^3.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-day-picker/node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/react-device-detect": {
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
@@ -60,7 +60,7 @@
|
||||
"nosleep.js": "^0.12.0",
|
||||
"react": "^18.3.1",
|
||||
"react-apexcharts": "^1.4.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-day-picker": "^9.7.0",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
|
||||
@@ -3,25 +3,25 @@
|
||||
"snort": "نَفْخَة",
|
||||
"heartbeat": "نَبْض القَلْب",
|
||||
"pets": "حَيَوَانَات أَلِيفَة",
|
||||
"whoop": "هُتَاف",
|
||||
"whoop": "هتاف",
|
||||
"humming": "هَمْهَمَة",
|
||||
"chewing": "مَضْغ",
|
||||
"yodeling": "تَزَلْغُط",
|
||||
"yodeling": "غناء متقلب",
|
||||
"howl": "عُوَاء",
|
||||
"speech": "كَلَام",
|
||||
"speech": "كلام",
|
||||
"hiccup": "فُوَاق",
|
||||
"dog": "كَلْب",
|
||||
"yip": "نُبَيْحَة",
|
||||
"babbling": "ثَرْثَرَة",
|
||||
"yell": "صُرَاخ",
|
||||
"bellow": "خُوَار",
|
||||
"whispering": "هَمْس",
|
||||
"laughter": "ضَحِك",
|
||||
"snicker": "تَضَاحُك",
|
||||
"crying": "بُكَاء",
|
||||
"sigh": "تَنَهُّد",
|
||||
"singing": "غِنَاء",
|
||||
"choir": "جَوْقَة",
|
||||
"babbling": "ثرثرة",
|
||||
"yell": "صراخ",
|
||||
"bellow": "زمجرة",
|
||||
"whispering": "همس",
|
||||
"laughter": "ضحك",
|
||||
"snicker": "ضحكة خفيفه",
|
||||
"crying": "بكاء",
|
||||
"sigh": "تنهد",
|
||||
"singing": "غناء",
|
||||
"choir": "فرقة غناء",
|
||||
"chant": "تَرْنِيم",
|
||||
"mantra": "تَرْنِيمَة",
|
||||
"child_singing": "غِنَاء طِفْل",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user