Compare commits

...

79 Commits

Author SHA1 Message Date
Blake Blackshear
91ac4c4cee define detect resolution to speed up tests 2023-10-14 06:47:23 -05:00
Blake Blackshear
b85c488d7e don't zero out motion boxes 2023-10-14 06:47:06 -05:00
Nicolas Mowen
fa6c6c50d0 Support ManagedMediaSource and update docs to reflect iOS 17.1+ supports MSE (#8160)
* Refactor media source handling in MsePlayer.js and Birdseye.jsx to support ManagedMediaSource

* lint

* Update docs to reflect iOS supporting mse

---------

Co-authored-by: Sergey Krashevich <svk@svk.su>
2023-10-13 19:17:09 -05:00
Nicolas Mowen
4c7ea01137 Don't print ffprobe stdout logs (#8153) 2023-10-13 17:04:38 -05:00
Nicolas Mowen
34b315cc8c Fix bug forcing rtsp for audio detection ffmpeg process (#8156)
* Fix forcing rtsp for audio process

* Send as list
2023-10-13 17:03:04 -05:00
Nicolas Mowen
9b687d77ce Add support for nvidia decoder and encoder utilization stats (#8150)
* Add encoder and decoder stats to nvidia hwaccel stats

* Fix

* Fix
2023-10-13 09:44:18 -05:00
tpjanssen
e32bd4ab15 Added audio sensors to camera metrics and API stats (#8109)
* Added audio sensor to camera metrics and API stats

* Update types.py

* Update app.py
2023-10-13 06:17:41 -05:00
Nicolas Mowen
e19c0668e7 Require init delay (#8126) 2023-10-13 06:16:36 -05:00
Nicolas Mowen
869bb2b177 clarifications and fixes for live go2rtc example (#8132)
* clarifications and fixes for live go2rtc example

* fix
2023-10-13 06:15:39 -05:00
Nicolas Mowen
3869b274e2 Add note about recording retention to manual event docs (#8141) 2023-10-13 06:14:55 -05:00
Nicolas Mowen
2379e6fd1b Support TiB in storage calculation (#8142) 2023-10-13 06:14:24 -05:00
Nicolas Mowen
dcafcc1320 Fix Config success message (#8121)
* Fix saved message

* Fix save mask
2023-10-10 08:23:18 -05:00
Nicolas Mowen
d508088bd0 Add audio role to camera config docs (#8113) 2023-10-09 18:16:34 -05:00
Felipe Santos
97e5a98b95 Fix jetson docker build with old apt key (#8112)
* Fix jetson docker build with old apt key

* Fix CI
2023-10-09 18:16:05 -05:00
Nicolas Mowen
68ebd55425 Lower min scores for person and car in plus docs (#8114) 2023-10-09 18:15:23 -05:00
tpjanssen
a82334ca1c API enhancements (#8107) 2023-10-09 08:52:26 -05:00
Nicolas Mowen
d7ddcea951 Show settings cog for camera toggles on mobile (#8098)
* Show settings cog on mobile

* Cleanup ui and remove label

* Fix tests
2023-10-08 14:49:41 -05:00
Daniel
cc6e049966 Change multiselect camera icon (#8016)
* CenterFocusString icon

* Add CenterFocusString to multiselect

* Rename CenterFocusString.jsx

* Rename icon and make it smaller

* Rename icon

* Fix lint and use icon
for speech

* remove unused vars

* Remove unused import
2023-10-08 14:30:53 -05:00
Josh Hawkins
dbd21eb6fa use getattr instead of get (#8094) 2023-10-08 14:30:23 -05:00
Nicolas Mowen
e1a6398219 Fix trt build (#8091) 2023-10-07 09:51:02 -05:00
Nicolas Mowen
7f5fba08b7 Fix export name (#8090) 2023-10-07 08:40:20 -06:00
Tyler Fisher
c35e8371be corrected a typo in the "Configuring Minimum Volume" section. (#8012) 2023-10-07 09:23:49 -05:00
Nicolas Mowen
49e7723405 Add debug logs for audio labels (#8080) 2023-10-07 09:23:11 -05:00
Nicolas Mowen
52cc707eb8 Consider new attribute a significant change (#8054)
* Consider new attribute a significant change

* Update object_processing.py
2023-10-07 09:22:45 -05:00
tpjanssen
c47b02d2fe Added filter option for min/max score for event to API function /events (#8079)
* Update api.md

* Update api.md

* Added filter option for min/max score for event to API function /events

* Added filter for submitted events

* Update http.py
2023-10-07 09:22:14 -05:00
Felipe Santos
3a460133d4 Add docker healthcheck (#8006)
* Add docker healthcheck

* Fix unknown start-interval

* Add HEALTHCHECK for tensorrt and bump default start-period

* Move healthcheck to deps stage
2023-10-07 09:21:03 -05:00
Josh Hawkins
67a5a7d21a onvif: use preset token if name is unsupported (#8046)
* onvif: use preset token if name is unsupported

* move parenthesis
2023-10-07 09:20:42 -05:00
theawes0megamer
5d2b87e077 Update ESXi instructions (#8018) 2023-10-07 09:20:29 -05:00
Josh Hawkins
8298806028 update list of onvif capable cams (#8021)
* update list of onvif capable cams

* clarify hikvision

* wording

* hikvision

* add sunba and ctronics from forum reports

* add cams
2023-10-07 09:20:10 -05:00
Nicolas Mowen
8cc7acd591 Zone docs (#8047)
* Update zone docs

* Document zone inertia in zone docs
2023-10-07 09:19:56 -05:00
Nicolas Mowen
8bde914939 Fix a couple bugs (#8045) 2023-10-07 09:18:34 -05:00
Josh Hawkins
fe9fcf3eaa remove erroneous check for move threads (#8031) 2023-10-07 09:18:07 -05:00
Josh Hawkins
20c2ab39bc disable zooming if relative zoom not supported (#8028) 2023-10-07 09:17:54 -05:00
Nicolas Mowen
08ef69bac4 Add recordings timeline entry for frigate+ attributes (#8063)
* Add attribute item to timeline

* Add face icon

* Add support for other icons

* Cleanup

* Ensure attributes are only updated once

* don't show _ in attributes
2023-10-07 09:17:18 -05:00
Nicolas Mowen
79fabbb6b0 Fix recording snapshot time range (#8073)
* Fix recording snapshot time range

* Formatting

* Formatting
2023-10-07 09:16:12 -05:00
Nicolas Mowen
8941aa5311 Ensure deleted export file name is safe (#8089)
* Ensure deleted export file name is safe

* Fix import
2023-10-07 09:12:48 -05:00
Blake Blackshear
14d2b79c72 Security fixes (#8081)
* use safeloader

* use json responses wherever possible

* remove CORS and add CSRF token

* formatting fixes

* add envjs back

* fix baseurl test
2023-10-06 22:20:30 -05:00
Nicolas Mowen
9a4f970337 Set default min score for attributes labels to 0.7 (#8001)
* Set min score for attributes to 0.7

* Allow other fields to be set
2023-09-30 07:38:15 -05:00
Josh Hawkins
22b9507797 add image of debug view (#8003) 2023-09-30 06:25:39 -05:00
Nicolas Mowen
37379e6fba Update autotracking gif (#8002) 2023-09-29 20:08:15 -05:00
Nicolas Mowen
232588636f Force birdseye to standard aspect ratio (#7994)
* Force birdseye to standard aspect ratio

* Make rounding consistent

* Formatting
2023-09-29 17:53:45 -05:00
Josh Hawkins
e77fedc445 docs for onvif camera support (#7999)
* docs for onvif camera support

* fix warning

* warning to caution

* update table

* centering

* no autotracking for reolinks

* zoom only for 511WA
2023-09-29 17:52:57 -05:00
Josh Hawkins
ead03c381b Autotracking improvements and bugfixes (#7984)
* add zoom factor and catch motion exception

* reword error message

* check euclidean distance of estimate points

* use numpy for euclidean distance

* config entry

* use zoom factor and zoom based on velocity

* move debug inside try

* change log type to info

* logger level warning

* docs

* exception handling
2023-09-28 18:21:37 -05:00
Nicolas Mowen
0048cd5edc Pull radeon driver from bookworm (#7983) 2023-09-28 18:20:48 -05:00
On Freund
56dfcd7a32 Update CAP_PERFMON instructions on hardware_acceleration.md (#7957)
* Update CAP_PERFMON instructions on hardware_acceleration.md

* Three -> there
2023-09-28 18:20:09 -05:00
Nicolas Mowen
9f3ac19e05 Limit max player height (#7974) 2023-09-28 18:01:23 -05:00
Josh Hawkins
50f13b7196 thread lock for move queues (#7973) 2023-09-28 18:01:05 -05:00
tpjanssen
50b17031c4 Update api.md (#7971)
* Update api.md

* Update api.md
2023-09-28 18:00:32 -05:00
mvn23
d11c1a2066 Update camera_specific.md (#7694)
Add information for TP-Link VIGI stream settings
2023-09-27 06:19:29 -05:00
Josh Hawkins
27144eb0b9 Autotracker: Basic zooming and moves with velocity estimation (#7713)
* don't zoom if camera doesn't support it

* basic zooming

* make zooming configurable

* zooming docs

* optional zooming in camera status

* Use absolute instead of relative zooming

* increase edge threshold

* zoom considering object area

* bugfixes

* catch onvif zooming errors

* relative zooming option for dahua/amcrest cams

* docs

* docs

* don't make small movements

* remove old logger statement

* fix small movements

* use enum in config for zooming

* fix formatting

* empty move queue first

* clear tracked object before waiting for stop

* use velocity estimation for movements

* docs updates

* add tests

* typos

* recalc every 50 moves

* adjust zoom based on estimate box if calibrated

* tweaks for fast objects and large movements

* use real time for calibration and add info logging

* docs updates

* remove area scale

* Add example video to docs

* zooming font header size the same as the others

* log an error if a ptz doesn't report a MoveStatus

* debug logging for onvif service capabilities

* ensure camera supports ONVIF MoveStatus
2023-09-27 06:19:10 -05:00
Josh Hawkins
64705c065f update docs sidebar for go2rc 1.7.1 (#7946) 2023-09-27 06:11:37 -05:00
Nicolas Mowen
08eefd8385 Fix frame height default value in docs (#7947) 2023-09-27 06:11:23 -05:00
Blake Blackshear
705ee54315 plus docs update (#7964)
* plus docs update

* add attribute labels
2023-09-27 06:10:53 -05:00
Nicolas Mowen
e26bb94007 Add seconds to exports (#7955) 2023-09-27 06:10:37 -05:00
Nicolas Mowen
1aba8c1ef5 Refactor time filter (#7962)
* Add ability to filter events by start time

* Add tests

* Add time param to events

* Add time picker

* Update docs

* Catch overnight case

Update comment

* Cleanup

* Fix tests
2023-09-27 06:09:38 -05:00
Nicolas Mowen
f92237c9c1 Fix recording timeline info text in light mode (#7963) 2023-09-27 06:08:58 -05:00
Blake Blackshear
0858859939 Dep updates (#7933)
* update actions

* web deps
2023-09-24 07:00:41 -05:00
Nicolas Mowen
1c27ee2d2b Increase initial hit count for norfair tracker (#7925)
* Increase initial hit count of tracked object

* Formatting
2023-09-24 06:32:43 -05:00
Nicolas Mowen
08586f8f65 Fix case where camera is disabled but autotrack is enabled (#7914) 2023-09-24 05:05:29 -05:00
Blake Blackshear
cfd04d164e add more robust plus documentation (#7905)
* add more robust plus documentation

* rename page

* note changing circumstances
2023-09-22 07:35:04 -05:00
Nicolas Mowen
fa2e4993d9 fix typo (#7903) 2023-09-21 17:08:31 -05:00
Nicolas Mowen
080d7a2d88 Update go2rtc to 1.7.1 (#7657)
* Update go2rtc to 1.7.0

* Update docs references

* Add docs for homekit restream

* Exit with better error message when substitution is not correct

* Formatting

* Fix pin

* Update go2rtc dep

* Update go2rtc docs references

* Fix name

* Mute player by default

* Remove homekit mention
2023-09-21 06:52:46 -06:00
Nicolas Mowen
7d0216b8fb Improve default timelapse args and make timelapse customizable (#7840)
* Add args to ignore audio and only process keyframes

* Add timelapse args to config

* Update docs

* Formatting

* Fix spacing

* Fix formatting

* add example of math for pts
2023-09-21 06:20:05 -06:00
Scott Stancil
c743dfd657 Add capability to link directly to an event ID in the web UI (#7803)
* Add capability to link directly to an event ID in the web UI

* Move event ID to searchParams, add View All button

* Use searchParams inside eventsFetcher
2023-09-21 05:27:23 -05:00
Nicolas Mowen
4a6579527b Add docs for audio volume config (#7807)
* Add docs for audio volume

* fix typo
2023-09-21 05:26:44 -05:00
Nicolas Mowen
fd9196ae3e add note about network bandwidth permissions and don't set interfaces by default (#7813)
* add note about network bandwidth permissions

* Update default net int

* Set default network interfaces to empty

* Don't read interfaces if none are set

* Formatting

* Add stderr output
2023-09-21 05:26:22 -05:00
Josh Hawkins
a3eccce8f3 use useEffect for key listeners on camera control panel (#7827)
* use useEffect for key listeners

* dependencies

* useCallbacks
2023-09-21 05:25:57 -05:00
Nicolas Mowen
111933d3b4 Refactor Exports To Better Handle Recording Configs (#7846)
* Refactor export logic

* Fix param

* Ensure float is used

* Fix variable assignment

* Fix range

* Formatting
2023-09-21 05:24:49 -05:00
Nicolas Mowen
76d4f16db3 Add cancel button to delete starred event dialog (#7853) 2023-09-21 05:24:12 -05:00
Nate Meyer
0d6bb6714a Add support for selecting a specific GPU to use when converting TRT models (#7857) 2023-09-21 05:23:51 -05:00
Nicolas Mowen
5d30944d6e Add fire alarm to default audio labels (#7854)
* Add fire alarm to default audio list

* Update docs for default audio label list

* Update audio detectors with default label list
2023-09-21 05:23:26 -05:00
Nicolas Mowen
3797340efa Set export sub process to be lower priority (#7862) 2023-09-21 05:22:35 -05:00
Nicolas Mowen
8728139ae3 Fix birdseye exception handling (#7864) 2023-09-21 05:22:11 -05:00
Nicolas Mowen
730851cda9 Remove frame interval for qsv timelapse output args (#7873) 2023-09-21 05:21:53 -05:00
Nicolas Mowen
46412e99d9 revert 1/2 min region size (#7883) 2023-09-21 05:21:32 -05:00
Nicolas Mowen
e5664826b1 Add ability to play and delete exports from webUI (#7882)
* add ability to playback exports on exports screen

* Add ability to delete exports from exports screen

* Fix large dialog

* Formatting
2023-09-21 05:20:57 -05:00
Nicolas Mowen
9a1c8b2cc4 Remove maximum inertia constraint (#7890) 2023-09-21 05:20:26 -05:00
Nicolas Mowen
6aedc39a9a Remove quotes from tensorrt env variable example (#7823) 2023-09-16 05:00:07 -05:00
Nicolas Mowen
b9e6afa659 Fix webUI success / error messages (#7820)
* Fix export error handling

* Ensure that config editor success / error is updated each time

* Set response

* Formatting
2023-09-16 04:59:50 -05:00
93 changed files with 3673 additions and 1444 deletions

View File

@@ -28,7 +28,7 @@ jobs:
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push amd64 standard build
uses: docker/build-push-action@v4
uses: docker/build-push-action@v5
with:
context: .
file: docker/main/Dockerfile
@@ -38,7 +38,7 @@ jobs:
tags: ${{ steps.setup.outputs.image-name }}-amd64
cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64
- name: Build and push TensorRT (x86 GPU)
uses: docker/bake-action@v3
uses: docker/bake-action@v4
with:
push: true
targets: tensorrt
@@ -59,7 +59,7 @@ jobs:
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push arm64 standard build
uses: docker/build-push-action@v4
uses: docker/build-push-action@v5
with:
context: .
file: docker/main/Dockerfile
@@ -70,7 +70,7 @@ jobs:
${{ steps.setup.outputs.image-name }}-standard-arm64
cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64
- name: Build and push RPi build
uses: docker/bake-action@v3
uses: docker/bake-action@v4
with:
push: true
targets: rpi
@@ -96,7 +96,7 @@ jobs:
BASE_IMAGE: timongentzsch/l4t-ubuntu20-opencv:latest
SLIM_BASE: timongentzsch/l4t-ubuntu20-opencv:latest
TRT_BASE: timongentzsch/l4t-ubuntu20-opencv:latest
uses: docker/bake-action@v3
uses: docker/bake-action@v4
with:
push: true
targets: tensorrt
@@ -122,7 +122,7 @@ jobs:
BASE_IMAGE: nvcr.io/nvidia/l4t-tensorrt:r8.5.2-runtime
SLIM_BASE: nvcr.io/nvidia/l4t-tensorrt:r8.5.2-runtime
TRT_BASE: nvcr.io/nvidia/l4t-tensorrt:r8.5.2-runtime
uses: docker/bake-action@v3
uses: docker/bake-action@v4
with:
push: true
targets: tensorrt
@@ -145,7 +145,7 @@ jobs:
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -97,9 +97,9 @@ jobs:
run: npm run build
working-directory: ./web
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Build
run: make
- name: Run mypy

View File

@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1.4
# syntax=docker/dockerfile:1.6
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
ARG DEBIAN_FRONTEND=noninteractive
@@ -33,7 +33,7 @@ RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/cache/apt \
FROM scratch AS go2rtc
ARG TARGETARCH
WORKDIR /rootfs/usr/local/go2rtc/bin
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.6.2/go2rtc_linux_${TARGETARCH}" go2rtc
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.7.1/go2rtc_linux_${TARGETARCH}" go2rtc
####
@@ -121,13 +121,15 @@ RUN apt-get -qq update \
apt-transport-https \
gnupg \
wget \
&& apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 648ACFD622F3D138 \
&& echo "deb http://deb.debian.org/debian bullseye main contrib non-free" | tee /etc/apt/sources.list.d/raspi.list \
# the key fingerprint can be obtained from https://ftp-master.debian.org/keys.html
&& wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xA4285295FC7B1A81600062A9605C66F00D6C9793" | \
gpg --dearmor > /usr/share/keyrings/debian-archive-bullseye-stable.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/debian-archive-bullseye-stable.gpg] http://deb.debian.org/debian bullseye main contrib non-free" | \
tee /etc/apt/sources.list.d/debian-bullseye-nonfree.list \
&& apt-get -qq update \
&& apt-get -qq install -y \
python3.9 \
python3.9-dev \
wget \
# opencv dependencies
build-essential cmake git pkg-config libgtk-3-dev \
libavcodec-dev libavformat-dev libswscale-dev libv4l-dev \
@@ -199,6 +201,9 @@ ENV S6_LOGGING_SCRIPT="T 1 n0 s10000000 T"
ENTRYPOINT ["/init"]
CMD []
HEALTHCHECK --start-period=120s --start-interval=5s --interval=15s --timeout=5s --retries=3 \
CMD curl --fail --silent --show-error http://127.0.0.1:5000/api/version || exit 1
# Frigate deps with Node.js and NPM for devcontainer
FROM deps AS devcontainer

View File

@@ -55,13 +55,20 @@ fi
# arch specific packages
if [[ "${TARGETARCH}" == "amd64" ]]; then
# Use debian testing repo only for hwaccel packages
# use debian bookworm for AMD hwaccel packages
echo 'deb https://deb.debian.org/debian bookworm main contrib' >/etc/apt/sources.list.d/debian-bookworm.list
apt-get -qq update
apt-get -qq install --no-install-recommends --no-install-suggests -y \
mesa-va-drivers radeontop
rm -f /etc/apt/sources.list.d/debian-bookworm.list
# Use debian testing repo only for intel hwaccel packages
echo 'deb http://deb.debian.org/debian testing main non-free' >/etc/apt/sources.list.d/debian-testing.list
apt-get -qq update
# intel-opencl-icd specifically for GPU support in OpenVino
apt-get -qq install --no-install-recommends --no-install-suggests -y \
intel-opencl-icd \
mesa-va-drivers libva-drm2 intel-media-va-driver-non-free i965-va-driver libmfx1 radeontop intel-gpu-tools
libva-drm2 intel-media-va-driver-non-free i965-va-driver libmfx1 intel-gpu-tools
# something about this dependency requires it to be installed in a separate call rather than in the line above
apt-get -qq install --no-install-recommends --no-install-suggests -y \
i965-va-driver-shaders

View File

@@ -100,12 +100,25 @@ for name in go2rtc_config.get("streams", {}):
stream = go2rtc_config["streams"][name]
if isinstance(stream, str):
go2rtc_config["streams"][name] = go2rtc_config["streams"][name].format(
**FRIGATE_ENV_VARS
)
try:
go2rtc_config["streams"][name] = go2rtc_config["streams"][name].format(
**FRIGATE_ENV_VARS
)
except KeyError as e:
print(
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
)
sys.exit(e)
elif isinstance(stream, list):
for i, stream in enumerate(stream):
go2rtc_config["streams"][name][i] = stream.format(**FRIGATE_ENV_VARS)
try:
go2rtc_config["streams"][name][i] = stream.format(**FRIGATE_ENV_VARS)
except KeyError as e:
print(
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
)
sys.exit(e)
# add birdseye restream stream if enabled
if config.get("birdseye", {}).get("restream", False):

View File

@@ -93,10 +93,6 @@ http {
secure_token $args;
secure_token_types application/vnd.apple.mpegurl;
add_header Access-Control-Allow-Headers '*';
add_header Access-Control-Expose-Headers 'Server,range,Content-Length,Content-Range';
add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS';
add_header Access-Control-Allow-Origin '*';
add_header Cache-Control "no-store";
expires off;
}
@@ -104,16 +100,6 @@ http {
location /stream/ {
add_header Cache-Control "no-store";
expires off;
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Expose-Headers' 'Content-Length';
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
types {
application/dash+xml mpd;
@@ -126,16 +112,6 @@ http {
}
location /clips/ {
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Expose-Headers' 'Content-Length';
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
types {
video/mp4 mp4;
@@ -152,17 +128,6 @@ http {
}
location /recordings/ {
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Expose-Headers' 'Content-Length';
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
types {
video/mp4 mp4;
}
@@ -173,17 +138,6 @@ http {
}
location /exports/ {
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Expose-Headers' 'Content-Length';
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
types {
video/mp4 mp4;
}
@@ -235,8 +189,6 @@ http {
}
location ~* /api/.*\.(jpg|jpeg|png)$ {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
rewrite ^/api/(.*)$ $1 break;
proxy_pass http://frigate_api;
proxy_pass_request_headers on;
@@ -248,10 +200,6 @@ http {
location /api/ {
add_header Cache-Control "no-store";
expires off;
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
proxy_pass http://frigate_api/;
proxy_pass_request_headers on;
proxy_set_header Host $host;

View File

@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1.4
# syntax=docker/dockerfile:1.6
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
ARG DEBIAN_FRONTEND=noninteractive
@@ -24,3 +24,6 @@ COPY --from=trt-deps /usr/local/lib/libyolo_layer.so /usr/local/lib/libyolo_laye
COPY --from=trt-deps /usr/local/src/tensorrt_demos /usr/local/src/tensorrt_demos
COPY docker/tensorrt/detector/rootfs/ /
ENV YOLO_MODELS="yolov7-320"
HEALTHCHECK --start-period=600s --start-interval=5s --interval=15s --timeout=5s --retries=3 \
CMD curl --fail --silent --show-error http://127.0.0.1:5000/api/version || exit 1

View File

@@ -43,6 +43,15 @@ if [[ -z ${MODEL_CONVERT} ]]; then
exit 0
fi
# Setup ENV to select GPU for conversion
if [ ! -z ${TRT_MODEL_PREP_DEVICE+x} ]; then
if [ ! -z ${CUDA_VISIBLE_DEVICES+x} ]; then
PREVIOUS_CVD="$CUDA_VISIBLE_DEVICES"
unset CUDA_VISIBLE_DEVICES
fi
export CUDA_VISIBLE_DEVICES="$TRT_MODEL_PREP_DEVICE"
fi
# On Jetpack 4.6, the nvidia container runtime will mount several host nvidia libraries into the
# container which should not be present in the image - if they are, TRT model generation will
# fail or produce invalid models. Thus we must request the user to install them on the host in
@@ -87,5 +96,14 @@ do
echo "Generated ${model}.trt in $(($(date +%s)-start)) seconds"
done
# Restore ENV after conversion
if [ ! -z ${TRT_MODEL_PREP_DEVICE+x} ]; then
unset CUDA_VISIBLE_DEVICES
if [ ! -z ${PREVIOUS_CVD+x} ]; then
export CUDA_VISIBLE_DEVICES="$PREVIOUS_CVD"
fi
fi
# Print which models exist in output folder
echo "Available tensorrt models:"
cd ${OUTPUT_FOLDER} && ls *.trt;

View File

@@ -120,7 +120,7 @@ NOTE: The folder that is mapped from the host needs to be the folder that contai
## Custom go2rtc version
Frigate currently includes go2rtc v1.6.2, there may be certain cases where you want to run a different version of go2rtc.
Frigate currently includes go2rtc v1.7.1, there may be certain cases where you want to run a different version of go2rtc.
To do this:

View File

@@ -48,15 +48,26 @@ cameras:
- detect
```
### Configuring Minimum Volume
The audio detector uses volume levels in the same way that motion in a camera feed is used for object detection. This means that frigate will not run audio detection unless the audio volume is above the configured level in order to reduce resource usage. Audio levels can vary widely between camera models so it is important to run tests to see what volume levels are. MQTT explorer can be used on the audio topic to see what volume level is being detected.
:::tip
Volume is considered motion for recordings, this means when the `record -> retain -> mode` is set to `motion` any time audio volume is > min_volume that recording segment for that camera will be kept.
:::
### Configuring Audio Events
The included audio model has over [500 different types](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt) of audio that can be detected, many of which are not practical. By default `bark`, `speech`, `yell`, and `scream` are enabled but these can be customized.
The included audio model has over [500 different types](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt) of audio that can be detected, many of which are not practical. By default `bark`, `fire_alarm`, `scream`, `speech`, and `yell` are enabled but these can be customized.
```yaml
audio:
enabled: True
listen:
- bark
- fire_alarm
- scream
- speech
- yell

View File

@@ -5,6 +5,8 @@ title: Camera Autotracking
An ONVIF-capable, PTZ (pan-tilt-zoom) camera that supports relative movement within the field of view (FOV) can be configured to automatically track moving objects and keep them in the center of the frame.
![Autotracking example with zooming](/img/frigate-autotracking-example.gif)
## Autotracking behavior
Once Frigate determines that an object is not a false positive and has entered one of the required zones, the autotracker will move the PTZ camera to keep the object centered in the frame until the object either moves out of the frame, the PTZ is not capable of any more movement, or Frigate loses track of it.
@@ -50,6 +52,23 @@ cameras:
autotracking:
# Optional: enable/disable object autotracking. (default: shown below)
enabled: False
# Optional: calibrate the camera on startup (default: shown below)
# A calibration will move the PTZ in increments and measure the time it takes to move.
# The results are used to help estimate the position of tracked objects after a camera move.
# Frigate will update your config file automatically after a calibration with
# a "movement_weights" entry for the camera. You should then set calibrate_on_startup to False.
calibrate_on_startup: False
# Optional: the mode to use for zooming in/out on objects during autotracking. (default: shown below)
# Available options are: disabled, absolute, and relative
# disabled - don't zoom in/out on autotracked objects, use pan/tilt only
# absolute - use absolute zooming (supported by most PTZ capable cameras)
# relative - use relative zooming (not supported on all PTZs, but makes concurrent pan/tilt/zoom movements)
zooming: disabled
# Optional: A value to change the behavior of zooming on autotracked objects. (default: shown below)
# A lower value will keep more of the scene in view around a tracked object.
# A higher value will zoom in more on a tracked object, but Frigate may lose tracking more quickly.
# The value should be between 0.1 and 0.75
zoom_factor: 0.3
# Optional: list of objects to track from labelmap.txt (default: shown below)
track:
- person
@@ -60,17 +79,47 @@ cameras:
return_preset: home
# Optional: Seconds to delay before returning to preset. (default: shown below)
timeout: 10
# Optional: Values generated automatically by a camera calibration. Do not modify these manually. (default: shown below)
movement_weights: []
```
## Calibration
PTZ motors operate at different speeds. Performing a calibration will direct Frigate to measure this speed over a variety of movements and use those measurements to better predict the amount of movement necessary to keep autotracked objects in the center of the frame.
Calibration is optional, but will greatly assist Frigate in autotracking objects that move across the camera's field of view more quickly.
To begin calibration, set the `calibrate_on_startup` for your camera to `True` and restart Frigate. Frigate will then make a series of 30 small and large movements with your camera. Don't move the PTZ manually while calibration is in progress. Once complete, camera motion will stop and your config file will be automatically updated with a `movement_weights` parameter to be used in movement calculations. You should not modify this parameter manually.
After calibration has ended, your PTZ will be moved to the preset specified by `return_preset` and you should set `calibrate_on_startup` in your config file to `False`.
Note that Frigate will refine and update the `movement_weights` parameter in your config automatically as the PTZ moves during autotracking and more measurements are obtained.
You can recalibrate at any time by removing the `movement_weights` parameter, setting `calibrate_on_startup` to `True`, and then restarting Frigate. You may need to recalibrate or remove `movement_weights` from your config altogether if autotracking is erratic. If you change your `return_preset` in any way, a recalibration is also recommended.
## Best practices and considerations
Every PTZ camera is different, so autotracking may not perform ideally in every situation. This experimental feature was initially developed using an EmpireTech/Dahua SD1A404XB-GNR.
The object tracker in Frigate estimates the motion of the PTZ so that tracked objects are preserved when the camera moves. In most cases (especially for faster moving objects), the default 5 fps is insufficient for the motion estimator to perform accurately. 10 fps is the current recommendation. Higher frame rates will likely not be more performant and will only slow down Frigate and the motion estimator. Adjust your camera to output at least 10 frames per second and change the `fps` parameter in the [detect configuration](index.md) of your configuration file.
A fast [detector](object_detectors.md) is recommended. CPU detectors will not perform well or won't work at all. If Frigate already has trouble keeping track of your object, the autotracker will struggle as well.
A fast [detector](object_detectors.md) is recommended. CPU detectors will not perform well or won't work at all. You can watch Frigate's debug viewer for your camera to see a thicker colored box around the object currently being autotracked.
The autotracker will add PTZ motion requests to a queue while the motor is moving. Once the motor stops, the events in the queue will be executed together as one large move (rather than incremental moves). If your PTZ's motor is slow, you may not be able to reliably autotrack fast moving objects.
![Autotracking Debug View](/img/autotracking-debug.gif)
A full-frame zone in `required_zones` is not recommended, especially if you've calibrated your camera and there are `movement_weights` defined in the configuration file. Frigate will continue to autotrack an object that has entered one of the `required_zones`, even if it moves outside of that zone.
## Zooming
Zooming is still a very experimental feature and may use significantly more CPU when tracking objects than panning/tilting only. It may be helpful to tweak your camera's autofocus settings if you are noticing focus problems when using zooming.
Absolute zooming makes zoom movements separate from pan/tilt movements. Most PTZ cameras will support absolute zooming.
Relative zooming attempts to make a zoom movement concurrently with any pan/tilt movements. It was tested to work with some Dahua and Amcrest PTZs. But the ONVIF specification indicates that there no assumption about how the generic zoom range is mapped to magnification, field of view or other physical zoom dimension when using relative zooming. So if relative zooming behavior is erratic or just doesn't work, use absolute zooming.
You can optionally adjust the `zoom_factor` for your camera in your configuration file. Lower values will leave more space from the scene around the tracked object while higher values will cause your camera to zoom in more on the object. However, keep in mind that Frigate needs a fair amount of pixels and scene details outside of the bounding box of the tracked object to estimate the motion of your camera. If the object is taking up too much of the frame, Frigate will not be able to track the motion of the camera and your object will be lost.
The range of this option is from 0.1 to 0.75. The default value of 0.3 should be sufficient for most users. If you have a powerful zoom lens on your PTZ or you find your autotracked objects are often lost, you may want to lower this value. Because every PTZ and scene is different, you should experiment to determine what works best for you.
## Usage applications

View File

@@ -80,8 +80,8 @@ cameras:
rtmp:
enabled: False # <-- RTMP should be disabled if your stream is not H264
detect:
width: # <- optional, by default Frigate tries to automatically detect resolution
height: # <- optional, by default Frigate tries to automatically detect resolution
width: # <- optional, by default Frigate tries to automatically detect resolution
height: # <- optional, by default Frigate tries to automatically detect resolution
```
### Blue Iris RTSP Cameras
@@ -108,20 +108,20 @@ According to [this discussion](https://github.com/blakeblackshear/frigate/issues
```yaml
go2rtc:
streams:
your_reolink_camera:
your_reolink_camera:
- "ffmpeg:http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password#video=copy#audio=copy#audio=opus"
your_reolink_camera_sub:
your_reolink_camera_sub:
- "ffmpeg:http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_ext.bcs&user=username&password=password"
cameras:
reolink:
your_reolink_camera:
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/your_reolink_camera?video=copy&audio=aac
- path: rtsp://127.0.0.1:8554/your_reolink_camera
input_args: preset-rtsp-restream
roles:
- record
- path: rtsp://127.0.0.1:8554/your_reolink_camera_sub?video=copy
- path: rtsp://127.0.0.1:8554/your_reolink_camera_sub
input_args: preset-rtsp-restream
roles:
- detect
@@ -140,7 +140,7 @@ go2rtc:
- rtspx://192.168.1.1:7441/abcdefghijk
```
[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.6.2#source-rtsp)
[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#source-rtsp)
In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record and rtmp if used directly with unifi protect.
@@ -150,3 +150,7 @@ ffmpeg:
record: preset-record-ubiquiti
rtmp: preset-rtmp-ubiquiti # recommend using go2rtc instead
```
### TP-Link VIGI Cameras
TP-Link VIGI cameras need some adjustments to the main stream settings on the camera itself to avoid issues. The stream needs to be configured as `H264` with `Smart Coding` set to `off`. Without these settings you may have problems when trying to watch recorded events. For example Firefox will stop playback after a few seconds and show the following error message: `The media playback was aborted due to a corruption problem or because the media used features your browser did not support.`.

View File

@@ -11,11 +11,12 @@ A camera is enabled by default but can be temporarily disabled by using `enabled
Each role can only be assigned to one input per camera. The options for roles are as follows:
| Role | Description |
| ---------- | ---------------------------------------------------------------------------------------- |
| `detect` | Main feed for object detection |
| `record` | Saves segments of the video feed based on configuration settings. [docs](record.md) |
| `rtmp` | Deprecated: Broadcast as an RTMP feed for other services to consume. [docs](restream.md) |
| Role | Description |
| -------- | ---------------------------------------------------------------------------------------- |
| `detect` | Main feed for object detection. [docs](object_detectors.md) |
| `record` | Saves segments of the video feed based on configuration settings. [docs](record.md) |
| `audio` | Feed for audio based detection. [docs](audio_detectors.md) |
| `rtmp` | Deprecated: Broadcast as an RTMP feed for other services to consume. [docs](restream.md) |
```yaml
mqtt:
@@ -51,13 +52,18 @@ For camera model specific settings check the [camera specific](camera_specific.m
## Setting up camera PTZ controls
Add onvif config to camera
:::caution
Not every PTZ supports ONVIF, which is the standard protocol Frigate uses to communicate with your camera. Check the [official list of ONVIF conformant products](https://www.onvif.org/conformant-products/), your camera documentation, or camera manufacturer's website to ensure your PTZ supports ONVIF. Also, ensure your camera is running the latest firmware.
:::
Add the onvif section to your camera in your configuration file:
```yaml
cameras:
back:
ffmpeg:
...
ffmpeg: ...
onvif:
host: 10.0.10.10
port: 8000
@@ -65,6 +71,25 @@ cameras:
password: password
```
then PTZ controls will be available in the cameras WebUI.
If the ONVIF connection is successful, PTZ controls will be available in the camera's WebUI.
An ONVIF-capable camera that supports relative movement within the field of view (FOV) can also be configured to automatically track moving objects and keep them in the center of the frame. For autotracking setup, see the [autotracking](autotracking.md) docs.
## ONVIF PTZ camera recommendations
This list of working and non-working PTZ cameras is based on user feedback.
| Brand or specific camera | PTZ Controls | Autotracking | Notes |
| ------------------------ | :----------: | :----------: | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Amcrest | ✅ | ✅ | ⛔️ Generally, Amcrest should work, but some older models (like the common IP2M-841) don't support autotracking |
| Amcrest ASH21 | ❌ | ❌ | No ONVIF support |
| Ctronics PTZ | ✅ | ❌ | |
| Dahua | ✅ | ✅ | |
| Foscam R5 | ✅ | ❌ | |
| Hikvision | ✅ | ❌ | Incomplete ONVIF support (MoveStatus won't update even on latest firmware) - reported with HWP-N4215IH-DE and DS-2DE3304W-DE, but likely others |
| Reolink 511WA | ✅ | ❌ | Zoom only |
| Reolink E1 Pro | ✅ | ❌ | |
| Reolink E1 Zoom | ✅ | ❌ | |
| Sunba 405-D20X | ✅ | ❌ | |
| Tapo C210 | ❌ | ❌ | Incomplete ONVIF support |
| Vikylin PTZ-2804X-I2 | ❌ | ❌ | Incomplete ONVIF support |

View File

@@ -64,11 +64,10 @@ ffmpeg:
### Configuring Intel GPU Stats in Docker
Additional configuration is needed for the Docker container to be able to access the `intel_gpu_top` command for GPU stats. Three possible changes can be made:
Additional configuration is needed for the Docker container to be able to access the `intel_gpu_top` command for GPU stats. There are two options:
1. Run the container as privileged.
2. Adding the `CAP_PERFMON` capability.
3. Setting the `perf_event_paranoid` low enough to allow access to the performance event system.
2. Add the `CAP_PERFMON` capability (note: you might need to set the `perf_event_paranoid` low enough to allow access to the performance event system.)
#### Run as privileged
@@ -125,7 +124,7 @@ _Note: This setting must be changed for the entire system._
For more information on the various values across different distributions, see https://askubuntu.com/questions/1400874/what-does-perf-paranoia-level-four-do.
Depending on your OS and kernel configuration, you may need to change the `/proc/sys/kernel/perf_event_paranoid` kernel tunable. You can test the change by running `sudo sh -c 'echo 2 >/proc/sys/kernel/perf_event_paranoid'` which will persist until a reboot. Make it permanent by running `sudo sh -c 'echo kernel.perf_event_paranoid=1 >> /etc/sysctl.d/local.conf'`
Depending on your OS and kernel configuration, you may need to change the `/proc/sys/kernel/perf_event_paranoid` kernel tunable. You can test the change by running `sudo sh -c 'echo 2 >/proc/sys/kernel/perf_event_paranoid'` which will persist until a reboot. Make it permanent by running `sudo sh -c 'echo kernel.perf_event_paranoid=2 >> /etc/sysctl.d/local.conf'`
## AMD/ATI GPUs (Radeon HD 2000 and newer GPUs) via libva-mesa-driver

View File

@@ -151,6 +151,7 @@ audio:
# Optional: Types of audio to listen for (default: shown below)
listen:
- bark
- fire_alarm
- scream
- speech
- yell
@@ -323,7 +324,7 @@ motion:
# Low values will cause things like moving shadows to be detected as motion for longer.
# https://www.geeksforgeeks.org/background-subtraction-in-an-image-using-concept-of-running-average/
frame_alpha: 0.01
# Optional: Height of the resized motion frame (default: 50)
# Optional: Height of the resized motion frame (default: 100)
# Higher values will result in more granular motion detection at the expense of higher CPU usage.
# Lower values result in less CPU, but small changes may not register as motion.
frame_height: 100
@@ -361,6 +362,16 @@ record:
# active_objects - save all recording segments with active/moving objects
# NOTE: this mode only applies when the days setting above is greater than 0
mode: all
# Optional: Recording Export Settings
export:
# Optional: Timelapse Output Args (default: shown below).
# NOTE: The default args are set to fit 24 hours of recording into 1 hour playback.
# See https://stackoverflow.com/a/58268695 for more info on how these args work.
# As an example: if you wanted to go from 24 hours to 30 minutes that would be going
# from 86400 seconds to 1800 seconds which would be 1800 / 86400 = 0.02.
# The -r (framerate) dictates how smooth the output video is.
# So the args would be -vf setpts=0.02*PTS -r 30 in that case.
timelapse_args: "-vf setpts=0.04*PTS -r 30"
# Optional: Event recording settings
events:
# Optional: Number of seconds before the event to include (default: shown below)
@@ -425,7 +436,7 @@ rtmp:
enabled: False
# Optional: Restream configuration
# Uses https://github.com/AlexxIT/go2rtc (v1.6.2)
# Uses https://github.com/AlexxIT/go2rtc (v1.7.1)
go2rtc:
# Optional: jsmpeg stream configuration for WebUI
@@ -516,7 +527,7 @@ cameras:
# Required: List of x,y coordinates to define the polygon of the zone.
# NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box.
coordinates: 545,1077,747,939,788,805
# Optional: Number of consecutive frames required for object to be considered present in the zone. Allowed values are 1-10 (default: shown below)
# Optional: Number of consecutive frames required for object to be considered present in the zone (default: shown below).
inertia: 3
# Optional: List of objects that can trigger this zone (default: all tracked objects)
objects:
@@ -573,6 +584,23 @@ cameras:
autotracking:
# Optional: enable/disable object autotracking. (default: shown below)
enabled: False
# Optional: calibrate the camera on startup (default: shown below)
# A calibration will move the PTZ in increments and measure the time it takes to move.
# The results are used to help estimate the position of tracked objects after a camera move.
# Frigate will update your config file automatically after a calibration with
# a "movement_weights" entry for the camera. You should then set calibrate_on_startup to False.
calibrate_on_startup: False
# Optional: the mode to use for zooming in/out on objects during autotracking. (default: shown below)
# Available options are: disabled, absolute, and relative
# disabled - don't zoom in/out on autotracked objects, use pan/tilt only
# absolute - use absolute zooming (supported by most PTZ capable cameras)
# relative - use relative zooming (not supported on all PTZs, but makes concurrent pan/tilt/zoom movements)
zooming: disabled
# Optional: A value to change the behavior of zooming on autotracked objects. (default: shown below)
# A lower value will keep more of the scene in view around a tracked object.
# A higher value will zoom in more on a tracked object, but Frigate may lose tracking more quickly.
# The value should be between 0.1 and 0.75
zoom_factor: 0.3
# Optional: list of objects to track from labelmap.txt (default: shown below)
track:
- person
@@ -580,9 +608,11 @@ cameras:
required_zones:
- zone_name
# Required: Name of ONVIF preset in camera's firmware to return to when tracking is over. (default: shown below)
return_preset: preset_name
return_preset: home
# Optional: Seconds to delay before returning to preset. (default: shown below)
timeout: 10
# Optional: Values generated automatically by a camera calibration. Do not modify these manually. (default: shown below)
movement_weights: []
# Optional: Configuration for how to sort the cameras in the Birdseye view.
birdseye:
@@ -624,7 +654,7 @@ ui:
# Optional: Telemetry configuration
telemetry:
# Optional: Enabled network interfaces for bandwidth stats monitoring (default: shown below)
# Optional: Enabled network interfaces for bandwidth stats monitoring (default: empty list, let nethogs search all)
network_interfaces:
- eth
- enp
@@ -639,6 +669,7 @@ telemetry:
# Enable Intel GPU stats (default: shown below)
intel_gpu_stats: True
# Enable network bandwidth stats monitoring for camera ffmpeg processes, go2rtc, and object detectors. (default: shown below)
# NOTE: The container must either be privileged or have cap_net_admin, cap_net_raw capabilities enabled.
network_bandwidth: False
# Optional: Enable the latest version outbound check (default: shown below)
# NOTE: If you use the HomeAssistant integration, disabling this will prevent it from reporting new versions

View File

@@ -9,11 +9,11 @@ Frigate has different live view options, some of which require the bundled `go2r
Live view options can be selected while viewing the live stream. The options are:
| Source | Latency | Frame Rate | Resolution | Audio | Requires go2rtc | Other Limitations |
| ------ | ------- | ------------------------------------- | -------------- | ---------------------------- | --------------- | -------------------------------------------- |
| jsmpeg | low | same as `detect -> fps`, capped at 10 | same as detect | no | no | none |
| mse | low | native | native | yes (depends on audio codec) | yes | not supported on iOS, Firefox is h.264 only |
| webrtc | lowest | native | native | yes (depends on audio codec) | yes | requires extra config, doesn't support h.265 |
| Source | Latency | Frame Rate | Resolution | Audio | Requires go2rtc | Other Limitations |
| ------ | ------- | ------------------------------------- | -------------- | ---------------------------- | --------------- | ------------------------------------------------- |
| jsmpeg | low | same as `detect -> fps`, capped at 10 | same as detect | no | no | none |
| mse | low | native | native | yes (depends on audio codec) | yes | iPhone requires iOS 17.1+, Firefox is h.264 only |
| webrtc | lowest | native | native | yes (depends on audio codec) | yes | requires extra config, doesn't support h.265 |
### Audio Support
@@ -37,12 +37,12 @@ There may be some cameras that you would prefer to use the sub stream for live v
```yaml
go2rtc:
streams:
rtsp_cam:
test_cam:
- rtsp://192.168.1.5:554/live0 # <- stream which supports video & aac audio.
- "ffmpeg:rtsp_cam#audio=opus" # <- copy of the stream which transcodes audio to opus
rtsp_cam_sub:
- "ffmpeg:test_cam#audio=opus" # <- copy of the stream which transcodes audio to opus for webrtc
test_cam_sub:
- rtsp://192.168.1.5:554/substream # <- stream which supports video & aac audio.
- "ffmpeg:rtsp_cam_sub#audio=opus" # <- copy of the stream which transcodes audio to opus
- "ffmpeg:test_cam_sub#audio=opus" # <- copy of the stream which transcodes audio to opus for webrtc
cameras:
test_cam:
@@ -59,7 +59,7 @@ cameras:
roles:
- detect
live:
stream_name: rtsp_cam_sub
stream_name: test_cam_sub
```
### WebRTC extra configuration:
@@ -78,7 +78,7 @@ WebRTC works by creating a TCP or UDP connection on port `8555`. However, it req
- 192.168.1.10:8555
- stun:8555
```
- For access through Tailscale, the Frigate system's Tailscale IP must be added as a WebRTC candidate. Tailscale IPs all start with `100.`, and are reserved within the `100.0.0.0/8` CIDR block.
:::tip
@@ -115,4 +115,4 @@ services:
:::
See [go2rtc WebRTC docs](https://github.com/AlexxIT/go2rtc/tree/v1.6.2#module-webrtc) for more information about this.
See [go2rtc WebRTC docs](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#module-webrtc) for more information about this.

View File

@@ -235,10 +235,18 @@ An example `docker-compose.yml` fragment that converts the `yolov4-608` and `yol
```yml
frigate:
environment:
- YOLO_MODELS="yolov4-608,yolov7x-640"
- YOLO_MODELS=yolov4-608,yolov7x-640
- USE_FP16=false
```
If you have multiple GPUs passed through to Frigate, you can specify which one to use for the model conversion. The conversion script will use the first visible GPU, however in systems with mixed GPU models you may not want to use the default index for object detection. Add the `TRT_MODEL_PREP_DEVICE` environment variable to select a specific GPU.
```yml
frigate:
environment:
- TRT_MODEL_PREP_DEVICE=0 # Optionally, select which GPU is used for model optimization
```
### Configuration Parameters
The TensorRT detector can be selected by specifying `tensorrt` as the model type. The GPU will need to be passed through to the docker container using the same methods described in the [Hardware Acceleration](hardware_acceleration.md#nvidia-gpu) section. If you pass through multiple GPUs, you can select which GPU is used for a detector with the `device` configuration parameter. The `device` parameter is an integer value of the GPU index, as shown by `nvidia-smi` within the container.

View File

@@ -7,7 +7,7 @@ title: Restream
Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://<frigate_host>:8554/<camera_name>`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate.
Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.6.2) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.6.2#configuration) for more advanced configurations and features.
Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.7.1) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#configuration) for more advanced configurations and features.
:::note
@@ -53,31 +53,31 @@ One connection is made to the camera. One for the restream, `detect` and `record
```yaml
go2rtc:
streams:
rtsp_cam: # <- for RTSP streams
name_your_rtsp_cam: # <- for RTSP streams
- rtsp://192.168.1.5:554/live0 # <- stream which supports video & aac audio
- "ffmpeg:rtsp_cam#audio=opus" # <- copy of the stream which transcodes audio to the missing codec (usually will be opus)
http_cam: # <- for other streams
- "ffmpeg:name_your_rtsp_cam#audio=opus" # <- copy of the stream which transcodes audio to the missing codec (usually will be opus)
name_your_http_cam: # <- for other streams
- http://192.168.50.155/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=user&password=password # <- stream which supports video & aac audio
- "ffmpeg:http_cam#audio=opus" # <- copy of the stream which transcodes audio to the missing codec (usually will be opus)
- "ffmpeg:name_your_http_cam#audio=opus" # <- copy of the stream which transcodes audio to the missing codec (usually will be opus)
cameras:
rtsp_cam:
name_your_rtsp_cam:
ffmpeg:
output_args:
record: preset-record-generic-audio-copy
inputs:
- path: rtsp://127.0.0.1:8554/rtsp_cam # <--- the name here must match the name of the camera in restream
- path: rtsp://127.0.0.1:8554/name_your_rtsp_cam # <--- the name here must match the name of the camera in restream
input_args: preset-rtsp-restream
roles:
- record
- detect
- audio # <- only necessary if audio detection is enabled
http_cam:
name_your_http_cam:
ffmpeg:
output_args:
record: preset-record-generic-audio-copy
inputs:
- path: rtsp://127.0.0.1:8554/http_cam # <--- the name here must match the name of the camera in restream
- path: rtsp://127.0.0.1:8554/name_your_http_cam # <--- the name here must match the name of the camera in restream
input_args: preset-rtsp-restream
roles:
- record
@@ -92,44 +92,44 @@ Two connections are made to the camera. One for the sub stream, one for the rest
```yaml
go2rtc:
streams:
rtsp_cam:
name_your_rtsp_cam:
- rtsp://192.168.1.5:554/live0 # <- stream which supports video & aac audio. This is only supported for rtsp streams, http must use ffmpeg
- "ffmpeg:rtsp_cam#audio=opus" # <- copy of the stream which transcodes audio to opus
rtsp_cam_sub:
- "ffmpeg:name_your_rtsp_cam#audio=opus" # <- copy of the stream which transcodes audio to opus
name_your_rtsp_cam_sub:
- rtsp://192.168.1.5:554/substream # <- stream which supports video & aac audio. This is only supported for rtsp streams, http must use ffmpeg
- "ffmpeg:rtsp_cam_sub#audio=opus" # <- copy of the stream which transcodes audio to opus
http_cam:
- "ffmpeg:name_your_rtsp_cam_sub#audio=opus" # <- copy of the stream which transcodes audio to opus
name_your_http_cam:
- http://192.168.50.155/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=user&password=password # <- stream which supports video & aac audio. This is only supported for rtsp streams, http must use ffmpeg
- "ffmpeg:http_cam#audio=opus" # <- copy of the stream which transcodes audio to opus
http_cam_sub:
- "ffmpeg:name_your_http_cam#audio=opus" # <- copy of the stream which transcodes audio to opus
name_your_http_cam_sub:
- http://192.168.50.155/flv?port=1935&app=bcs&stream=channel0_ext.bcs&user=user&password=password # <- stream which supports video & aac audio. This is only supported for rtsp streams, http must use ffmpeg
- "ffmpeg:http_cam_sub#audio=opus" # <- copy of the stream which transcodes audio to opus
- "ffmpeg:name_your_http_cam_sub#audio=opus" # <- copy of the stream which transcodes audio to opus
cameras:
rtsp_cam:
name_your_rtsp_cam:
ffmpeg:
output_args:
record: preset-record-generic-audio-copy
inputs:
- path: rtsp://127.0.0.1:8554/rtsp_cam # <--- the name here must match the name of the camera in restream
- path: rtsp://127.0.0.1:8554/name_your_rtsp_cam # <--- the name here must match the name of the camera in restream
input_args: preset-rtsp-restream
roles:
- record
- path: rtsp://127.0.0.1:8554/rtsp_cam_sub # <--- the name here must match the name of the camera_sub in restream
- path: rtsp://127.0.0.1:8554/name_your_rtsp_cam_sub # <--- the name here must match the name of the camera_sub in restream
input_args: preset-rtsp-restream
roles:
- audio # <- only necessary if audio detection is enabled
- detect
http_cam:
name_your_http_cam:
ffmpeg:
output_args:
record: preset-record-generic-audio-copy
inputs:
- path: rtsp://127.0.0.1:8554/http_cam # <--- the name here must match the name of the camera in restream
- path: rtsp://127.0.0.1:8554/name_your_http_cam # <--- the name here must match the name of the camera in restream
input_args: preset-rtsp-restream
roles:
- record
- path: rtsp://127.0.0.1:8554/http_cam_sub # <--- the name here must match the name of the camera_sub in restream
- path: rtsp://127.0.0.1:8554/name_your_http_cam_sub # <--- the name here must match the name of the camera_sub in restream
input_args: preset-rtsp-restream
roles:
- audio # <- only necessary if audio detection is enabled
@@ -138,7 +138,7 @@ cameras:
## Advanced Restream Configurations
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.6.2#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
NOTE: The output will need to be passed with two curly braces `{{output}}`

View File

@@ -56,3 +56,27 @@ camera:
```
Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. You will get events for person objects that enter anywhere in the yard, and events for cars only if they enter the street.
### Zone Inertia
Sometimes an objects bounding box may be slightly incorrect and the bottom center of the bounding box is inside the zone while the object is not actually in the zone. Zone inertia helps guard against this by requiring an object's bounding box to be within the zone for multiple consecutive frames. This value can be configured:
```yaml
camera:
zones:
front_yard:
inertia: 3
objects:
- person
```
There may also be cases where you expect an object to quickly enter and exit a zone, like when a car is pulling into the driveway, and you may want to have the object be considered present in the zone immediately:
```yaml
camera:
zones:
driveway_entrance:
inertia: 1
objects:
- car
```

View File

@@ -155,10 +155,6 @@ cd web && npm install
cd web && npm run dev
```
#### 3a. Run the development server against a non-local instance
To run the development server against a non-local instance, you will need to modify the API_HOST default return in `web/src/env.js`.
#### 4. Making changes
The Web UI is built using [Vite](https://vitejs.dev/), [Preact](https://preactjs.com), and [Tailwind CSS](https://tailwindcss.com).

View File

@@ -204,6 +204,8 @@ It is recommended to run Frigate in LXC for maximum performance. See [this discu
For details on running Frigate using ESXi, please see the instructions [here](https://williamlam.com/2023/05/frigate-nvr-with-coral-tpu-igpu-passthrough-using-esxi-on-intel-nuc.html).
If you're running Frigate on a rack mounted server and want to passthough the Google Coral, [read this.](https://github.com/blakeblackshear/frigate/issues/305)
## Synology NAS on DSM 7
These settings were tested on DSM 7.1.1-42962 Update 4

View File

@@ -11,7 +11,7 @@ Use of the bundled go2rtc is optional. You can still configure FFmpeg to connect
# Setup a go2rtc stream
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. If you set the stream name under go2rtc to match the name of your camera, it will automatically be mapped and you will get additional live view options for the camera. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.6.2#module-streams), not just rtsp.
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. If you set the stream name under go2rtc to match the name of your camera, it will automatically be mapped and you will get additional live view options for the camera. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#module-streams), not just rtsp.
```yaml
go2rtc:
@@ -24,7 +24,7 @@ The easiest live view to get working is MSE. After adding this to the config, re
### What if my video doesn't play?
If you are unable to see your video feed, first check the go2rtc logs in the Frigate UI under Logs in the sidebar. If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log. If you do not see any errors, then the video codec of the stream may not be supported in your browser. If your camera stream is set to H265, try switching to H264. You can see more information about [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.6.2#codecs-madness) in the go2rtc documentation. If you are not able to switch your camera settings from H265 to H264 or your stream is a different format such as MJPEG, you can use go2rtc to re-encode the video using the [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.6.2#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. Here is an example of a config that will re-encode the stream to H264 without hardware acceleration:
If you are unable to see your video feed, first check the go2rtc logs in the Frigate UI under Logs in the sidebar. If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log. If you do not see any errors, then the video codec of the stream may not be supported in your browser. If your camera stream is set to H265, try switching to H264. You can see more information about [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#codecs-madness) in the go2rtc documentation. If you are not able to switch your camera settings from H265 to H264 or your stream is a different format such as MJPEG, you can use go2rtc to re-encode the video using the [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. Here is an example of a config that will re-encode the stream to H264 without hardware acceleration:
```yaml
go2rtc:

View File

@@ -155,18 +155,25 @@ Version info
Events from the database. Accepts the following query string parameters:
| param | Type | Description |
| -------------------- | ---- | --------------------------------------------- |
| `before` | int | Epoch time |
| `after` | int | Epoch time |
| `cameras` | str | , separated list of cameras |
| `labels` | str | , separated list of labels |
| `zones` | str | , separated list of zones |
| `limit` | int | Limit the number of events returned |
| `has_snapshot` | int | Filter to events that have snapshots (0 or 1) |
| `has_clip` | int | Filter to events that have clips (0 or 1) |
| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) |
| `in_progress` | int | Limit to events in progress (0 or 1) |
| param | Type | Description |
| -------------------- | ----- | ----------------------------------------------------- |
| `before` | int | Epoch time |
| `after` | int | Epoch time |
| `cameras` | str | , separated list of cameras |
| `labels` | str | , separated list of labels |
| `zones` | str | , separated list of zones |
| `limit` | int | Limit the number of events returned |
| `has_snapshot` | int | Filter to events that have snapshots (0 or 1) |
| `has_clip` | int | Filter to events that have clips (0 or 1) |
| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) |
| `in_progress` | int | Limit to events in progress (0 or 1) |
| `time_range` | str | Time range in format after,before (00:00,24:00) |
| `timezone` | str | Timezone to use for time range |
| `min_score` | float | Minimum score of the event |
| `max_score` | float | Maximum score of the event |
| `is_submitted` | int | Filter events that are submitted to Frigate+ (0 or 1) |
| `min_length` | float | Minimum length of the event |
| `max_length` | float | Maximum length of the event |
### `GET /api/timeline`
@@ -252,7 +259,7 @@ Accepts the following query string parameters, but they are only applied when an
Returns the snapshot image from the latest event for the given camera and label combo. Using `any` as the label will return the latest thumbnail regardless of type.
### `GET /api/<camera_name>/recording/<frame_time>/snapshot.png`
### `GET /api/<camera_name>/recordings/<frame_time>/snapshot.png`
Returns the snapshot image from the specific point in that cameras recordings.
@@ -313,13 +320,19 @@ Get PTZ info for the camera.
### `POST /api/events/<camera_name>/<label>/create`
Create a manual API with a given `label` (ex: doorbell press) to capture a specific event besides an object being detected.
Create a manual event with a given `label` (ex: doorbell press) to capture a specific event besides an object being detected.
:::caution
Recording retention config still applies to manual events, if frigate is configured with `mode: motion` then the manual event will only keep recording segments when motion occurred.
:::
**Optional Body:**
```json
{
"subLabel": "some_string", // add sub label to event
"sub_label": "some_string", // add sub label to event
"duration": 30, // predetermined length of event (default: 30 seconds) or can be to null for indeterminate length event
"include_recording": true, // whether the event should save recordings along with the snapshot that is taken
"draw": {

View File

@@ -3,13 +3,7 @@ id: plus
title: Frigate+
---
:::info
Frigate+ is under active development. Models are available as a part of an invitation only beta. It is free to create an account and upload/annotate your examples.
:::
Frigate+ offers models trained from scratch and specifically designed for the way Frigate NVR analyzes video footage. They offer higher accuracy with less resources and include a more relevant set of objects for security cameras. By uploading your own labeled examples, your model can be uniquely tuned for accuracy in your specific conditions. After tuning, performance is evaluated against a broad dataset and real world examples submitted by other Frigate+ users to prevent overfitting.
For more information about how to use Frigate+ to improve your model, see the [Frigate+ docs](/plus/).
## Setup

139
docs/docs/plus/index.md Normal file
View File

@@ -0,0 +1,139 @@
---
id: index
title: Models Guide
---
Frigate+ offers models trained from scratch and specifically designed for the way Frigate NVR analyzes video footage. These models offer higher accuracy with less resources. By uploading your own labeled examples, your model is tuned for accuracy in your specific conditions. After tuning, performance is evaluated against a broad dataset and real world examples submitted by other Frigate+ users to prevent overfitting.
With a subscription, and at each annual renewal, you will receive 12 model training credits that can be used to train tuned models. If you cancel your subscription, you will keep your existing credits and retain access to any trained models. Users with an active subscription can purchase additional training credits for $5 each.
Information on how to integrate Frigate+ with Frigate can be found in the [integrations docs](/integrations/plus).
## Frequently asked questions
### Are my models trained just on my image uploads? How are they built?
Frigate+ models are built by fine tuning a base model with the images you have annotated and verified. The base model is trained from scratch from a sampling of images across all Frigate+ user submissions and takes weeks of expensive GPU resources to train. If the models were built using your image uploads alone, you would need to provide tens of thousands of examples and it would take more than a week (and considerable cost) to train. Diversity helps the model generalize.
### What is a training credit and how do I use them?
Essentially, `1 training credit = 1 trained model`. When you have uploaded, annotated, and verified additional images and you are ready to train your model, you will submit a model request which will use one credit. The model that is trained will utilize all of the verified images in your account.
### Are my video feeds sent to the cloud for analysis when using Frigate+ models?
No. Frigate+ models are a drop in replacement for the default model. All processing is performed locally as always. The only images sent to Frigate+ are the ones you specifically submit via the `Send to Frigate+` button or upload directly.
### Can I label anything I want and train the model to recognize something custom for me?
Not currently. At the moment, the set of labels will be consistent for all users. The focus will be on expanding that set of labels before working on completely custom user labels.
### Can Frigate+ models be used offline?
Yes. Models and metadata are stored in the `model_cache` directory within the config folder. Frigate will only attempt to download a model if it does not exist in the cache. This means you can backup the directory and/or use it completely offline.
### Can I keep using my Frigate+ models even if I do not renew my subscription?
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained using the training credits that you purchased are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models.
## Important model information
### Supported Model Types
Currently, Frigate+ models only support CPU (`cpu`) and Coral (`edgetpu`) models. OpenVino is next in line to gain support.
The models are created using the same MobileDet architecture as the default model. Additional architectures will be added in future releases as needed.
### Higher Scores
Frigate+ models generally have much higher scores than the default model provided in Frigate. You will likely need to increase your `threshold` and `min_score` values. Here is an example of how these values can be refined, but you should expect these to evolve as your model improves:
```yaml
objects:
filters:
dog:
min_score: .7
threshold: .9
cat:
min_score: .65
threshold: .8
face:
min_score: .7
package:
min_score: .65
threshold: .9
license_plate:
min_score: .6
amazon:
min_score: .75
ups:
min_score: .75
fedex:
min_score: .75
person:
min_score: .65
threshold: .85
car:
min_score: .65
threshold: .85
```
### Available label types
Frigate+ models support a more relevant set of objects for security cameras. Currently, only the following objects are supported: `person`, `face`, `car`, `license_plate`, `amazon`, `ups`, `fedex`, `package`, `dog`, `cat`, `deer`. Other object types available in the default Frigate model are not available. Additional object types will be added in future releases.
#### Label attributes
Frigate has special handling for some labels when using Frigate+ models. `face`, `license_plate`, `amazon`, `ups`, and `fedex` are considered attribute labels which are not tracked like regular objects and do not generate events. In addition, the `threshold` filter will have no effect on these labels. You should adjust the `min_score` and other filter values as needed.
In order to have Frigate start using these attribute labels, you will need to add them to the list of objects to track:
```yaml
objects:
track:
- person
- face
- license_plate
- dog
- cat
- car
- amazon
- fedex
- ups
- package
```
When using Frigate+ models, Frigate will choose the snapshot of a person object that has the largest visible face. For cars, the snapshot with the largest visible license plate will be selected. This aids in secondary processing such as facial and license plate recognition for person and car objects.
![Face Attribute](/img/plus/attribute-example-face.jpg)
`amazon`, `ups`, and `fedex` labels are used to automatically assign a sub label to car objects.
![Fedex Attribute](/img/plus/attribute-example-fedex.jpg)
## Properly labeling images
For the best results, follow the following guidelines.
**Label every object in the image**: It is important that you label all objects in each image before verifying. If you don't label a car for example, the model will be taught that part of the image is _not_ a car and it will start to get confused.
**Make tight bounding boxes**: Tighter bounding boxes improve the recognition and ensure that accurate bounding boxes are predicted at runtime.
**Label the full object even when occluded**: If you have a person standing behind a car, label the full person even though a portion of their body may be hidden behind the car. This helps predict accurate bounding boxes and improves zone accuracy and filters at runtime.
**`amazon`, `ups`, and `fedex` should label the logo**: For a Fedex truck, label the truck as a `car` and make a different bounding box just for the Fedex logo. If there are multiple logos, label each of them.
![Fedex Logo](/img/plus/fedex-logo.jpg)
## Improving your model
You may find that Frigate+ models result in more false positives initially, but by submitting true and false positives, the model will improve. This may be because your cameras don't look quite enough like the user submissions that were used to train the base model. Over time, this will improve as more and more users (including you) submit examples to Frigate+.
False positives can be reduced by submitting **both** true positives and false positives. This will help the model differentiate between what is and isn't correct.
You may find that it's helpful to lower your thresholds a little in order to generate more false/true positives near the threshold value. For example, if you have some false positives that are scoring at 68% and some true positives scoring at 72%, you can try lowering your threshold to 65% and submitting both true and false positives within that range. This will help the model learn and widen the gap between true and false positive scores.
Note that only verified images will be used when training your model. Submitting an image from Frigate as a true or false positive will not verify the image. You still must verify the image in Frigate+ in order for it to be used in training.
In order to request your first model, you will need to have annotated and verified at least 10 images. Each subsequent model request will require that 10 additional images are verified. However, this is the bare minimum. For the best results, you should provide at least 100 verified images per camera. Keep in mind that varying conditions should be included. You will want images from cloudy days, sunny days, dawn, dusk, and night.
As circumstances change, you may need to submit new examples to address new types of false positives. For example, the change from summer days to snowy winter days or other changes such as a new grill or patio furniture may require additional examples and training.

View File

@@ -21,8 +21,8 @@ module.exports = {
{
type: "link",
label: "Go2RTC Configuration Reference",
href: "https://github.com/AlexxIT/go2rtc/tree/v1.6.2#configuration"
}
href: "https://github.com/AlexxIT/go2rtc/tree/v1.7.1#configuration",
},
],
Detectors: [
"configuration/object_detectors",
@@ -57,13 +57,11 @@ module.exports = {
"integrations/mqtt",
"integrations/third_party_extensions",
],
Troubleshooting: [
"troubleshooting/faqs",
"troubleshooting/recordings",
],
"Frigate+": ["plus/index"],
Troubleshooting: ["troubleshooting/faqs", "troubleshooting/recordings"],
Development: [
"development/contributing",
"development/contributing-boards"
"development/contributing-boards",
],
},
};

BIN
docs/static/img/autotracking-debug.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
docs/static/img/plus/fedex-logo.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -163,6 +163,8 @@ class FrigateApp:
"frame_queue": mp.Queue(maxsize=2),
"capture_process": None,
"process": None,
"audio_rms": mp.Value("d", 0.0), # type: ignore[typeddict-item]
"audio_dBFS": mp.Value("d", 0.0), # type: ignore[typeddict-item]
}
self.ptz_metrics[camera_name] = {
"ptz_autotracker_enabled": mp.Value( # type: ignore[typeddict-item]
@@ -179,6 +181,12 @@ class FrigateApp:
"ptz_stop_time": mp.Value("d", 0.0), # type: ignore[typeddict-item]
# issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards
"ptz_frame_time": mp.Value("d", 0.0), # type: ignore[typeddict-item]
# issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards
"ptz_zoom_level": mp.Value("d", 0.0), # type: ignore[typeddict-item]
# issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards
}
self.ptz_metrics[camera_name]["ptz_stopped"].set()
self.feature_metrics[camera_name] = {
@@ -494,6 +502,7 @@ class FrigateApp:
args=(
self.config,
self.audio_recordings_info_queue,
self.camera_metrics,
self.feature_metrics,
self.inter_process_communicator,
),

View File

@@ -13,6 +13,7 @@ from pydantic import BaseModel, Extra, Field, parse_obj_as, validator
from pydantic.fields import PrivateAttr
from frigate.const import (
ALL_ATTRIBUTE_LABELS,
AUDIO_MIN_CONFIDENCE,
CACHE_DIR,
DEFAULT_DB_PATH,
@@ -48,9 +49,10 @@ DEFAULT_TIME_FORMAT = "%m/%d/%Y %H:%M:%S"
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
DEFAULT_TRACKED_OBJECTS = ["person"]
DEFAULT_LISTEN_AUDIO = ["bark", "speech", "yell", "scream"]
DEFAULT_LISTEN_AUDIO = ["bark", "fire_alarm", "scream", "speech", "yell"]
DEFAULT_DETECTORS = {"cpu": {"type": "cpu"}}
DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720}
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
class FrigateBaseModel(BaseModel):
@@ -107,7 +109,7 @@ class StatsConfig(FrigateBaseModel):
class TelemetryConfig(FrigateBaseModel):
network_interfaces: List[str] = Field(
default=["eth", "enp", "eno", "ens", "wl", "lo"],
default=[],
title="Enabled network interfaces for bandwidth calculation.",
)
stats: StatsConfig = Field(
@@ -137,8 +139,26 @@ class MqttConfig(FrigateBaseModel):
return v
class ZoomingModeEnum(str, Enum):
disabled = "disabled"
absolute = "absolute"
relative = "relative"
class PtzAutotrackConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable PTZ object autotracking.")
calibrate_on_startup: bool = Field(
default=False, title="Perform a camera calibration when Frigate starts."
)
zooming: ZoomingModeEnum = Field(
default=ZoomingModeEnum.disabled, title="Autotracker zooming mode."
)
zoom_factor: float = Field(
default=0.3,
title="Zooming factor (0.1-0.75).",
ge=0.1,
le=0.75,
)
track: List[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.")
required_zones: List[str] = Field(
default_factory=list,
@@ -151,6 +171,27 @@ class PtzAutotrackConfig(FrigateBaseModel):
timeout: int = Field(
default=10, title="Seconds to delay before returning to preset."
)
movement_weights: Optional[Union[float, List[float]]] = Field(
default=[],
title="Internal value used for PTZ movements based on the speed of your camera's motor.",
)
@validator("movement_weights", pre=True)
def validate_weights(cls, v):
if v is None:
return None
if isinstance(v, str):
weights = list(map(float, v.split(",")))
elif isinstance(v, list):
weights = [float(val) for val in v]
else:
raise ValueError("Invalid type for movement_weights")
if len(weights) != 3:
raise ValueError("movement_weights must have exactly 3 floats")
return weights
class OnvifConfig(FrigateBaseModel):
@@ -198,6 +239,12 @@ class RecordRetainConfig(FrigateBaseModel):
mode: RetainModeEnum = Field(default=RetainModeEnum.all, title="Retain mode.")
class RecordExportConfig(FrigateBaseModel):
timelapse_args: str = Field(
default=DEFAULT_TIME_LAPSE_FFMPEG_ARGS, title="Timelapse Args"
)
class RecordConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable record on all cameras.")
sync_on_startup: bool = Field(
@@ -213,6 +260,9 @@ class RecordConfig(FrigateBaseModel):
events: EventsConfig = Field(
default_factory=EventsConfig, title="Event specific settings."
)
export: RecordExportConfig = Field(
default_factory=RecordExportConfig, title="Recording Export Config"
)
enabled_in_config: Optional[bool] = Field(
title="Keep track of original state of recording."
)
@@ -387,7 +437,6 @@ class ZoneConfig(BaseModel):
default=3,
title="Number of consecutive frames required for object to be considered present in the zone.",
gt=0,
le=10,
)
objects: List[str] = Field(
default_factory=list,
@@ -425,7 +474,7 @@ class ZoneConfig(BaseModel):
class ObjectConfig(FrigateBaseModel):
track: List[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.")
filters: Optional[Dict[str, FilterConfig]] = Field(title="Object filters.")
filters: Dict[str, FilterConfig] = Field(default={}, title="Object filters.")
mask: Union[str, List[str]] = Field(default="", title="Object mask.")
@@ -1029,6 +1078,13 @@ class FrigateConfig(FrigateBaseModel):
config.mqtt.user = config.mqtt.user.format(**FRIGATE_ENV_VARS)
config.mqtt.password = config.mqtt.password.format(**FRIGATE_ENV_VARS)
# set default min_score for object attributes
for attribute in ALL_ATTRIBUTE_LABELS:
if not config.objects.filters.get(attribute):
config.objects.filters[attribute] = FilterConfig(min_score=0.7)
elif config.objects.filters[attribute].min_score == 0.5:
config.objects.filters[attribute].min_score = 0.7
# Global config to propagate down to camera level
global_config = config.dict(
include={

View File

@@ -14,7 +14,7 @@ import requests
from setproctitle import setproctitle
from frigate.comms.inter_process import InterProcessCommunicator
from frigate.config import CameraConfig, FrigateConfig
from frigate.config import CameraConfig, CameraInput, FfmpegConfig, FrigateConfig
from frigate.const import (
AUDIO_DURATION,
AUDIO_FORMAT,
@@ -26,7 +26,7 @@ from frigate.const import (
from frigate.ffmpeg_presets import parse_preset_input
from frigate.log import LogPipe
from frigate.object_detection import load_labels
from frigate.types import FeatureMetricsTypes
from frigate.types import CameraMetricsTypes, FeatureMetricsTypes
from frigate.util.builtin import get_ffmpeg_arg_list
from frigate.util.services import listen
from frigate.video import start_or_restart_ffmpeg, stop_ffmpeg
@@ -39,19 +39,36 @@ except ModuleNotFoundError:
logger = logging.getLogger(__name__)
def get_ffmpeg_command(input_args: list[str], input_path: str) -> list[str]:
return get_ffmpeg_arg_list(
f"ffmpeg {{}} -i {{}} -f {AUDIO_FORMAT} -ar {AUDIO_SAMPLE_RATE} -ac 1 -y {{}}".format(
" ".join(input_args),
input_path,
def get_ffmpeg_command(ffmpeg: FfmpegConfig) -> list[str]:
ffmpeg_input: CameraInput = [i for i in ffmpeg.inputs if "audio" in i.roles][0]
input_args = get_ffmpeg_arg_list(ffmpeg.global_args) + (
parse_preset_input(ffmpeg_input.input_args, 1)
or ffmpeg_input.input_args
or parse_preset_input(ffmpeg.input_args, 1)
or ffmpeg.input_args
)
return (
["ffmpeg", "-vn"]
+ input_args
+ ["-i"]
+ [ffmpeg_input.path]
+ [
"-f",
f"{AUDIO_FORMAT}",
"-ar",
f"{AUDIO_SAMPLE_RATE}",
"-ac",
"1",
"-y",
"pipe:",
)
]
)
def listen_to_audio(
config: FrigateConfig,
recordings_info_queue: mp.Queue,
camera_metrics: dict[str, CameraMetricsTypes],
process_info: dict[str, FeatureMetricsTypes],
inter_process_communicator: InterProcessCommunicator,
) -> None:
@@ -80,6 +97,7 @@ def listen_to_audio(
audio = AudioEventMaintainer(
camera,
recordings_info_queue,
camera_metrics,
process_info,
stop_event,
inter_process_communicator,
@@ -153,6 +171,7 @@ class AudioEventMaintainer(threading.Thread):
self,
camera: CameraConfig,
recordings_info_queue: mp.Queue,
camera_metrics: dict[str, CameraMetricsTypes],
feature_metrics: dict[str, FeatureMetricsTypes],
stop_event: mp.Event,
inter_process_communicator: InterProcessCommunicator,
@@ -161,19 +180,16 @@ class AudioEventMaintainer(threading.Thread):
self.name = f"{camera.name}_audio_event_processor"
self.config = camera
self.recordings_info_queue = recordings_info_queue
self.camera_metrics = camera_metrics
self.feature_metrics = feature_metrics
self.inter_process_communicator = inter_process_communicator
self.detections: dict[dict[str, any]] = feature_metrics
self.detections: dict[dict[str, any]] = {}
self.stop_event = stop_event
self.detector = AudioTfl(stop_event, self.config.audio.num_threads)
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2))
self.logger = logging.getLogger(f"audio.{self.config.name}")
self.ffmpeg_cmd = get_ffmpeg_command(
get_ffmpeg_arg_list(self.config.ffmpeg.global_args)
+ parse_preset_input("preset-rtsp-audio-only", 1),
[i.path for i in self.config.ffmpeg.inputs if "audio" in i.roles][0],
)
self.ffmpeg_cmd = get_ffmpeg_command(self.config.ffmpeg)
self.logpipe = LogPipe(f"ffmpeg.{self.config.name}.audio")
self.audio_listener = None
@@ -184,6 +200,9 @@ class AudioEventMaintainer(threading.Thread):
audio_as_float = audio.astype(np.float32)
rms, dBFS = self.calculate_audio_levels(audio_as_float)
self.camera_metrics[self.config.name]["audio_rms"].value = rms
self.camera_metrics[self.config.name]["audio_dBFS"].value = dBFS
# only run audio detection when volume is above min_volume
if rms >= self.config.audio.min_volume:
# add audio info to recordings queue
@@ -196,6 +215,8 @@ class AudioEventMaintainer(threading.Thread):
model_detections = self.detector.detect(waveform)
for label, score, _ in model_detections:
logger.debug(f"Heard {label} with a score of {score}")
if label not in self.config.audio.listen:
continue

View File

@@ -42,6 +42,9 @@ def should_update_state(prev_event: Event, current_event: Event) -> bool:
if prev_event["stationary"] != current_event["stationary"]:
return True
if prev_event["attributes"] != current_event["attributes"]:
return True
return False

View File

@@ -97,8 +97,8 @@ PRESETS_HW_ACCEL_ENCODE_TIMELAPSE = {
"preset-rpi-32-h264": "ffmpeg -hide_banner {0} -c:v h264_v4l2m2m {1}",
"preset-rpi-64-h264": "ffmpeg -hide_banner {0} -c:v h264_v4l2m2m {1}",
"preset-vaapi": "ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {2} {0} -c:v h264_vaapi {1}",
"preset-intel-qsv-h264": "ffmpeg -hide_banner {0} -c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1 {1}",
"preset-intel-qsv-h265": "ffmpeg -hide_banner {0} -c:v hevc_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1 {1}",
"preset-intel-qsv-h264": "ffmpeg -hide_banner {0} -c:v h264_qsv -profile:v high -level:v 4.1 -async_depth:v 1 {1}",
"preset-intel-qsv-h265": "ffmpeg -hide_banner {0} -c:v hevc_qsv -profile:v high -level:v 4.1 -async_depth:v 1 {1}",
"preset-nvidia-h264": "ffmpeg -hide_banner -hwaccel cuda -hwaccel_output_format cuda -extra_hw_frames 8 {0} -c:v h264_nvenc {1}",
"preset-nvidia-h265": "ffmpeg -hide_banner -hwaccel cuda -hwaccel_output_format cuda -extra_hw_frames 8 {0} -c:v hevc_nvenc {1}",
"preset-jetson-h264": "ffmpeg -hide_banner {0} -c:v h264_nvmpi -profile high {1}",
@@ -256,13 +256,6 @@ PRESETS_INPUT = {
"-use_wallclock_as_timestamps",
"1",
],
"preset-rtsp-audio-only": [
"-rtsp_transport",
"tcp",
TIMEOUT_PARAM,
"5000000",
"-vn",
],
"preset-rtsp-restream": _user_agent_args
+ [
"-rtsp_transport",

View File

@@ -20,6 +20,7 @@ from flask import (
Flask,
Response,
current_app,
escape,
jsonify,
make_response,
request,
@@ -28,12 +29,14 @@ from peewee import DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict
from playhouse.sqliteq import SqliteQueueDatabase
from tzlocal import get_localzone_name
from werkzeug.utils import secure_filename
from frigate.config import FrigateConfig
from frigate.const import (
CACHE_DIR,
CLIPS_DIR,
CONFIG_DIR,
EXPORT_DIR,
MAX_SEGMENT_DURATION,
RECORD_DIR,
)
@@ -55,6 +58,8 @@ from frigate.version import VERSION
logger = logging.getLogger(__name__)
DEFAULT_TIME_RANGE = "00:00,24:00"
bp = Blueprint("frigate", __name__)
@@ -70,6 +75,13 @@ def create_app(
):
app = Flask(__name__)
@app.before_request
def check_csrf():
if request.method in ["GET", "HEAD", "OPTIONS", "TRACE"]:
pass
if "origin" in request.headers and "x-csrf-token" not in request.headers:
return jsonify({"success": False, "message": "Missing CSRF header"}), 401
@app.before_request
def _db_connect():
if database.is_closed():
@@ -529,10 +541,14 @@ def event_thumbnail(id, max_cache_age=2592000):
if tracked_obj is not None:
thumbnail_bytes = tracked_obj.get_thumbnail()
except Exception:
return "Event not found", 404
return make_response(
jsonify({"success": False, "message": "Event not found"}), 404
)
if thumbnail_bytes is None:
return "Event not found", 404
return make_response(
jsonify({"success": False, "message": "Event not found"}), 404
)
# android notifications prefer a 2:1 ratio
if format == "android":
@@ -627,7 +643,9 @@ def event_snapshot(id):
event = Event.get(Event.id == id, Event.end_time != None)
event_complete = True
if not event.has_snapshot:
return "Snapshot not available", 404
return make_response(
jsonify({"success": False, "message": "Snapshot not available"}), 404
)
# read snapshot from disk
with open(
os.path.join(CLIPS_DIR, f"{event.camera}-{id}.jpg"), "rb"
@@ -649,12 +667,18 @@ def event_snapshot(id):
quality=request.args.get("quality", default=70, type=int),
)
except Exception:
return "Event not found", 404
return make_response(
jsonify({"success": False, "message": "Event not found"}), 404
)
except Exception:
return "Event not found", 404
return make_response(
jsonify({"success": False, "message": "Event not found"}), 404
)
if jpg_bytes is None:
return "Event not found", 404
return make_response(
jsonify({"success": False, "message": "Event not found"}), 404
)
response = make_response(jpg_bytes)
response.headers["Content-Type"] = "image/jpeg"
@@ -707,10 +731,14 @@ def event_clip(id):
try:
event: Event = Event.get(Event.id == id)
except DoesNotExist:
return "Event not found.", 404
return make_response(
jsonify({"success": False, "message": "Event not found"}), 404
)
if not event.has_clip:
return "Clip not available", 404
return make_response(
jsonify({"success": False, "message": "Clip not available"}), 404
)
file_name = f"{event.camera}-{id}.mp4"
clip_path = os.path.join(CLIPS_DIR, file_name)
@@ -768,11 +796,17 @@ def events():
limit = request.args.get("limit", 100)
after = request.args.get("after", type=float)
before = request.args.get("before", type=float)
time_range = request.args.get("time_range", DEFAULT_TIME_RANGE)
has_clip = request.args.get("has_clip", type=int)
has_snapshot = request.args.get("has_snapshot", type=int)
in_progress = request.args.get("in_progress", type=int)
include_thumbnails = request.args.get("include_thumbnails", default=1, type=int)
favorites = request.args.get("favorites", type=int)
min_score = request.args.get("min_score", type=float)
max_score = request.args.get("max_score", type=float)
is_submitted = request.args.get("is_submitted", type=int)
min_length = request.args.get("min_length", type=float)
max_length = request.args.get("max_length", type=float)
clauses = []
@@ -850,6 +884,36 @@ def events():
if before:
clauses.append((Event.start_time < before))
if time_range != DEFAULT_TIME_RANGE:
# get timezone arg to ensure browser times are used
tz_name = request.args.get("timezone", default="utc", type=str)
hour_modifier, minute_modifier = get_tz_modifiers(tz_name)
times = time_range.split(",")
time_after = times[0]
time_before = times[1]
start_hour_fun = fn.strftime(
"%H:%M",
fn.datetime(Event.start_time, "unixepoch", hour_modifier, minute_modifier),
)
# cases where user wants events overnight, ex: from 20:00 to 06:00
# should use or operator
if time_after > time_before:
clauses.append(
(
reduce(
operator.or_,
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
)
)
)
# all other cases should be and operator
else:
clauses.append((start_hour_fun > time_after))
clauses.append((start_hour_fun < time_before))
if has_clip is not None:
clauses.append((Event.has_clip == has_clip))
@@ -865,6 +929,24 @@ def events():
if favorites:
clauses.append((Event.retain_indefinitely == favorites))
if max_score is not None:
clauses.append((Event.data["score"] <= max_score))
if min_score is not None:
clauses.append((Event.data["score"] >= min_score))
if min_length is not None:
clauses.append(((Event.end_time - Event.start_time) >= min_length))
if max_length is not None:
clauses.append(((Event.end_time - Event.start_time) <= max_length))
if is_submitted is not None:
if is_submitted == 0:
clauses.append((Event.plus_id.is_null()))
else:
clauses.append((Event.plus_id != ""))
if len(clauses) == 0:
clauses.append((True))
@@ -985,7 +1067,9 @@ def config_raw():
config_file = config_file_yaml
if not os.path.isfile(config_file):
return "Could not find file", 410
return make_response(
jsonify({"success": False, "message": "Could not find file"}), 404
)
with open(config_file, "r") as f:
raw_config = f.read()
@@ -1001,7 +1085,12 @@ def config_save():
new_config = request.get_data().decode()
if not new_config:
return "Config with body param is required", 400
return make_response(
jsonify(
{"success": False, "message": "Config with body param is required"}
),
400,
)
# Validate the config schema
try:
@@ -1011,7 +1100,7 @@ def config_save():
jsonify(
{
"success": False,
"message": f"\nConfig Error:\n\n{str(traceback.format_exc())}",
"message": f"\nConfig Error:\n\n{escape(str(traceback.format_exc()))}",
}
),
400,
@@ -1046,14 +1135,30 @@ def config_save():
restart_frigate()
except Exception as e:
logging.error(f"Error restarting Frigate: {e}")
return "Config successfully saved, unable to restart Frigate", 200
return make_response(
jsonify(
{
"success": True,
"message": "Config successfully saved, unable to restart Frigate",
}
),
200,
)
return (
"Config successfully saved, restarting (this can take up to one minute)...",
return make_response(
jsonify(
{
"success": True,
"message": "Config successfully saved, restarting (this can take up to one minute)...",
}
),
200,
)
else:
return "Config successfully saved.", 200
return make_response(
jsonify({"success": True, "message": "Config successfully saved."}),
200,
)
@bp.route("/config/set", methods=["PUT"])
@@ -1093,9 +1198,20 @@ def config_set():
)
except Exception as e:
logging.error(f"Error updating config: {e}")
return "Error updating config", 500
return make_response(
jsonify({"success": False, "message": "Error updating config"}),
500,
)
return "Config successfully updated, restart to apply", 200
return make_response(
jsonify(
{
"success": True,
"message": "Config successfully updated, restart to apply",
}
),
200,
)
@bp.route("/config/schema.json")
@@ -1145,7 +1261,10 @@ def mjpeg_feed(camera_name):
mimetype="multipart/x-mixed-replace; boundary=frame",
)
else:
return "Camera named {} not found".format(camera_name), 404
return make_response(
jsonify({"success": False, "message": "Camera not found"}),
404,
)
@bp.route("/<camera_name>/ptz/info")
@@ -1153,7 +1272,10 @@ def camera_ptz_info(camera_name):
if camera_name in current_app.frigate_config.cameras:
return jsonify(current_app.onvif.get_camera_info(camera_name))
else:
return "Camera named {} not found".format(camera_name), 404
return make_response(
jsonify({"success": False, "message": "Camera not found"}),
404,
)
@bp.route("/<camera_name>/latest.jpg")
@@ -1195,7 +1317,10 @@ def latest_frame(camera_name):
width = int(height * frame.shape[1] / frame.shape[0])
if frame is None:
return "Unable to get valid frame from {}".format(camera_name), 500
return make_response(
jsonify({"success": False, "message": "Unable to get valid frame"}),
500,
)
if height < 1 or width < 1:
return (
@@ -1231,7 +1356,10 @@ def latest_frame(camera_name):
response.headers["Cache-Control"] = "no-store"
return response
else:
return "Camera named {} not found".format(camera_name), 404
return make_response(
jsonify({"success": False, "message": "Camera not found"}),
404,
)
@bp.route("/<camera_name>/recordings/<frame_time>/snapshot.png")
@@ -1246,7 +1374,10 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str):
Recordings.start_time,
)
.where(
((frame_time > Recordings.start_time) & (frame_time < Recordings.end_time))
(
(frame_time >= Recordings.start_time)
& (frame_time <= Recordings.end_time)
)
)
.where(Recordings.camera == camera_name)
)
@@ -1281,7 +1412,15 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str):
response.headers["Content-Type"] = "image/png"
return response
except DoesNotExist:
return "Recording not found for {} at {}".format(camera_name, frame_time), 404
return make_response(
jsonify(
{
"success": False,
"message": "Recording not found at {}".format(frame_time),
}
),
404,
)
@bp.route("/recordings/storage", methods=["GET"])
@@ -1483,7 +1622,15 @@ def recording_clip(camera_name, start_ts, end_ts):
if p.returncode != 0:
logger.error(p.stderr)
return f"Could not create clip from recordings for {camera_name}.", 500
return make_response(
jsonify(
{
"success": False,
"message": "Could not create clip from recordings",
}
),
500,
)
else:
logger.debug(
f"Ignoring subsequent request for {path} as it already exists in the cache."
@@ -1539,7 +1686,15 @@ def vod_ts(camera_name, start_ts, end_ts):
if not clips:
logger.error("No recordings found for the requested time range")
return "No recordings found.", 404
return make_response(
jsonify(
{
"success": False,
"message": "No recordings found.",
}
),
404,
)
hour_ago = datetime.now() - timedelta(hours=1)
return jsonify(
@@ -1582,11 +1737,27 @@ def vod_event(id):
event: Event = Event.get(Event.id == id)
except DoesNotExist:
logger.error(f"Event not found: {id}")
return "Event not found.", 404
return make_response(
jsonify(
{
"success": False,
"message": "Event not found.",
}
),
404,
)
if not event.has_clip:
logger.error(f"Event does not have recordings: {id}")
return "Recordings not available", 404
return make_response(
jsonify(
{
"success": False,
"message": "Recordings not available.",
}
),
404,
)
clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{id}.mp4")
@@ -1646,7 +1817,12 @@ def export_recording(camera_name: str, start_time, end_time):
)
if recordings_count <= 0:
return "No recordings found for time range", 400
return make_response(
jsonify(
{"success": False, "message": "No recordings found for time range"}
),
400,
)
exporter = RecordingExporter(
current_app.frigate_config,
@@ -1658,7 +1834,38 @@ def export_recording(camera_name: str, start_time, end_time):
else PlaybackFactorEnum.realtime,
)
exporter.start()
return "Starting export of recording", 200
return make_response(
jsonify(
{
"success": True,
"message": "Starting export of recording.",
}
),
200,
)
@bp.route("/export/<file_name>", methods=["DELETE"])
def export_delete(file_name: str):
safe_file_name = secure_filename(file_name)
file = os.path.join(EXPORT_DIR, safe_file_name)
if not os.path.exists(file):
return make_response(
jsonify({"success": False, "message": f"{file_name} not found."}),
404,
)
os.unlink(file)
return make_response(
jsonify(
{
"success": True,
"message": "Successfully deleted file.",
}
),
200,
)
def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
@@ -1758,8 +1965,11 @@ def logs(service: str):
}
service_location = log_locations.get(service)
if not service:
return f"{service} is not a valid service", 404
if not service_location:
return make_response(
jsonify({"success": False, "message": "Not a valid service"}),
404,
)
try:
file = open(service_location, "r")
@@ -1767,4 +1977,7 @@ def logs(service: str):
file.close()
return contents, 200
except FileNotFoundError as e:
return f"Could not find log file: {e}", 500
return make_response(
jsonify({"success": False, "message": f"Could not find log file: {e}"}),
500,
)

View File

@@ -20,3 +20,7 @@ class MotionDetector(ABC):
@abstractmethod
def detect(self, frame):
pass
@abstractmethod
def is_calibrating(self):
pass

View File

@@ -38,6 +38,9 @@ class FrigateMotionDetector(MotionDetector):
self.threshold = threshold
self.contour_area = contour_area
def is_calibrating(self):
return False
def detect(self, frame):
motion_boxes = []

View File

@@ -49,6 +49,9 @@ class ImprovedMotionDetector(MotionDetector):
self.contrast_values[:, 1:2] = 255
self.contrast_values_index = 0
def is_calibrating(self):
return self.calibrating
def detect(self, frame):
motion_boxes = []
@@ -141,7 +144,6 @@ class ImprovedMotionDetector(MotionDetector):
# if calibrating or the motion contours are > 80% of the image area (lightning, ir, ptz) recalibrate
if self.calibrating or pct_motion > self.config.lightning_threshold:
motion_boxes = []
self.calibrating = True
if self.save_images:

View File

@@ -232,6 +232,9 @@ class TrackedObject:
if self.obj_data["position_changes"] != obj_data["position_changes"]:
significant_change = True
if self.obj_data["attributes"] != obj_data["attributes"]:
significant_change = True
# if the motionless_count reaches the stationary threshold
if (
self.obj_data["motionless_count"]

View File

@@ -33,7 +33,7 @@ from frigate.util.image import (
logger = logging.getLogger(__name__)
def get_standard_aspect_ratio(width, height) -> tuple[int, int]:
def get_standard_aspect_ratio(width: int, height: int) -> tuple[int, int]:
"""Ensure that only standard aspect ratios are used."""
known_aspects = [
(16, 9),
@@ -52,6 +52,22 @@ def get_standard_aspect_ratio(width, height) -> tuple[int, int]:
return known_aspects[known_aspects_ratios.index(closest)]
def get_canvas_shape(width: int, height: int) -> tuple[int, int]:
"""Get birdseye canvas shape."""
canvas_width = width
canvas_height = height
a_w, a_h = get_standard_aspect_ratio(width, height)
if round(a_w / a_h, 2) != round(width / height, 2):
canvas_width = width
canvas_height = int((canvas_width / a_w) * a_h)
logger.warning(
f"The birdseye resolution is a non-standard aspect ratio, forcing birdseye resolution to {canvas_width} x {canvas_height}"
)
return (canvas_width, canvas_height)
class Canvas:
def __init__(self, canvas_width: int, canvas_height: int) -> None:
gcd = math.gcd(canvas_width, canvas_height)
@@ -226,8 +242,7 @@ class BirdsEyeFrameManager:
self.config = config
self.mode = config.birdseye.mode
self.frame_manager = frame_manager
width = config.birdseye.width
height = config.birdseye.height
width, height = get_canvas_shape(config.birdseye.width, config.birdseye.height)
self.frame_shape = (height, width)
self.yuv_shape = (height * 3 // 2, width)
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
@@ -580,7 +595,7 @@ class BirdsEyeFrameManager:
except Exception:
updated_frame = False
self.active_cameras = []
self.camera_layout = 0
self.camera_layout = []
print(traceback.format_exc())
# if the frame was updated or the fps is too low, send frame

View File

@@ -2,7 +2,7 @@
import copy
import logging
import math
import os
import queue
import threading
import time
@@ -11,11 +11,17 @@ from multiprocessing.synchronize import Event as MpEvent
import cv2
import numpy as np
from norfair.camera_motion import MotionEstimator, TranslationTransformationGetter
from norfair.camera_motion import (
HomographyTransformationGetter,
MotionEstimator,
TranslationTransformationGetter,
)
from frigate.config import CameraConfig, FrigateConfig
from frigate.config import CameraConfig, FrigateConfig, ZoomingModeEnum
from frigate.const import CONFIG_DIR
from frigate.ptz.onvif import OnvifController
from frigate.types import PTZMetricsTypes
from frigate.util.builtin import update_yaml_file
from frigate.util.image import SharedMemoryFrameManager, intersection_over_union
logger = logging.getLogger(__name__)
@@ -26,12 +32,8 @@ def ptz_moving_at_frame_time(frame_time, ptz_start_time, ptz_stop_time):
# for non ptz/autotracking cameras, this will always return False
# ptz_start_time is initialized to 0 on startup and only changes
# when autotracking movements are made
# the offset "primes" the motion estimator with a few frames before movement
offset = 0.5
return (ptz_start_time != 0.0 and frame_time >= ptz_start_time - offset) and (
ptz_stop_time == 0.0 or (ptz_start_time - offset <= frame_time <= ptz_stop_time)
return (ptz_start_time != 0.0 and frame_time > ptz_start_time) and (
ptz_stop_time == 0.0 or (ptz_start_time <= frame_time <= ptz_stop_time)
)
@@ -54,13 +56,24 @@ class PtzMotionEstimator:
# If we've just started up or returned to our preset, reset motion estimator for new tracking session
if self.ptz_metrics["ptz_reset"].is_set():
self.ptz_metrics["ptz_reset"].clear()
logger.debug("Motion estimator reset")
# homography is nice (zooming) but slow, translation is pan/tilt only but fast.
if (
self.camera_config.onvif.autotracking.zooming
!= ZoomingModeEnum.disabled
):
logger.debug("Motion estimator reset - homography")
transformation_type = HomographyTransformationGetter()
else:
logger.debug("Motion estimator reset - translation")
transformation_type = TranslationTransformationGetter()
self.norfair_motion_estimator = MotionEstimator(
transformations_getter=TranslationTransformationGetter(),
transformations_getter=transformation_type,
min_distance=30,
max_points=900,
)
self.coord_transformations = None
if ptz_moving_at_frame_time(
@@ -91,16 +104,22 @@ class PtzMotionEstimator:
# Norfair estimator function needs color so it can convert it right back to gray
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGRA)
self.coord_transformations = self.norfair_motion_estimator.update(
frame, mask
)
try:
self.coord_transformations = self.norfair_motion_estimator.update(
frame, mask
)
logger.debug(
f"Motion estimator transformation: {self.coord_transformations.rel_to_abs([[0,0]])}"
)
except Exception:
# sometimes opencv can't find enough features in the image to find homography, so catch this error
logger.warning(
f"Autotracker: motion estimator couldn't get transformations for {camera_name} at frame time {frame_time}"
)
self.coord_transformations = None
self.frame_manager.close(frame_id)
logger.debug(
f"Motion estimator transformation: {self.coord_transformations.rel_to_abs((0,0))}"
)
return self.coord_transformations
@@ -121,6 +140,9 @@ class PtzAutoTrackerThread(threading.Thread):
def run(self):
while not self.stop_event.wait(1):
for camera_name, cam in self.config.cameras.items():
if not cam.enabled:
continue
if cam.onvif.autotracking.enabled:
self.ptz_autotracker.camera_maintenance(camera_name)
else:
@@ -144,15 +166,24 @@ class PtzAutoTracker:
self.ptz_metrics = ptz_metrics
self.tracked_object: dict[str, object] = {}
self.tracked_object_previous: dict[str, object] = {}
self.previous_frame_time = None
self.object_types = {}
self.required_zones = {}
self.move_queues = {}
self.move_threads = {}
self.autotracker_init = {}
self.previous_frame_time: dict[str, object] = {}
self.object_types: dict[str, object] = {}
self.required_zones: dict[str, object] = {}
self.move_queues: dict[str, object] = {}
self.move_queue_locks: dict[str, object] = {}
self.move_threads: dict[str, object] = {}
self.autotracker_init: dict[str, object] = {}
self.move_metrics: dict[str, object] = {}
self.calibrating: dict[str, object] = {}
self.intercept: dict[str, object] = {}
self.move_coefficients: dict[str, object] = {}
self.zoom_factor: dict[str, object] = {}
# if cam is set to autotrack, onvif should be set up
for camera_name, cam in self.config.cameras.items():
if not cam.enabled:
continue
self.autotracker_init[camera_name] = False
if cam.onvif.autotracking.enabled:
self._autotracker_setup(cam, camera_name)
@@ -162,11 +193,18 @@ class PtzAutoTracker:
self.object_types[camera_name] = cam.onvif.autotracking.track
self.required_zones[camera_name] = cam.onvif.autotracking.required_zones
self.zoom_factor[camera_name] = cam.onvif.autotracking.zoom_factor
self.tracked_object[camera_name] = None
self.tracked_object_previous[camera_name] = None
self.calibrating[camera_name] = False
self.move_metrics[camera_name] = []
self.intercept[camera_name] = None
self.move_coefficients[camera_name] = []
self.move_queues[camera_name] = queue.Queue()
self.move_queue_locks[camera_name] = threading.Lock()
if not self.onvif.cams[camera_name]["init"]:
if not self.onvif._init_onvif(camera_name):
@@ -176,7 +214,7 @@ class PtzAutoTracker:
return
if not self.onvif.cams[camera_name]["relative_fov_supported"]:
if "pt-r-fov" not in self.onvif.cams[camera_name]["features"]:
cam.onvif.autotracking.enabled = False
self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value = False
logger.warning(
@@ -185,22 +223,166 @@ class PtzAutoTracker:
return
# movement thread per camera
if not self.move_threads or not self.move_threads[camera_name]:
self.move_threads[camera_name] = threading.Thread(
name=f"move_thread_{camera_name}",
target=partial(self._process_move_queue, camera_name),
movestatus_supported = self.onvif.get_service_capabilities(camera_name)
if movestatus_supported is None or movestatus_supported.lower() != "true":
cam.onvif.autotracking.enabled = False
self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value = False
logger.warning(
f"Disabling autotracking for {camera_name}: ONVIF MoveStatus not supported"
)
self.move_threads[camera_name].daemon = True
self.move_threads[camera_name].start()
return
self.onvif.get_camera_status(camera_name)
# movement thread per camera
self.move_threads[camera_name] = threading.Thread(
name=f"move_thread_{camera_name}",
target=partial(self._process_move_queue, camera_name),
)
self.move_threads[camera_name].daemon = True
self.move_threads[camera_name].start()
if cam.onvif.autotracking.movement_weights:
self.intercept[camera_name] = cam.onvif.autotracking.movement_weights[0]
self.move_coefficients[
camera_name
] = cam.onvif.autotracking.movement_weights[1:]
if cam.onvif.autotracking.calibrate_on_startup:
self._calibrate_camera(camera_name)
self.autotracker_init[camera_name] = True
def write_config(self, camera):
config_file = os.environ.get("CONFIG_FILE", f"{CONFIG_DIR}/config.yml")
logger.debug(
f"Writing new config with autotracker motion coefficients: {self.config.cameras[camera].onvif.autotracking.movement_weights}"
)
update_yaml_file(
config_file,
["cameras", camera, "onvif", "autotracking", "movement_weights"],
self.config.cameras[camera].onvif.autotracking.movement_weights,
)
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)
# TODO: take zooming into account too
num_steps = 30
step_sizes = np.linspace(0, 1, num_steps)
self.calibrating[camera] = True
logger.info(f"Camera calibration for {camera} in progress")
self.onvif._move_to_preset(
camera,
self.config.cameras[camera].onvif.autotracking.return_preset.lower(),
)
self.ptz_metrics[camera]["ptz_reset"].set()
self.ptz_metrics[camera]["ptz_stopped"].clear()
# Wait until the camera finishes moving
while not self.ptz_metrics[camera]["ptz_stopped"].is_set():
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)
# Wait until the camera finishes moving
while not self.ptz_metrics[camera]["ptz_stopped"].is_set():
self.onvif.get_camera_status(camera)
stop_time = time.time()
self.move_metrics[camera].append(
{
"pan": pan,
"tilt": tilt,
"start_timestamp": start_time,
"end_timestamp": stop_time,
}
)
self.onvif._move_to_preset(
camera,
self.config.cameras[camera].onvif.autotracking.return_preset.lower(),
)
self.ptz_metrics[camera]["ptz_reset"].set()
self.ptz_metrics[camera]["ptz_stopped"].clear()
# Wait until the camera finishes moving
while not self.ptz_metrics[camera]["ptz_stopped"].is_set():
self.onvif.get_camera_status(camera)
self.calibrating[camera] = False
logger.info(f"Calibration for {camera} complete")
# calculate and save new intercept and coefficients
self._calculate_move_coefficients(camera, True)
def _calculate_move_coefficients(self, camera, calibration=False):
# calculate new coefficients when we have 50 more new values. Save up to 500
if calibration or (
len(self.move_metrics[camera]) % 50 == 0
and len(self.move_metrics[camera]) != 0
and len(self.move_metrics[camera]) <= 500
):
X = np.array(
[abs(d["pan"]) + abs(d["tilt"]) for d in self.move_metrics[camera]]
)
y = np.array(
[
d["end_timestamp"] - d["start_timestamp"]
for d in self.move_metrics[camera]
]
)
# simple linear regression with intercept
X_with_intercept = np.column_stack((np.ones(X.shape[0]), X))
self.move_coefficients[camera] = np.linalg.lstsq(
X_with_intercept, y, rcond=None
)[0]
# only assign a new intercept if we're calibrating
if calibration:
self.intercept[camera] = y[0]
# write the intercept and coefficients back to the config file as a comma separated string
movement_weights = np.concatenate(
([self.intercept[camera]], self.move_coefficients[camera])
)
self.config.cameras[camera].onvif.autotracking.movement_weights = ", ".join(
map(str, movement_weights)
)
logger.debug(
f"New regression parameters - intercept: {self.intercept[camera]}, coefficients: {self.move_coefficients[camera]}"
)
self.write_config(camera)
def _predict_movement_time(self, camera, pan, tilt):
combined_movement = abs(pan) + abs(tilt)
input_data = np.array([self.intercept[camera], combined_movement])
return np.dot(self.move_coefficients[camera], input_data)
def _process_move_queue(self, camera):
while True:
try:
move_data = self.move_queues[camera].get()
frame_time, pan, tilt = move_data
move_data = self.move_queues[camera].get()
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
if ptz_moving_at_frame_time(
@@ -211,50 +393,234 @@ class PtzAutoTracker:
# instead of dequeueing this might be a good place to preemptively move based
# on an estimate - for fast moving objects, etc.
logger.debug(
f"Move queue: PTZ moving, dequeueing move request - frame time: {frame_time}, final pan: {pan}, final tilt: {tilt}"
f"Move queue: PTZ moving, dequeueing move request - frame time: {frame_time}, final pan: {pan}, final tilt: {tilt}, final zoom: {zoom}"
)
continue
else:
# on some cameras with cheaper motors it seems like small values can cause jerky movement
# TODO: double check, might not need this
if abs(pan) > 0.02 or abs(tilt) > 0.02:
self.onvif._move_relative(camera, pan, tilt, 1)
if (
self.config.cameras[camera].onvif.autotracking.zooming
== ZoomingModeEnum.relative
):
self.onvif._move_relative(camera, pan, tilt, zoom, 1)
else:
logger.debug(
f"Not moving, pan and tilt too small: {pan}, {tilt}"
)
if zoom > 0:
self.onvif._zoom_absolute(camera, zoom, 1)
else:
self.onvif._move_relative(camera, pan, tilt, 0, 1)
# Wait until the camera finishes moving
while not self.ptz_metrics[camera]["ptz_stopped"].is_set():
# check if ptz is moving
self.onvif.get_camera_status(camera)
except queue.Empty:
continue
if self.config.cameras[camera].onvif.autotracking.movement_weights:
logger.debug(
f"Predicted movement time: {self._predict_movement_time(camera, pan, tilt)}"
)
logger.debug(
f'Actual movement time: {self.ptz_metrics[camera]["ptz_stop_time"].value-self.ptz_metrics[camera]["ptz_start_time"].value}'
)
# save metrics for better estimate calculations
if (
self.intercept[camera] is not None
and len(self.move_metrics[camera]) < 500
):
logger.debug("Adding new values to move metrics")
self.move_metrics[camera].append(
{
"pan": pan,
"tilt": tilt,
"start_timestamp": self.ptz_metrics[camera][
"ptz_start_time"
].value,
"end_timestamp": self.ptz_metrics[camera][
"ptz_stop_time"
].value,
}
)
# calculate new coefficients if we have enough data
self._calculate_move_coefficients(camera)
def _enqueue_move(self, camera, frame_time, pan, tilt, zoom):
def split_value(value):
clipped = np.clip(value, -1, 1)
return clipped, value - clipped
def _enqueue_move(self, camera, frame_time, pan, tilt):
move_data = (frame_time, pan, tilt)
if (
frame_time > self.ptz_metrics[camera]["ptz_start_time"].value
and frame_time > self.ptz_metrics[camera]["ptz_stop_time"].value
and not self.move_queue_locks[camera].locked()
):
logger.debug(f"enqueue pan: {pan}, enqueue tilt: {tilt}")
self.move_queues[camera].put(move_data)
# don't make small movements
if abs(pan) < 0.02:
pan = 0
if abs(tilt) < 0.02:
tilt = 0
# split up any large moves caused by velocity estimated movements
while pan != 0 or tilt != 0 or zoom != 0:
pan, pan_excess = split_value(pan)
tilt, tilt_excess = split_value(tilt)
zoom, zoom_excess = split_value(zoom)
logger.debug(
f"Enqueue movement for frame time: {frame_time} pan: {pan}, enqueue tilt: {tilt}, enqueue zoom: {zoom}"
)
move_data = (frame_time, pan, tilt, zoom)
self.move_queues[camera].put(move_data)
pan = pan_excess
tilt = tilt_excess
zoom = zoom_excess
def _should_zoom_in(self, camera, box, area, average_velocity):
camera_config = self.config.cameras[camera]
camera_width = camera_config.frame_shape[1]
camera_height = camera_config.frame_shape[0]
camera_area = camera_width * camera_height
bb_left, bb_top, bb_right, bb_bottom = box
# If bounding box is not within 5% of an edge
# If object area is less than 70% of frame
# Then zoom in, otherwise try zooming out
# should we make these configurable?
#
# TODO: Take into account the area changing when an object is moving out of frame
edge_threshold = 0.15
area_threshold = self.zoom_factor[camera]
velocity_threshold = 0.1
# if we have a fast moving object, let's zoom out
# fast moving is defined as a velocity of more than 10% of the camera's width or height
# so an object with an x velocity of 15 pixels on a 1280x720 camera would trigger a zoom out
velocity_threshold = average_velocity[0] > (
camera_width * velocity_threshold
) or average_velocity[1] > (camera_height * velocity_threshold)
# returns True to zoom in, False to zoom out
return (
bb_left > edge_threshold * camera_width
and bb_right < (1 - edge_threshold) * camera_width
and bb_top > edge_threshold * camera_height
and bb_bottom < (1 - edge_threshold) * camera_height
and area < area_threshold * camera_area
and not velocity_threshold
)
def _autotrack_move_ptz(self, camera, obj):
camera_config = self.config.cameras[camera]
average_velocity = (0,) * 4
# # frame width and height
camera_width = camera_config.frame_shape[1]
camera_height = camera_config.frame_shape[0]
camera_fps = camera_config.detect.fps
centroid_x = obj.obj_data["centroid"][0]
centroid_y = obj.obj_data["centroid"][1]
# Normalize coordinates. top right of the fov is (1,1), center is (0,0), bottom left is (-1, -1).
pan = ((obj.obj_data["centroid"][0] / camera_width) - 0.5) * 2
tilt = (0.5 - (obj.obj_data["centroid"][1] / camera_height)) * 2
pan = ((centroid_x / camera_width) - 0.5) * 2
tilt = (0.5 - (centroid_y / camera_height)) * 2
# ideas: check object velocity for camera speed?
self._enqueue_move(camera, obj.obj_data["frame_time"], pan, tilt)
if (
camera_config.onvif.autotracking.movement_weights
): # use estimates if we have available coefficients
predicted_movement_time = self._predict_movement_time(camera, pan, tilt)
# Norfair gives us two points for the velocity of an object represented as x1, y1, x2, y2
x1, y1, x2, y2 = obj.obj_data["estimate_velocity"]
average_velocity = (
(x1 + x2) / 2,
(y1 + y2) / 2,
(x1 + x2) / 2,
(y1 + y2) / 2,
)
# get euclidean distance of the two points, sometimes the estimate is way off
distance = np.linalg.norm([x2 - x1, y2 - y1])
if distance <= 5:
# this box could exceed the frame boundaries if velocity is high
# but we'll handle that in _enqueue_move() as two separate moves
predicted_box = [
round(x + camera_fps * predicted_movement_time * v)
for x, v in zip(obj.obj_data["box"], average_velocity)
]
else:
# estimate was bad
predicted_box = obj.obj_data["box"]
centroid_x = round((predicted_box[0] + predicted_box[2]) / 2)
centroid_y = round((predicted_box[1] + predicted_box[3]) / 2)
# recalculate pan and tilt with new centroid
pan = ((centroid_x / camera_width) - 0.5) * 2
tilt = (0.5 - (centroid_y / camera_height)) * 2
logger.debug(f'Original box: {obj.obj_data["box"]}')
logger.debug(f"Predicted box: {predicted_box}")
logger.debug(f'Velocity: {obj.obj_data["estimate_velocity"]}')
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.relative:
# relative zooming concurrently with pan/tilt
zoom = min(
obj.obj_data["area"]
/ (camera_width * camera_height)
* 100
* self.zoom_factor[camera],
1,
)
logger.debug(f"Zoom value: {zoom}")
# test if we need to zoom out
if not self._should_zoom_in(
camera,
predicted_box
if camera_config.onvif.autotracking.movement_weights
else obj.obj_data["box"],
obj.obj_data["area"],
average_velocity,
):
zoom = -(1 - zoom)
# don't make small movements to zoom in if area hasn't changed significantly
# but always zoom out if necessary
if (
"area" in obj.previous
and abs(obj.obj_data["area"] - obj.previous["area"])
/ obj.obj_data["area"]
< 0.2
and zoom > 0
):
zoom = 0
else:
zoom = 0
self._enqueue_move(camera, obj.obj_data["frame_time"], pan, tilt, zoom)
def _autotrack_zoom_only(self, camera, obj):
camera_config = self.config.cameras[camera]
# absolute zooming separately from pan/tilt
if camera_config.onvif.autotracking.zooming == ZoomingModeEnum.absolute:
zoom_level = self.ptz_metrics[camera]["ptz_zoom_level"].value
if 0 < zoom_level <= 1:
if self._should_zoom_in(
camera, obj.obj_data["box"], obj.obj_data["area"], (0, 0, 0, 0)
):
zoom = min(1.0, zoom_level + 0.1)
else:
zoom = max(0.0, zoom_level - 0.1)
if zoom != zoom_level:
self._enqueue_move(camera, obj.obj_data["frame_time"], 0, 0, zoom)
def autotrack_object(self, camera, obj):
camera_config = self.config.cameras[camera]
@@ -263,6 +629,10 @@ class PtzAutoTracker:
if not self.autotracker_init[camera]:
self._autotracker_setup(self.config.cameras[camera], camera)
if self.calibrating[camera]:
logger.debug("Calibrating camera")
return
# either this is a brand new object that's on our camera, has our label, entered the zone, is not a false positive,
# and is not initially motionless - or one we're already tracking, which assumes all those things are already true
if (
@@ -281,7 +651,7 @@ class PtzAutoTracker:
)
self.tracked_object[camera] = obj
self.tracked_object_previous[camera] = copy.deepcopy(obj)
self.previous_frame_time = obj.obj_data["frame_time"]
self.previous_frame_time[camera] = obj.obj_data["frame_time"]
self._autotrack_move_ptz(camera, obj)
return
@@ -293,7 +663,7 @@ class PtzAutoTracker:
and obj.obj_data["id"] == self.tracked_object[camera].obj_data["id"]
and obj.obj_data["frame_time"] != self.previous_frame_time
):
self.previous_frame_time = obj.obj_data["frame_time"]
self.previous_frame_time[camera] = obj.obj_data["frame_time"]
# Don't move ptz if Euclidean distance from object to center of frame is
# less than 15% of the of the larger dimension (width or height) of the frame,
# multiplied by a scaling factor for object size.
@@ -301,10 +671,11 @@ class PtzAutoTracker:
# more often to keep the object in the center. Raising the percentage will cause less
# movement and will be more flexible with objects not quite being centered.
# TODO: there's probably a better way to approach this
distance = math.sqrt(
(obj.obj_data["centroid"][0] - camera_config.detect.width / 2) ** 2
+ (obj.obj_data["centroid"][1] - camera_config.detect.height / 2)
** 2
distance = np.linalg.norm(
[
obj.obj_data["centroid"][0] - camera_config.detect.width / 2,
obj.obj_data["centroid"][1] - camera_config.detect.height / 2,
]
)
obj_width = obj.obj_data["box"][2] - obj.obj_data["box"][0]
@@ -331,6 +702,10 @@ class PtzAutoTracker:
logger.debug(
f"Autotrack: Existing object (do NOT move ptz): {obj.obj_data['id']} {obj.obj_data['box']} {obj.obj_data['frame_time']}"
)
# no need to move, but try absolute zooming
self._autotrack_zoom_only(camera, obj)
return
logger.debug(
@@ -339,6 +714,9 @@ class PtzAutoTracker:
self.tracked_object_previous[camera] = copy.deepcopy(obj)
self._autotrack_move_ptz(camera, obj)
# try absolute zooming too
self._autotrack_zoom_only(camera, obj)
return
if (
@@ -350,10 +728,9 @@ class PtzAutoTracker:
and obj.obj_data["label"] in self.object_types[camera]
and not obj.previous["false_positive"]
and not obj.false_positive
and obj.obj_data["motionless_count"] == 0
and self.tracked_object_previous[camera] is not None
):
self.previous_frame_time = obj.obj_data["frame_time"]
self.previous_frame_time[camera] = obj.obj_data["frame_time"]
if (
intersection_over_union(
self.tracked_object_previous[camera].obj_data["region"],
@@ -382,6 +759,12 @@ class PtzAutoTracker:
self.tracked_object[camera] = None
def camera_maintenance(self, camera):
# bail and don't check anything if we're calibrating or tracking an object
if self.calibrating[camera] or self.tracked_object[camera] is not None:
return
logger.debug("Running camera maintenance")
# calls get_camera_status to check/update ptz movement
# returns camera to preset after timeout when tracking is over
autotracker_config = self.config.cameras[camera].onvif.autotracking
@@ -398,19 +781,26 @@ class PtzAutoTracker:
and self.tracked_object_previous[camera] is not None
and (
# might want to use a different timestamp here?
time.time()
self.ptz_metrics[camera]["ptz_frame_time"].value
- self.tracked_object_previous[camera].obj_data["frame_time"]
> autotracker_config.timeout
)
and autotracker_config.return_preset
):
# empty move queue
while not self.move_queues[camera].empty():
self.move_queues[camera].get()
# clear tracked object
self.tracked_object[camera] = None
self.tracked_object_previous[camera] = None
self.ptz_metrics[camera]["ptz_stopped"].wait()
logger.debug(
f"Autotrack: Time is {time.time()}, returning to preset: {autotracker_config.return_preset}"
f"Autotrack: Time is {self.ptz_metrics[camera]['ptz_frame_time'].value}, returning to preset: {autotracker_config.return_preset}"
)
self.onvif._move_to_preset(
camera,
autotracker_config.return_preset.lower(),
)
self.ptz_metrics[camera]["ptz_reset"].set()
self.tracked_object_previous[camera] = None

View File

@@ -1,6 +1,5 @@
"""Configure and control camera via onvif."""
import datetime
import logging
import site
from enum import Enum
@@ -8,8 +7,9 @@ from enum import Enum
import numpy
from onvif import ONVIFCamera, ONVIFError
from frigate.config import FrigateConfig
from frigate.config import FrigateConfig, ZoomingModeEnum
from frigate.types import PTZMetricsTypes
from frigate.util.builtin import find_by_key
logger = logging.getLogger(__name__)
@@ -33,6 +33,7 @@ class OnvifController:
self, config: FrigateConfig, ptz_metrics: dict[str, PTZMetricsTypes]
) -> None:
self.cams: dict[str, ONVIFCamera] = {}
self.config = config
self.ptz_metrics = ptz_metrics
for cam_name, cam in config.cameras.items():
@@ -73,11 +74,20 @@ class OnvifController:
return False
ptz = onvif.create_ptz_service()
request = ptz.create_type("GetConfigurations")
configs = ptz.GetConfigurations(request)[0]
request = ptz.create_type("GetConfigurationOptions")
request.ConfigurationToken = profile.PTZConfiguration.token
ptz_config = ptz.GetConfigurationOptions(request)
logger.debug(f"Onvif config for {camera_name}: {ptz_config}")
service_capabilities_request = ptz.create_type("GetServiceCapabilities")
self.cams[camera_name][
"service_capabilities_request"
] = service_capabilities_request
fov_space_id = next(
(
i
@@ -89,6 +99,20 @@ class OnvifController:
None,
)
# autoracking relative panning/tilting needs a relative zoom value set to 0
# if camera supports relative movement
if self.config.cameras[camera_name].onvif.autotracking.zooming:
zoom_space_id = next(
(
i
for i, space in enumerate(
ptz_config.Spaces.RelativeZoomTranslationSpace
)
if "TranslationGenericSpace" in space["URI"]
),
None,
)
# setup continuous moving request
move_request = ptz.create_type("ContinuousMove")
move_request.ProfileToken = profile.token
@@ -105,19 +129,33 @@ class OnvifController:
"RelativePanTiltTranslationSpace"
][fov_space_id]["URI"]
# try setting relative zoom translation space
try:
move_request.Translation.Zoom.space = ptz_config["Spaces"][
"RelativeZoomTranslationSpace"
][0]["URI"]
if (
self.config.cameras[camera_name].onvif.autotracking.zooming
== ZoomingModeEnum.relative
):
if zoom_space_id is not None:
move_request.Translation.Zoom.space = ptz_config["Spaces"][
"RelativeZoomTranslationSpace"
][0]["URI"]
except Exception:
# camera does not support relative zoom
pass
if (
self.config.cameras[camera_name].onvif.autotracking.zooming
== ZoomingModeEnum.relative
):
self.config.cameras[
camera_name
].onvif.autotracking.zooming = ZoomingModeEnum.disabled
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported"
)
if move_request.Speed is None:
move_request.Speed = ptz.GetStatus({"ProfileToken": profile.token}).Position
self.cams[camera_name]["relative_move_request"] = move_request
# setup relative moving request for autotracking
# setup absolute moving request for autotracking zooming
move_request = ptz.create_type("AbsoluteMove")
move_request.ProfileToken = profile.token
self.cams[camera_name]["absolute_move_request"] = move_request
@@ -126,6 +164,8 @@ class OnvifController:
status_request = ptz.create_type("GetStatus")
status_request.ProfileToken = profile.token
self.cams[camera_name]["status_request"] = status_request
status = ptz.GetStatus(status_request)
logger.debug(f"Onvif status config for {camera_name}: {status}")
# setup existing presets
try:
@@ -135,7 +175,9 @@ class OnvifController:
presets = []
for preset in presets:
self.cams[camera_name]["presets"][preset["Name"].lower()] = preset["token"]
self.cams[camera_name]["presets"][
getattr(preset, "Name", f"preset {preset['token']}").lower()
] = preset["token"]
# get list of supported features
ptz_config = ptz.GetConfigurationOptions(request)
@@ -153,14 +195,28 @@ class OnvifController:
if ptz_config.Spaces and ptz_config.Spaces.RelativeZoomTranslationSpace:
supported_features.append("zoom-r")
if ptz_config.Spaces and ptz_config.Spaces.AbsoluteZoomPositionSpace:
supported_features.append("zoom-a")
try:
# get camera's zoom limits from onvif config
self.cams[camera_name][
"absolute_zoom_range"
] = ptz_config.Spaces.AbsoluteZoomPositionSpace[0]
self.cams[camera_name]["zoom_limits"] = configs.ZoomLimits
except Exception:
if self.config.cameras[camera_name].onvif.autotracking.zooming:
self.config.cameras[camera_name].onvif.autotracking.zooming = False
logger.warning(
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported"
)
# set relative pan/tilt space for autotracker
if fov_space_id is not None:
supported_features.append("pt-r-fov")
self.cams[camera_name][
"relative_fov_range"
] = ptz_config.Spaces.RelativePanTiltTranslationSpace[fov_space_id]
self.cams[camera_name]["relative_fov_supported"] = fov_space_id is not None
self.cams[camera_name]["features"] = supported_features
self.cams[camera_name]["init"] = True
@@ -210,8 +266,8 @@ class OnvifController:
onvif.get_service("ptz").ContinuousMove(move_request)
def _move_relative(self, camera_name: str, pan, tilt, speed) -> None:
if not self.cams[camera_name]["relative_fov_supported"]:
def _move_relative(self, camera_name: str, pan, tilt, zoom, speed) -> None:
if "pt-r-fov" not in self.cams[camera_name]["features"]:
logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
return
@@ -225,10 +281,12 @@ class OnvifController:
self.cams[camera_name]["active"] = True
self.ptz_metrics[camera_name]["ptz_stopped"].clear()
logger.debug(f"PTZ start time: {datetime.datetime.now().timestamp()}")
self.ptz_metrics[camera_name][
"ptz_start_time"
].value = datetime.datetime.now().timestamp()
logger.debug(
f"PTZ start time: {self.ptz_metrics[camera_name]['ptz_frame_time'].value}"
)
self.ptz_metrics[camera_name]["ptz_start_time"].value = self.ptz_metrics[
camera_name
]["ptz_frame_time"].value
self.ptz_metrics[camera_name]["ptz_stop_time"].value = 0
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
move_request = self.cams[camera_name]["relative_move_request"]
@@ -257,15 +315,30 @@ class OnvifController:
"x": speed,
"y": speed,
},
"Zoom": 0,
}
move_request.Translation.PanTilt.x = pan
move_request.Translation.PanTilt.y = tilt
move_request.Translation.Zoom.x = 0
if "zoom-r" in self.cams[camera_name]["features"]:
move_request.Speed = {
"PanTilt": {
"x": speed,
"y": speed,
},
"Zoom": {"x": speed},
}
move_request.Translation.Zoom.x = zoom
onvif.get_service("ptz").RelativeMove(move_request)
# reset after the move request
move_request.Translation.PanTilt.x = 0
move_request.Translation.PanTilt.y = 0
if "zoom-r" in self.cams[camera_name]["features"]:
move_request.Translation.Zoom.x = 0
self.cams[camera_name]["active"] = False
def _move_to_preset(self, camera_name: str, preset: str) -> None:
@@ -305,6 +378,50 @@ class OnvifController:
onvif.get_service("ptz").ContinuousMove(move_request)
def _zoom_absolute(self, camera_name: str, zoom, speed) -> None:
if "zoom-a" not in self.cams[camera_name]["features"]:
logger.error(f"{camera_name} does not support ONVIF AbsoluteMove zooming.")
return
logger.debug(f"{camera_name} called AbsoluteMove: zoom: {zoom}")
if self.cams[camera_name]["active"]:
logger.warning(
f"{camera_name} is already performing an action, not moving..."
)
return
self.cams[camera_name]["active"] = True
self.ptz_metrics[camera_name]["ptz_stopped"].clear()
logger.debug(
f"PTZ start time: {self.ptz_metrics[camera_name]['ptz_frame_time'].value}"
)
self.ptz_metrics[camera_name]["ptz_start_time"].value = self.ptz_metrics[
camera_name
]["ptz_frame_time"].value
self.ptz_metrics[camera_name]["ptz_stop_time"].value = 0
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
move_request = self.cams[camera_name]["absolute_move_request"]
# function takes in 0 to 1 for zoom, interpolate to the values of the camera.
zoom = numpy.interp(
zoom,
[0, 1],
[
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
],
)
move_request.Speed = {"Zoom": speed}
move_request.Position = {"Zoom": zoom}
logger.debug(f"Absolute zoom: {zoom}")
onvif.get_service("ptz").AbsoluteMove(move_request)
self.cams[camera_name]["active"] = False
def handle_command(
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
) -> None:
@@ -344,7 +461,30 @@ class OnvifController:
"presets": list(self.cams[camera_name]["presets"].keys()),
}
def get_camera_status(self, camera_name: str) -> dict[str, any]:
def get_service_capabilities(self, camera_name: str) -> None:
if camera_name not in self.cams.keys():
logger.error(f"Onvif is not setup for {camera_name}")
return {}
if not self.cams[camera_name]["init"]:
self._init_onvif(camera_name)
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
service_capabilities_request = self.cams[camera_name][
"service_capabilities_request"
]
service_capabilities = onvif.get_service("ptz").GetServiceCapabilities(
service_capabilities_request
)
logger.debug(
f"Onvif service capabilities for {camera_name}: {service_capabilities}"
)
# MoveStatus is required for autotracking - should return "true" if supported
return find_by_key(vars(service_capabilities), "MoveStatus")
def get_camera_status(self, camera_name: str) -> None:
if camera_name not in self.cams.keys():
logger.error(f"Onvif is not setup for {camera_name}")
return {}
@@ -356,32 +496,66 @@ class OnvifController:
status_request = self.cams[camera_name]["status_request"]
status = onvif.get_service("ptz").GetStatus(status_request)
if status.MoveStatus.PanTilt == "IDLE" and status.MoveStatus.Zoom == "IDLE":
# there doesn't seem to be an onvif standard with this optional parameter
# some cameras can report MoveStatus with or without PanTilt or Zoom attributes
pan_tilt_status = getattr(status.MoveStatus, "PanTilt", None)
zoom_status = getattr(status.MoveStatus, "Zoom", None)
# if it's not an attribute, see if MoveStatus even exists in the status result
if pan_tilt_status is None:
pan_tilt_status = getattr(status, "MoveStatus", None)
# we're unsupported
if pan_tilt_status is None or pan_tilt_status.lower() not in [
"idle",
"moving",
]:
logger.error(
f"Camera {camera_name} does not support the ONVIF GetStatus method. Autotracking will not function correctly and must be disabled in your config."
)
return
if pan_tilt_status.lower() == "idle" and (
zoom_status is None or zoom_status.lower() == "idle"
):
self.cams[camera_name]["active"] = False
if not self.ptz_metrics[camera_name]["ptz_stopped"].is_set():
self.ptz_metrics[camera_name]["ptz_stopped"].set()
logger.debug(f"PTZ stop time: {datetime.datetime.now().timestamp()}")
logger.debug(
f"PTZ stop time: {self.ptz_metrics[camera_name]['ptz_frame_time'].value}"
)
self.ptz_metrics[camera_name][
"ptz_stop_time"
].value = datetime.datetime.now().timestamp()
self.ptz_metrics[camera_name]["ptz_stop_time"].value = self.ptz_metrics[
camera_name
]["ptz_frame_time"].value
else:
self.cams[camera_name]["active"] = True
if self.ptz_metrics[camera_name]["ptz_stopped"].is_set():
self.ptz_metrics[camera_name]["ptz_stopped"].clear()
logger.debug(f"PTZ start time: {datetime.datetime.now().timestamp()}")
logger.debug(
f"PTZ start time: {self.ptz_metrics[camera_name]['ptz_frame_time'].value}"
)
self.ptz_metrics[camera_name][
"ptz_start_time"
].value = datetime.datetime.now().timestamp()
].value = self.ptz_metrics[camera_name]["ptz_frame_time"].value
self.ptz_metrics[camera_name]["ptz_stop_time"].value = 0
return {
"pan": status.Position.PanTilt.x,
"tilt": status.Position.PanTilt.y,
"zoom": status.Position.Zoom.x,
"pantilt_moving": status.MoveStatus.PanTilt,
"zoom_moving": status.MoveStatus.Zoom,
}
if (
self.config.cameras[camera_name].onvif.autotracking.zooming
== ZoomingModeEnum.absolute
):
# store absolute zoom level as 0 to 1 interpolated from the values of the camera
self.ptz_metrics[camera_name]["ptz_zoom_level"].value = numpy.interp(
round(status.Position.Zoom.x, 2),
[0, 1],
[
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
],
)
logger.debug(
f'Camera zoom level: {self.ptz_metrics[camera_name]["ptz_zoom_level"].value}'
)

View File

@@ -13,10 +13,18 @@ from frigate.ffmpeg_presets import (
EncodeTypeEnum,
parse_preset_hardware_acceleration_encode,
)
from frigate.models import Recordings
logger = logging.getLogger(__name__)
TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey"
def lower_priority():
os.nice(10)
class PlaybackFactorEnum(str, Enum):
realtime = "realtime"
timelapse_25x = "timelapse_25x"
@@ -42,7 +50,7 @@ class RecordingExporter(threading.Thread):
def get_datetime_from_timestamp(self, timestamp: int) -> str:
"""Convenience fun to get a simple date time from timestamp."""
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y_%m_%d_%H:%M")
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y_%m_%d_%H_%M")
def run(self) -> None:
logger.debug(
@@ -58,13 +66,31 @@ class RecordingExporter(threading.Thread):
)
else:
playlist_lines = []
playlist_start = self.start_time
while playlist_start < self.end_time:
playlist_lines.append(
f"file 'http://127.0.0.1:5000/vod/{self.camera}/start/{playlist_start}/end/{min(playlist_start + MAX_PLAYLIST_SECONDS, self.end_time)}/index.m3u8'"
# get full set of recordings
export_recordings = (
Recordings.select()
.where(
Recordings.start_time.between(self.start_time, self.end_time)
| Recordings.end_time.between(self.start_time, self.end_time)
| (
(self.start_time > Recordings.start_time)
& (self.end_time < Recordings.end_time)
)
)
.where(Recordings.camera == self.camera)
.order_by(Recordings.start_time.asc())
)
# Use pagination to process records in chunks
page_size = 1000
num_pages = (export_recordings.count() + page_size - 1) // page_size
for page in range(1, num_pages + 1):
playlist = export_recordings.paginate(page, page_size)
playlist_lines.append(
f"file 'http://127.0.0.1:5000/vod/{self.camera}/start/{float(playlist[0].start_time)}/end/{float(playlist[-1].end_time)}/index.m3u8'"
)
playlist_start += MAX_PLAYLIST_SECONDS
ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin"
@@ -76,8 +102,8 @@ class RecordingExporter(threading.Thread):
ffmpeg_cmd = (
parse_preset_hardware_acceleration_encode(
self.config.ffmpeg.hwaccel_args,
ffmpeg_input,
f"-vf setpts=0.04*PTS -r 30 -an {file_name}",
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}",
f"{self.config.cameras[self.camera].record.export.timelapse_args} {file_name}",
EncodeTypeEnum.timelapse,
)
).split(" ")
@@ -86,6 +112,7 @@ class RecordingExporter(threading.Thread):
ffmpeg_cmd,
input="\n".join(playlist_lines),
encoding="ascii",
preexec_fn=lower_priority,
capture_output=True,
)

View File

@@ -355,6 +355,7 @@ class RecordingMaintainer(threading.Thread):
"+faststart",
file_path,
stderr=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.DEVNULL,
)
await p.wait()

View File

@@ -176,6 +176,8 @@ async def set_gpu_stats(
stats[nvidia_usage[i]["name"]] = {
"gpu": str(round(float(nvidia_usage[i]["gpu"]), 2)) + "%",
"mem": str(round(float(nvidia_usage[i]["mem"]), 2)) + "%",
"enc": str(round(float(nvidia_usage[i]["enc"]), 2)) + "%",
"dec": str(round(float(nvidia_usage[i]["dec"]), 2)) + "%",
}
else:
@@ -266,6 +268,8 @@ def stats_snapshot(
"pid": pid,
"capture_pid": cpid,
"ffmpeg_pid": ffmpeg_pid,
"audio_rms": round(camera_stats["audio_rms"].value, 4),
"audio_dBFS": round(camera_stats["audio_dBFS"].value, 4),
}
stats["detectors"] = {}

View File

@@ -0,0 +1,47 @@
"""Test camera user and password cleanup."""
import unittest
from frigate.output import get_canvas_shape
class TestBirdseye(unittest.TestCase):
def test_16x9(self):
"""Test 16x9 aspect ratio works as expected for birdseye."""
width = 1280
height = 720
canvas_width, canvas_height = get_canvas_shape(width, height)
assert canvas_width == width
assert canvas_height == height
def test_4x3(self):
"""Test 4x3 aspect ratio works as expected for birdseye."""
width = 1280
height = 960
canvas_width, canvas_height = get_canvas_shape(width, height)
assert canvas_width == width
assert canvas_height == height
def test_32x9(self):
"""Test 32x9 aspect ratio works as expected for birdseye."""
width = 2560
height = 720
canvas_width, canvas_height = get_canvas_shape(width, height)
assert canvas_width == width
assert canvas_height == height
def test_9x16(self):
"""Test 9x16 aspect ratio works as expected for birdseye."""
width = 720
height = 1280
canvas_width, canvas_height = get_canvas_shape(width, height)
assert canvas_width == width
assert canvas_height == height
def test_non_16x9(self):
"""Test non 16x9 aspect ratio fails for birdseye."""
width = 1280
height = 840
canvas_width, canvas_height = get_canvas_shape(width, height)
assert canvas_width == width # width will be the same
assert canvas_height != height

View File

@@ -1027,7 +1027,12 @@ class TestConfig(unittest.TestCase):
"roles": ["detect"],
},
]
}
},
"detect": {
"height": 720,
"width": 1280,
"fps": 5,
},
}
},
}
@@ -1082,6 +1087,11 @@ class TestConfig(unittest.TestCase):
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"snapshots": {
"height": 100,
},
@@ -1107,7 +1117,12 @@ class TestConfig(unittest.TestCase):
"roles": ["detect"],
},
]
}
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
@@ -1132,6 +1147,11 @@ class TestConfig(unittest.TestCase):
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"snapshots": {
"height": 150,
"enabled": True,
@@ -1160,6 +1180,11 @@ class TestConfig(unittest.TestCase):
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
@@ -1181,7 +1206,12 @@ class TestConfig(unittest.TestCase):
"roles": ["detect"],
},
]
}
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
@@ -1205,6 +1235,11 @@ class TestConfig(unittest.TestCase):
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"rtmp": {
"enabled": True,
},
@@ -1234,6 +1269,11 @@ class TestConfig(unittest.TestCase):
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
@@ -1257,6 +1297,11 @@ class TestConfig(unittest.TestCase):
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
@@ -1278,7 +1323,12 @@ class TestConfig(unittest.TestCase):
"roles": ["detect"],
},
]
}
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
@@ -1302,6 +1352,11 @@ class TestConfig(unittest.TestCase):
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"live": {
"quality": 7,
},
@@ -1329,6 +1384,11 @@ class TestConfig(unittest.TestCase):
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
@@ -1350,7 +1410,12 @@ class TestConfig(unittest.TestCase):
"roles": ["detect"],
},
]
}
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
@@ -1375,6 +1440,11 @@ class TestConfig(unittest.TestCase):
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"timestamp_style": {"position": "bl", "thickness": 4},
}
},
@@ -1400,6 +1470,11 @@ class TestConfig(unittest.TestCase):
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
@@ -1423,6 +1498,11 @@ class TestConfig(unittest.TestCase):
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
@@ -1450,6 +1530,11 @@ class TestConfig(unittest.TestCase):
},
],
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
@@ -1475,6 +1560,11 @@ class TestConfig(unittest.TestCase):
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"zones": {
"steps": {
"coordinates": "0,0,0,0",
@@ -1536,6 +1626,56 @@ class TestConfig(unittest.TestCase):
assert runtime_config.cameras["back"].objects.filters["dog"].min_ratio == 0.2
assert runtime_config.cameras["back"].objects.filters["dog"].max_ratio == 10.1
def test_valid_movement_weights(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"onvif": {"autotracking": {"movement_weights": "1.23, 2.34, 0.50"}},
}
},
}
frigate_config = FrigateConfig(**config)
runtime_config = frigate_config.runtime_config()
assert runtime_config.cameras["back"].onvif.autotracking.movement_weights == [
1.23,
2.34,
0.50,
]
def test_fails_invalid_movement_weights(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"onvif": {"autotracking": {"movement_weights": "1.234, 2.345a"}},
}
},
}
self.assertRaises(ValueError, lambda: FrigateConfig(**config))
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -236,6 +236,44 @@ class TestHttp(unittest.TestCase):
assert event["id"] == id
assert event["retain_indefinitely"] is False
def test_event_time_filtering(self):
app = create_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
PlusApi(),
)
morning_id = "123456.random"
evening_id = "654321.random"
morning = 1656590400 # 06/30/2022 6 am (GMT)
evening = 1656633600 # 06/30/2022 6 pm (GMT)
with app.test_client() as client:
_insert_mock_event(morning_id, morning)
_insert_mock_event(evening_id, evening)
# both events come back
events = client.get("/events").json
assert events
assert len(events) == 2
# morning event is excluded
events = client.get(
"/events",
query_string={"time_range": "07:00,24:00"},
).json
assert events
# assert len(events) == 1
# evening event is excluded
events = client.get(
"/events",
query_string={"time_range": "00:00,18:00"},
).json
assert events
assert len(events) == 1
def test_set_delete_sub_label(self):
app = create_app(
FrigateConfig(**self.minimal_config),
@@ -351,14 +389,17 @@ class TestHttp(unittest.TestCase):
assert stats == self.test_stats
def _insert_mock_event(id: str) -> Event:
def _insert_mock_event(
id: str,
start_time: datetime.datetime = datetime.datetime.now().timestamp(),
) -> Event:
"""Inserts a basic event model with a given id."""
return Event.insert(
id=id,
label="Mock",
camera="front_door",
start_time=datetime.datetime.now().timestamp(),
end_time=datetime.datetime.now().timestamp() + 20,
start_time=start_time,
end_time=start_time + 20,
top_score=100,
false_positive=False,
zones=list(),

View File

@@ -74,6 +74,7 @@ class TimelineProcessor(threading.Thread):
camera_config.detect.height,
event_data["region"],
),
"attribute": "",
},
}
if event_type == "start":
@@ -93,6 +94,12 @@ class TimelineProcessor(threading.Thread):
"stationary" if event_data["stationary"] else "active"
)
Timeline.insert(timeline_entry).execute()
elif prev_event_data["attributes"] == {} and event_data["attributes"] != {}:
timeline_entry[Timeline.class_type] = "attribute"
timeline_entry[Timeline.data]["attribute"] = list(
event_data["attributes"].keys()
)[0]
Timeline.insert(timeline_entry).execute()
elif event_type == "end":
timeline_entry[Timeline.class_type] = "gone"
Timeline.insert(timeline_entry).execute()

View File

@@ -77,7 +77,7 @@ class NorfairTracker(ObjectTracker):
self.tracker = Tracker(
distance_function=frigate_distance,
distance_threshold=2.5,
initialization_delay=0,
initialization_delay=config.detect.fps / 2,
hit_counter_max=self.max_disappeared,
)
if self.ptz_autotracker_enabled.value:
@@ -273,9 +273,11 @@ class NorfairTracker(ObjectTracker):
min(self.detect_config.width - 1, estimate[2]),
min(self.detect_config.height - 1, estimate[3]),
)
estimate_velocity = tuple(t.estimate_velocity.flatten().astype(int))
obj = {
**t.last_detection.data,
"estimate": estimate,
"estimate_velocity": estimate_velocity,
}
active_ids.append(t.global_id)
if t.global_id not in self.track_id_map:

View File

@@ -23,6 +23,8 @@ class CameraMetricsTypes(TypedDict):
process_fps: Synchronized
read_start: Synchronized
skipped_fps: Synchronized
audio_rms: Synchronized
audio_dBFS: Synchronized
class PTZMetricsTypes(TypedDict):
@@ -31,6 +33,8 @@ class PTZMetricsTypes(TypedDict):
ptz_reset: Event
ptz_start_time: Synchronized
ptz_stop_time: Synchronized
ptz_frame_time: Synchronized
ptz_zoom_level: Synchronized
class FeatureMetricsTypes(TypedDict):

View File

@@ -87,7 +87,8 @@ def load_config_with_no_duplicates(raw_config) -> dict:
"""Get config ensuring duplicate keys are not allowed."""
# https://stackoverflow.com/a/71751051
class PreserveDuplicatesLoader(yaml.loader.Loader):
# important to use SafeLoader here to avoid RCE
class PreserveDuplicatesLoader(yaml.loader.SafeLoader):
pass
def map_constructor(loader, node, deep=False):
@@ -249,3 +250,15 @@ def update_yaml(data, key_path, new_value):
temp[last_key] = new_value
return data
def find_by_key(dictionary, target_key):
if target_key in dictionary:
return dictionary[target_key]
else:
for value in dictionary.values():
if isinstance(value, dict):
result = find_by_key(value, target_key)
if result is not None:
return result
return None

View File

@@ -143,6 +143,9 @@ def get_cpu_stats() -> dict[str, dict]:
def get_physical_interfaces(interfaces) -> list:
if not interfaces:
return []
with open("/proc/net/dev", "r") as file:
lines = file.readlines()
@@ -171,6 +174,7 @@ def get_bandwidth_stats(config) -> dict[str, dict]:
)
if p.returncode != 0:
logger.error(f"Error getting network stats :: {p.stderr}")
return usages
else:
lines = p.stdout.split("\n")
@@ -289,6 +293,8 @@ def get_nvidia_gpu_stats() -> dict[int, dict]:
handle = nvml.nvmlDeviceGetHandleByIndex(i)
meminfo = try_get_info(nvml.nvmlDeviceGetMemoryInfo, handle)
util = try_get_info(nvml.nvmlDeviceGetUtilizationRates, handle)
enc = try_get_info(nvml.nvmlDeviceGetEncoderUtilization, handle)
dec = try_get_info(nvml.nvmlDeviceGetDecoderUtilization, handle)
if util != "N/A":
gpu_util = util.gpu
else:
@@ -299,10 +305,22 @@ def get_nvidia_gpu_stats() -> dict[int, dict]:
else:
gpu_mem_util = -1
if enc != "N/A":
enc_util = enc[0]
else:
enc_util = -1
if dec != "N/A":
dec_util = dec[0]
else:
dec_util = -1
results[i] = {
"name": nvml.nvmlDeviceGetName(handle),
"gpu": gpu_util,
"mem": gpu_mem_util,
"enc": enc_util,
"dec": dec_util,
}
except Exception:
pass

View File

@@ -21,7 +21,6 @@ from frigate.log import LogPipe
from frigate.motion import MotionDetector
from frigate.motion.improved_motion import ImprovedMotionDetector
from frigate.object_detection import RemoteObjectDetector
from frigate.ptz.autotrack import ptz_moving_at_frame_time
from frigate.track import ObjectTracker
from frigate.track.norfair_tracker import NorfairTracker
from frigate.types import PTZMetricsTypes
@@ -98,13 +97,8 @@ def filtered(obj, objects_to_track, object_filters):
def get_min_region_size(model_config: ModelConfig) -> int:
"""Get the min region size and ensure it is divisible by 4."""
half = int(max(model_config.height, model_config.width) / 2)
if half % 4 == 0:
return half
return int((half + 3) / 4) * 4
"""Get the min region size."""
return max(model_config.height, model_config.width)
def create_tensor_input(frame, model_config: ModelConfig, region):
@@ -772,6 +766,7 @@ def process_frames(
continue
current_frame_time.value = frame_time
ptz_metrics["ptz_frame_time"].value = frame_time
frame = frame_manager.get(
f"{camera_name}{frame_time}", (frame_shape[0] * 3 // 2, frame_shape[1])
@@ -781,19 +776,8 @@ def process_frames(
logger.info(f"{camera_name}: frame {frame_time} is not in memory store.")
continue
# look for motion if enabled and ptz is not moving
# ptz_moving_at_frame_time() always returns False for
# non ptz/autotracking cameras
motion_boxes = (
motion_detector.detect(frame)
if motion_enabled.value
and not ptz_moving_at_frame_time(
frame_time,
ptz_metrics["ptz_start_time"].value,
ptz_metrics["ptz_stop_time"].value,
)
else []
)
# look for motion if enabled
motion_boxes = motion_detector.detect(frame) if motion_enabled.value else []
regions = []
consolidated_detections = []
@@ -818,8 +802,10 @@ def process_frames(
)
# and it hasn't disappeared
and object_tracker.disappeared[obj["id"]] == 0
# and it doesn't overlap with any current motion boxes
and not intersects_any(obj["box"], motion_boxes)
# and it doesn't overlap with any current motion boxes when not calibrating
and not intersects_any(
obj["box"], [] if motion_detector.is_calibrating() else motion_boxes
)
]
# get tracked object boxes that aren't stationary
@@ -829,7 +815,10 @@ def process_frames(
if obj["id"] not in stationary_object_ids
]
combined_boxes = motion_boxes + tracked_object_boxes
combined_boxes = tracked_object_boxes
# only add in the motion boxes when not calibrating
if not motion_detector.is_calibrating():
combined_boxes += motion_boxes
cluster_candidates = get_cluster_candidates(
frame_shape, region_min_size, combined_boxes

View File

@@ -20,7 +20,6 @@
},
"ignorePatterns": ["*.d.ts"],
"rules": {
"indent": ["error", 2, { "SwitchCase": 1 }],
"comma-dangle": [
"error",
{

View File

@@ -1,4 +1,5 @@
{
"printWidth": 120,
"trailingComma": "es5",
"singleQuote": true
}

View File

@@ -1,8 +1,8 @@
import { rest } from 'msw';
import { API_HOST } from '../src/env';
// import { API_HOST } from '../src/env';
export const handlers = [
rest.get(`${API_HOST}api/config`, (req, res, ctx) => {
rest.get(`api/config`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
@@ -37,7 +37,7 @@ export const handlers = [
})
);
}),
rest.get(`${API_HOST}api/stats`, (req, res, ctx) => {
rest.get(`api/stats`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
@@ -58,7 +58,7 @@ export const handlers = [
})
);
}),
rest.get(`${API_HOST}api/events`, (req, res, ctx) => {
rest.get(`api/events`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json(
@@ -77,7 +77,7 @@ export const handlers = [
)
);
}),
rest.get(`${API_HOST}api/sub_labels`, (req, res, ctx) => {
rest.get(`api/sub_labels`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([

1784
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,7 @@
"eslint-config-preact": "^1.3.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jest": "^27.2.3",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vitest-globals": "^1.4.0",
"fake-indexeddb": "^4.0.1",
"jsdom": "^22.0.0",

View File

@@ -1,2 +1 @@
export const ENV = 'test';
export const API_HOST = 'http://base-url.local:5000/';

View File

@@ -18,6 +18,6 @@ describe('useApiHost', () => {
<Test />
</ApiProvider>
);
expect(screen.queryByText('http://base-url.local:5000/')).toBeInTheDocument();
expect(screen.queryByText('http://localhost:3000/')).toBeInTheDocument();
});
});

View File

@@ -1,2 +1 @@
import { API_HOST } from '../env';
export const baseUrl = API_HOST || `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`;
export const baseUrl = `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`;

View File

@@ -5,6 +5,9 @@ import { WsProvider } from './ws';
import axios from 'axios';
axios.defaults.baseURL = `${baseUrl}api/`;
axios.defaults.headers.common = {
'X-CSRF-TOKEN': 1,
};
export function ApiProvider({ children, options }) {
return (

View File

@@ -1,5 +1,5 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import { useEffect, useCallback, useState } from 'preact/hooks';
import useSWR from 'swr';
import { usePtzCommand } from '../api/ws';
import ActivityIndicator from './ActivityIndicator';
@@ -27,29 +27,25 @@ export default function CameraControlPanel({ camera = '' }) {
setCurrentPreset('');
};
const onSetMove = async (e, dir) => {
const onSetMove = useCallback(async (e, dir) => {
e.stopPropagation();
sendPtz(`MOVE_${dir}`);
setCurrentPreset('');
};
}, [sendPtz, setCurrentPreset]);
const onSetZoom = async (e, dir) => {
const onSetZoom = useCallback(async (e, dir) => {
e.stopPropagation();
sendPtz(`ZOOM_${dir}`);
setCurrentPreset('');
};
}, [sendPtz, setCurrentPreset]);
const onSetStop = async (e) => {
const onSetStop = useCallback(async (e) => {
e.stopPropagation();
sendPtz('STOP');
};
}, [sendPtz]);
if (!ptz) {
return <ActivityIndicator />;
}
document.addEventListener('keydown', (e) => {
if (!e) {
const keydownListener = useCallback((e) => {
if (!ptz || !e) {
return;
}
@@ -83,9 +79,9 @@ export default function CameraControlPanel({ camera = '' }) {
}
}
}
});
}, [onSetMove, onSetZoom, ptz]);
document.addEventListener('keyup', (e) => {
const keyupListener = useCallback((e) => {
if (!e || e.repeat) {
return;
}
@@ -101,7 +97,20 @@ export default function CameraControlPanel({ camera = '' }) {
e.preventDefault();
onSetStop(e);
}
});
}, [onSetStop]);
useEffect(() => {
document.addEventListener('keydown', keydownListener);
document.addEventListener('keyup', keyupListener);
return () => {
document.removeEventListener('keydown', keydownListener);
document.removeEventListener('keyup', keyupListener);
};
}, [keydownListener, keyupListener, ptz]);
if (!ptz) {
return <ActivityIndicator />;
}
return (
<div data-testid="control-panel" className="p-4 text-center sm:flex justify-start">

View File

@@ -58,7 +58,7 @@ export default function CameraImage({ camera, onload, searchParams = '', stretch
if (!config || scaledHeight === 0 || !canvasRef.current) {
return;
}
img.src = `${apiHost}/api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
img.src = `${apiHost}api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
return (

View File

@@ -0,0 +1,35 @@
import { h, Fragment } from 'preact';
import { createPortal } from 'preact/compat';
import { useState, useEffect } from 'preact/hooks';
export default function LargeDialog({ children, portalRootID = 'dialogs' }) {
const portalRoot = portalRootID && document.getElementById(portalRootID);
const [show, setShow] = useState(false);
useEffect(() => {
window.requestAnimationFrame(() => {
setShow(true);
});
}, []);
const dialog = (
<Fragment>
<div
data-testid="scrim"
key="scrim"
className="fixed bg-fixed inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
>
<div
role="modal"
className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 w-4/5 md:h-2/3 max-w-7xl text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
show ? 'scale-100 opacity-100' : ''
}`}
>
{children}
</div>
</div>
</Fragment>
);
return portalRoot ? createPortal(dialog, portalRoot) : dialog;
}

View File

@@ -56,10 +56,10 @@ export const HistoryVideo = ({
}
video.src({
src: `${apiHost}/vod/event/${id}/master.m3u8`,
src: `${apiHost}vod/event/${id}/master.m3u8`,
type: 'application/vnd.apple.mpegurl',
});
video.poster(`${apiHost}/api/events/${id}/snapshot.jpg`);
video.poster(`${apiHost}api/events/${id}/snapshot.jpg`);
if (videoIsPlaying) {
video.play();
}

View File

@@ -157,12 +157,9 @@ class VideoRTC extends HTMLElement {
if (this.ws) this.ws.send(JSON.stringify(value));
}
codecs(type) {
const test =
type === 'mse'
? (codec) => MediaSource.isTypeSupported(`video/mp4; codecs="${codec}"`)
: (codec) => this.video.canPlayType(`video/mp4; codecs="${codec}"`);
return this.CODECS.filter(test).join();
/** @param {Function} isSupported */
codecs(isSupported) {
return this.CODECS.filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
}
/**
@@ -217,6 +214,7 @@ class VideoRTC extends HTMLElement {
this.video.controls = true;
this.video.playsInline = true;
this.video.preload = 'auto';
this.video.muted = true;
this.video.style.display = 'block'; // fix bottom margin 4px
this.video.style.width = '100%';
@@ -310,7 +308,7 @@ class VideoRTC extends HTMLElement {
const modes = [];
if (this.mode.indexOf('mse') >= 0 && 'MediaSource' in window) {
if (this.mode.indexOf('mse') >= 0 && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
// iPhone
modes.push('mse');
this.onmse();
@@ -362,18 +360,29 @@ class VideoRTC extends HTMLElement {
}
onmse() {
const ms = new MediaSource();
ms.addEventListener(
'sourceopen',
() => {
URL.revokeObjectURL(this.video.src);
this.send({ type: 'mse', value: this.codecs('mse') });
},
{ once: true }
);
/** @type {MediaSource} */
let ms;
this.video.src = URL.createObjectURL(ms);
this.video.srcObject = null;
if ('ManagedMediaSource' in window) {
const MediaSource = window.ManagedMediaSource;
ms = new MediaSource();
ms.addEventListener('sourceopen', () => {
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
}, {once: true});
this.video.disableRemotePlayback = true;
this.video.srcObject = ms;
} else {
ms = new MediaSource();
ms.addEventListener('sourceopen', () => {
URL.revokeObjectURL(this.video.src);
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
}, {once: true});
this.video.src = URL.createObjectURL(ms);
this.video.srcObject = null;
}
this.play();
this.mseCodecs = '';
@@ -579,7 +588,7 @@ class VideoRTC extends HTMLElement {
video2.src = `data:video/mp4;base64,${VideoRTC.btoa(data)}`;
};
this.send({ type: 'mp4', value: this.codecs('mp4') });
this.send({ type: 'mp4', value: this.codecs(this.video.canPlayType) });
}
static btoa(buffer) {

View File

@@ -4,9 +4,7 @@ import Menu from './Menu';
import { ArrowDropdown } from '../icons/ArrowDropdown';
import Heading from './Heading';
import Button from './Button';
import CameraIcon from '../icons/Camera';
import SpeakerIcon from '../icons/Speaker';
import useSWR from 'swr';
import SelectOnlyIcon from '../icons/SelectOnly';
export default function MultiSelect({ className, title, options, selection, onToggle, onShowAll, onSelectSingle }) {
const popupRef = useRef(null);
@@ -20,7 +18,6 @@ export default function MultiSelect({ className, title, options, selection, onTo
};
const menuHeight = Math.round(window.innerHeight * 0.55);
const { data: config } = useSWR('config');
return (
<div className={`${className} p-2`} ref={popupRef}>
<div className="flex justify-between min-w-[120px]" onClick={() => setState({ showMenu: true })}>
@@ -61,8 +58,7 @@ export default function MultiSelect({ className, title, options, selection, onTo
className="max-h-[35px] mx-2"
onClick={() => onSelectSingle(item)}
>
{ (title === "Labels" && config.audio.listen.includes(item)) ? ( <SpeakerIcon /> ) : ( <CameraIcon /> ) }
{ ( <SelectOnlyIcon /> ) }
</Button>
</div>
</div>

View File

@@ -153,7 +153,7 @@ export function EventCard({ camera, event }) {
<Link className="" href={`/recording/${camera}/${format(start, 'yyyy-MM-dd/HH/mm/ss')}`}>
<div className="flex flex-row mb-2">
<div className="w-28 mr-4">
<img className="antialiased" loading="lazy" src={`${apiHost}/api/events/${event.id}/thumbnail.jpg`} />
<img className="antialiased" loading="lazy" src={`${apiHost}api/events/${event.id}/thumbnail.jpg`} />
</div>
<div className="flex flex-row w-full border-b">
<div className="w-full text-gray-700 font-semibold relative pt-0">

View File

@@ -1,7 +1,7 @@
import { h } from 'preact';
import { useCallback, useState } from 'preact/hooks';
export default function Switch({ checked, id, onChange, label, labelPosition = 'before' }) {
export default function Switch({ className, checked, id, onChange, label, labelPosition = 'before' }) {
const [isFocused, setFocused] = useState(false);
const handleChange = useCallback(() => {
@@ -21,7 +21,7 @@ export default function Switch({ checked, id, onChange, label, labelPosition = '
return (
<label
htmlFor={id}
className={`flex items-center space-x-4 w-full ${onChange ? 'cursor-pointer' : 'cursor-not-allowed'}`}
className={`${className ? className : ''} flex items-center space-x-4 w-full ${onChange ? 'cursor-pointer' : 'cursor-not-allowed'}`}
>
{label && labelPosition === 'before' ? (
<div data-testid={`${id}-label`} className="inline-flex flex-grow">

View File

@@ -1,182 +1,18 @@
import { h } from 'preact';
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
import { useState } from 'preact/hooks';
import { ArrowDropdown } from '../icons/ArrowDropdown';
import { ArrowDropup } from '../icons/ArrowDropup';
import Heading from './Heading';
const TimePicker = ({ dateRange, onChange }) => {
const [error, setError] = useState(null);
const [timeRange, setTimeRange] = useState(new Set());
const [hoverIdx, setHoverIdx] = useState(null);
const [reset, setReset] = useState(false);
const TimePicker = ({ timeRange, onChange }) => {
const times = timeRange.split(',');
const [after, setAfter] = useState(times[0]);
const [before, setBefore] = useState(times[1]);
/**
* Initializes two variables before and after with date objects,
* If they are not null, it creates a new Date object with the value of the property and if not,
* it creates a new Date object with the current hours to 0 and 24 respectively.
*/
const before = useMemo(() => {
return dateRange.before ? new Date(dateRange.before) : new Date(new Date().setHours(24, 0, 0, 0));
}, [dateRange]);
const after = useMemo(() => {
return dateRange.after ? new Date(dateRange.after) : new Date(new Date().setHours(0, 0, 0, 0));
}, [dateRange]);
useEffect(() => {
/**
* This will reset hours when user selects another date in the calendar.
*/
if (before.getHours() === 0 && after.getHours() === 0 && timeRange.size > 1) return setTimeRange(new Set());
}, [after, before, timeRange]);
useEffect(() => {
if (reset || !after) return;
/**
* calculates the number of hours between two dates, by finding the difference in days,
* converting it to hours and adding the hours from the before date.
*/
const days = Math.max(before.getDate() - after.getDate());
const hourOffset = days * 24;
const beforeOffset = before.getHours() ? hourOffset + before.getHours() : 0;
/**
* Fills the timeRange by iterating over the hours between 'after' and 'before' during component mount, to keep the selected hours persistent.
*/
for (let hour = after.getHours(); hour < beforeOffset; hour++) {
setTimeRange((timeRange) => timeRange.add(hour));
}
/**
* find an element by the id timeIndex- concatenated with the minimum value from timeRange array,
* and if that element is present, it will scroll into view if needed
*/
if (timeRange.size > 1) {
const element = document.getElementById(`timeIndex-${Math.max(...timeRange)}`);
if (element) {
element.scrollIntoViewIfNeeded(true);
}
}
}, [after, before, timeRange, reset]);
/**
* numberOfDaysSelected is a set that holds the number of days selected in the dateRange.
* The loop iterates through the days starting from the after date's day to the before date's day.
* If the before date's hour is 0, it skips it.
*/
const numberOfDaysSelected = useMemo(() => {
return new Set([...Array(Math.max(1, before.getDate() - after.getDate() + 1))].map((_, i) => after.getDate() + i));
}, [before, after]);
if (before.getHours() === 0) numberOfDaysSelected.delete(before.getDate());
// Create repeating array with the number of hours for each day selected ...23,24,0,1,2...
const hoursInDays = useMemo(() => {
return Array.from({ length: numberOfDaysSelected.size * 24 }, (_, i) => i % 24);
}, [numberOfDaysSelected]);
// function for handling the selected time from the provided list
const handleTime = useCallback(
(hour) => {
if (isNaN(hour)) return;
const _timeRange = new Set([...timeRange]);
_timeRange.add(hour);
// reset error messages
setError(null);
/**
* Check if the variable "hour" exists in the "timeRange" set.
* If it does, reset the timepicker
*/
if (timeRange.has(hour)) {
setTimeRange(new Set());
setReset(true);
const resetBefore = before.setDate(after.getDate() + numberOfDaysSelected.size - 1);
return onChange({
after: after.setHours(0, 0, 0, 0) / 1000,
before: new Date(resetBefore).setHours(24, 0, 0, 0) / 1000,
});
}
//update after
if (_timeRange.size === 1) {
// check if the first selected value is within first day
const firstSelectedHour = Math.ceil(Math.max(..._timeRange));
if (firstSelectedHour > 23) {
return setError('Select a time on the initial day!');
}
// calculate days offset
const dayOffsetAfter = new Date(after).setHours(Math.min(..._timeRange));
let dayOffsetBefore = before;
if (numberOfDaysSelected.size === 1) {
dayOffsetBefore = new Date(after).setHours(Math.min(..._timeRange) + 1);
}
onChange({
after: dayOffsetAfter / 1000,
before: dayOffsetBefore / 1000,
});
}
//update before
if (_timeRange.size > 1) {
let selectedDay = Math.ceil(Math.max(..._timeRange) / 24);
// if user selects time 00:00 for the next day, add one day
if (hour === 24 && selectedDay === numberOfDaysSelected.size - 1) {
selectedDay += 1;
}
// Check if end time is on the last day
if (selectedDay !== numberOfDaysSelected.size) {
return setError('Ending must occur on final day!');
}
// Check if end time is later than start time
const startHour = Math.min(..._timeRange);
if (hour <= startHour) {
return setError('Ending hour must be greater than start time!');
}
// Add all hours between start and end times to the set
for (let x = startHour; x <= hour; x++) {
_timeRange.add(x);
}
// calculate days offset
const dayOffsetBefore = new Date(dateRange.after);
onChange({
after: dateRange.after / 1000,
// we add one hour to get full 60min of last selected hour
before: dayOffsetBefore.setHours(Math.max(..._timeRange) + 1) / 1000,
});
}
for (let i = 0; i < _timeRange.size; i++) {
setTimeRange((timeRange) => timeRange.add(Array.from(_timeRange)[i]));
}
},
[after, before, timeRange, dateRange.after, numberOfDaysSelected.size, onChange]
);
const isSelected = useCallback(
(idx) => {
return !!timeRange.has(idx);
},
[timeRange]
);
const isHovered = useCallback(
(idx) => {
return timeRange.size === 1 && idx > Math.max(...timeRange) && idx <= hoverIdx;
},
[timeRange, hoverIdx]
);
// Create repeating array with the number of hours for 1 day ...23,24,0,1,2...
const hoursInDays = Array.from({ length: 24 }, (_, i) => String(i % 24).padStart(2, '0'));
// background colors for each day
const isSelectedCss = 'bg-blue-600 transition duration-300 ease-in-out hover:rounded-none';
function randomGrayTone(shade) {
const grayTones = [
'bg-[#212529]/50',
@@ -193,44 +29,72 @@ const TimePicker = ({ dateRange, onChange }) => {
return grayTones[shade % grayTones.length];
}
const isSelected = (idx, current) => {
return current == `${idx}:00`;
};
const isSelectedCss = 'bg-blue-600 transition duration-300 ease-in-out hover:rounded-none';
const handleTime = (after, before) => {
setAfter(after);
setBefore(before);
onChange(`${after},${before}`);
};
return (
<>
{error ? <span className="text-red-400 text-center text-xs absolute top-1 right-0 pr-2">{error}</span> : null}
<div className="mt-2 pr-3 hidden xs:block" aria-label="Calendar timepicker, select a time range">
<div className="flex items-center justify-center">
<ArrowDropup className="w-10 text-center" />
</div>
<div className="w-20 px-1">
<div
className="border border-gray-400/50 cursor-pointer hide-scroll shadow-md rounded-md"
style={{ maxHeight: '17rem', overflowY: 'scroll' }}
>
{hoursInDays.map((_, idx) => (
<div
key={idx}
id={`timeIndex-${idx}`}
className={`${isSelected(idx) ? isSelectedCss : ''}
${isHovered(idx) ? 'opacity-30 bg-slate-900 transition duration-150 ease-in-out' : ''}
${Math.min(...timeRange) === idx ? 'rounded-t-lg' : ''}
${timeRange.size > 1 && Math.max(...timeRange) === idx ? 'rounded-b-lg' : ''}`}
onMouseEnter={() => setHoverIdx(idx)}
onMouseLeave={() => setHoverIdx(null)}
>
<div
className={`
<div className="px-1 flex justify-between">
<div>
<Heading className="text-center" size="sm">
After
</Heading>
<div
className="w-20 border border-gray-400/50 cursor-pointer hide-scroll shadow-md rounded-md"
style={{ maxHeight: '17rem', overflowY: 'scroll' }}
>
{hoursInDays.map((time, idx) => (
<div className={`${isSelected(time, after) ? isSelectedCss : ''}`} key={idx} id={`timeIndex-${idx}`}>
<div
className={`
text-gray-300 w-full font-light border border-transparent hover:border hover:rounded-md hover:border-gray-600 text-center text-sm
${randomGrayTone([Math.floor(idx / 24)])}`}
onClick={() => handleTime(idx)}
>
<span aria-label={`${idx}:00`}>{hoursInDays[idx]}:00</span>
onClick={() => handleTime(`${time}:00`, before)}
>
<span aria-label={`${idx}:00`}>{hoursInDays[idx]}:00</span>
</div>
</div>
</div>
))}
))}
</div>
</div>
<div className="flex items-center justify-center">
<ArrowDropdown className="w-10 text-center" />
<div>
<Heading className="text-center" size="sm">
Before
</Heading>
<div
className="w-20 border border-gray-400/50 cursor-pointer hide-scroll shadow-md rounded-md"
style={{ maxHeight: '17rem', overflowY: 'scroll' }}
>
{hoursInDays.map((time, idx) => (
<div className={`${isSelected(time, before) ? isSelectedCss : ''}`} key={idx} id={`timeIndex-${idx}`}>
<div
className={`
text-gray-300 w-full font-light border border-transparent hover:border hover:rounded-md hover:border-gray-600 text-center text-sm
${randomGrayTone([Math.floor(idx / 24)])}`}
onClick={() => handleTime(after, `${time}:00`)}
>
<span aria-label={`${idx}:00`}>{hoursInDays[idx]}:00</span>
</div>
</div>
))}
</div>
</div>
</div>
<div className="flex items-center justify-center">
<ArrowDropdown className="w-10 text-center" />
</div>
</div>
</>
);

View File

@@ -55,7 +55,7 @@ export default function TimelineEventOverlay({ eventOverlay, cameraConfig }) {
) : null}
</div>
{isHovering && (
<div className="absolute bg-white dark:bg-slate-800 p-4 block dark:text-white text-lg" style={getHoverStyle()}>
<div className="absolute bg-white dark:bg-slate-800 p-4 block text-black dark:text-white text-lg" style={getHoverStyle()}>
<div>{`Area: ${getObjectArea()} px`}</div>
<div>{`Ratio: ${getObjectRatio()}`}</div>
</div>

View File

@@ -7,7 +7,10 @@ import ActiveObjectIcon from '../icons/ActiveObject';
import PlayIcon from '../icons/Play';
import ExitIcon from '../icons/Exit';
import StationaryObjectIcon from '../icons/StationaryObject';
import { Zone } from '../icons/Zone';
import FaceIcon from '../icons/Face';
import LicensePlateIcon from '../icons/LicensePlate';
import DeliveryTruckIcon from '../icons/DeliveryTruck';
import ZoneIcon from '../icons/Zone';
import { useMemo, useState } from 'preact/hooks';
import Button from './Button';
@@ -88,7 +91,7 @@ export default function TimelineSummary({ event, onFrameSelected }) {
aria-label={window.innerWidth > 640 ? getTimelineItemDescription(config, item, event) : ''}
onClick={() => onSelectMoment(index)}
>
{getTimelineIcon(item.class_type)}
{getTimelineIcon(item)}
</Button>
))}
</div>
@@ -113,8 +116,8 @@ export default function TimelineSummary({ event, onFrameSelected }) {
);
}
function getTimelineIcon(classType) {
switch (classType) {
function getTimelineIcon(timelineItem) {
switch (timelineItem.class_type) {
case 'visible':
return <PlayIcon className="w-8" />;
case 'gone':
@@ -124,7 +127,16 @@ function getTimelineIcon(classType) {
case 'stationary':
return <StationaryObjectIcon className="w-8" />;
case 'entered_zone':
return <Zone className="w-8" />;
return <ZoneIcon className="w-8" />;
case 'attribute':
switch (timelineItem.data.attribute) {
case 'face':
return <FaceIcon className="w-8" />;
case 'license_plate':
return <LicensePlateIcon className="w-8" />;
default:
return <DeliveryTruckIcon className="w-8" />;
}
}
}
@@ -156,6 +168,15 @@ function getTimelineItemDescription(config, timelineItem, event) {
time_style: 'medium',
time_format: config.ui.time_format,
})}`;
case 'attribute':
return `${timelineItem.data.attribute.replaceAll("_", " ")} detected for ${event.label} at ${formatUnixTimestampToDateTime(
timelineItem.timestamp,
{
date_style: 'short',
time_style: 'medium',
time_format: config.ui.time_format,
}
)}`;
case 'gone':
return `${event.label} left at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
date_style: 'short',

View File

@@ -1,2 +1 @@
export const ENV = import.meta.env.MODE;
export const API_HOST = ENV === 'production' ? '' : 'http://localhost:5000/';
export const ENV = import.meta.env.MODE;

View File

@@ -0,0 +1,15 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function StationaryObject({ className = '' }) {
return (
<svg className={`fill-current ${className}`} viewBox="0 -960 960 960">
<path
fill="currentColor"
d="M240-160q-50 0-85-35t-35-85H40v-440q0-33 23.5-56.5T120-800h560v160h120l120 160v200h-80q0 50-35 85t-85 35q-50 0-85-35t-35-85H360q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T280-280q0-17-11.5-28.5T240-320q-17 0-28.5 11.5T200-280q0 17 11.5 28.5T240-240ZM120-360h32q17-18 39-29t49-11q27 0 49 11t39 29h272v-360H120v360Zm600 120q17 0 28.5-11.5T760-280q0-17-11.5-28.5T720-320q-17 0-28.5 11.5T680-280q0 17 11.5 28.5T720-240Zm-40-200h170l-90-120h-80v120ZM360-540Z"
/>
</svg>
);
}
export default memo(StationaryObject);

22
web/src/icons/Face.jsx Normal file
View File

@@ -0,0 +1,22 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Zone({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 -960 960 960"
stroke={stroke}
onClick={onClick}
>
<path
fill="currentColor"
d="M360-390q-21 0-35.5-14.5T310-440q0-21 14.5-35.5T360-490q21 0 35.5 14.5T410-440q0 21-14.5 35.5T360-390Zm240 0q-21 0-35.5-14.5T550-440q0-21 14.5-35.5T600-490q21 0 35.5 14.5T650-440q0 21-14.5 35.5T600-390ZM480-160q134 0 227-93t93-227q0-24-3-46.5T786-570q-21 5-42 7.5t-44 2.5q-91 0-172-39T390-708q-32 78-91.5 135.5T160-486v6q0 134 93 227t227 93Zm0 80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm-54-715q42 70 114 112.5T700-640q14 0 27-1.5t27-3.5q-42-70-114-112.5T480-800q-14 0-27 1.5t-27 3.5ZM177-581q51-29 89-75t57-103q-51 29-89 75t-57 103Zm249-214Zm-103 36Z"
/>
</svg>
);
}
export default memo(Zone);

View File

@@ -0,0 +1,15 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function StationaryObject({ className = '' }) {
return (
<svg className={`fill-current ${className}`} viewBox="0 -960 960 960">
<path
fill="currentColor"
d="M400-280h360v-240H400v240ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm0-80h640v-480H160v480Zm0 0v-480 480Z"
/>
</svg>
);
}
export default memo(StationaryObject);

View File

@@ -0,0 +1,21 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function SelectOnly({ className = 'h-5 w-5', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
d="M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm-7 7H3v4c0 1.1.9 2 2 2h4v-2H5v-4zM5 5h4V3H5c-1.1 0-2 .9-2 2v4h2V5zm14-2h-4v2h4v4h2V5c0-1.1-.9-2-2-2zm0 16h-4v2h4c1.1 0 2-.9 2-2v-4h-2v4z"
/>
</svg>
);
}
export default memo(SelectOnly);

View File

@@ -35,7 +35,7 @@ export default function Birdseye() {
let player;
const playerClass = ptzCameras.length || isMaxWidth ? 'w-full' : 'max-w-5xl xl:w-1/2';
if (viewSource == 'mse' && config.birdseye.restream) {
if ('MediaSource' in window) {
if ('MediaSource' in window || 'ManagedMediaSource' in window) {
player = (
<Fragment>
<div className={playerClass}>
@@ -50,7 +50,7 @@ export default function Birdseye() {
player = (
<Fragment>
<div className="w-5xl text-center text-sm">
MSE is not supported on iOS devices. You'll need to use jsmpeg or webRTC. See the docs for more info.
MSE is only supported on iOS 17.1+. You'll need to update if available or use jsmpeg / webRTC streams. See the docs for more info.
</div>
</Fragment>
);

View File

@@ -116,7 +116,7 @@ export default function Camera({ camera }) {
let player;
if (viewMode === 'live') {
if (viewSource == 'mse' && restreamEnabled) {
if ('MediaSource' in window) {
if ('MediaSource' in window || 'ManagedMediaSource' in window) {
player = (
<Fragment>
<div className="max-w-5xl">
@@ -133,7 +133,7 @@ export default function Camera({ camera }) {
player = (
<Fragment>
<div className="w-5xl text-center text-sm">
MSE is not supported on iOS devices. You'll need to use jsmpeg or webRTC. See the docs for more info.
MSE is only supported on iOS 17.1+. You'll need to update if available or use jsmpeg / webRTC streams. See the docs for more info.
</div>
</Fragment>
);
@@ -212,7 +212,7 @@ export default function Camera({ camera }) {
key={objectType}
header={objectType}
href={`/events?cameras=${camera}&labels=${encodeURIComponent(objectType)}`}
media={<img src={`${apiHost}/api/${camera}/${encodeURIComponent(objectType)}/thumbnail.jpg`} />}
media={<img src={`${apiHost}api/${camera}/${encodeURIComponent(objectType)}/thumbnail.jpg`} />}
/>
))}
</div>

View File

@@ -30,8 +30,8 @@ export default function CameraMasks({ camera }) {
Array.isArray(motionMask)
? motionMask.map((mask) => getPolylinePoints(mask))
: motionMask
? [getPolylinePoints(motionMask)]
: []
? [getPolylinePoints(motionMask)]
: []
);
const [zonePoints, setZonePoints] = useState(
@@ -45,8 +45,8 @@ export default function CameraMasks({ camera }) {
[name]: Array.isArray(objectFilters[name].mask)
? objectFilters[name].mask.map((mask) => getPolylinePoints(mask))
: objectFilters[name].mask
? [getPolylinePoints(objectFilters[name].mask)]
: [],
? [getPolylinePoints(objectFilters[name].mask)]
: [],
}),
{}
)
@@ -135,7 +135,7 @@ export default function CameraMasks({ camera }) {
const endpoint = `config/set?${queryParameters}`;
const response = await axios.put(endpoint);
if (response.status === 200) {
setSuccess(response.data);
setSuccess(response.data.message);
}
} catch (error) {
if (error.response) {
@@ -146,7 +146,6 @@ export default function CameraMasks({ camera }) {
}
}, [camera, motionMaskPoints]);
// Zone methods
const handleEditZone = useCallback(
(key) => {
@@ -175,9 +174,11 @@ export default function CameraMasks({ camera }) {
const handleCopyZones = useCallback(async () => {
const textToCopy = ` zones:
${Object.keys(zonePoints)
.map(
(zoneName) => ` ${zoneName}:
coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`).join('\n')}`;
.map(
(zoneName) => ` ${zoneName}:
coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`
)
.join('\n')}`;
if (window.navigator.clipboard && window.navigator.clipboard.writeText) {
// Use Clipboard API if available
@@ -207,7 +208,10 @@ ${Object.keys(zonePoints)
const handleSaveZones = useCallback(async () => {
try {
const queryParameters = Object.keys(zonePoints)
.map((zoneName) => `cameras.${camera}.zones.${zoneName}.coordinates=${polylinePointsToPolyline(zonePoints[zoneName])}`)
.map(
(zoneName) =>
`cameras.${camera}.zones.${zoneName}.coordinates=${polylinePointsToPolyline(zonePoints[zoneName])}`
)
.join('&');
const endpoint = `config/set?${queryParameters}`;
const response = await axios.put(endpoint);
@@ -252,21 +256,26 @@ ${Object.keys(zonePoints)
await window.navigator.clipboard.writeText(` objects:
filters:
${Object.keys(objectMaskPoints)
.map((objectName) =>
objectMaskPoints[objectName].length
? ` ${objectName}:
.map((objectName) =>
objectMaskPoints[objectName].length
? ` ${objectName}:
mask: ${polylinePointsToPolyline(objectMaskPoints[objectName])}`
: ''
)
.filter(Boolean)
.join('\n')}`);
: ''
)
.filter(Boolean)
.join('\n')}`);
}, [objectMaskPoints]);
const handleSaveObjectMasks = useCallback(async () => {
try {
const queryParameters = Object.keys(objectMaskPoints)
.filter((objectName) => objectMaskPoints[objectName].length > 0)
.map((objectName, index) => `cameras.${camera}.objects.filters.${objectName}.mask.${index}=${polylinePointsToPolyline(objectMaskPoints[objectName])}`)
.map(
(objectName, index) =>
`cameras.${camera}.objects.filters.${objectName}.mask.${index}=${polylinePointsToPolyline(
objectMaskPoints[objectName]
)}`
)
.join('&');
const endpoint = `config/set?${queryParameters}`;
const response = await axios.put(endpoint);
@@ -324,8 +333,8 @@ ${Object.keys(objectMaskPoints)
<Card
content={
<p>
When done, copy each mask configuration into your <code className="font-mono">config.yml</code> file
restart your Frigate instance to save your changes.
When done, copy each mask configuration into your <code className="font-mono">config.yml</code> file restart
your Frigate instance to save your changes.
</p>
}
header="Warning"
@@ -336,7 +345,7 @@ ${Object.keys(objectMaskPoints)
<div className="space-y-4">
<div className="relative">
<img ref={imageRef} src={`${apiHost}/api/${camera}/latest.jpg`} />
<img ref={imageRef} src={`${apiHost}api/${camera}/latest.jpg`} />
<EditableMask
onChange={handleUpdateEditable}
points={'subkey' in editing ? editing.set[editing.key][editing.subkey] : editing.set[editing.key]}
@@ -487,16 +496,16 @@ function EditableMask({ onChange, points, scale, snap, width, height }) {
{!scaledPoints
? null
: scaledPoints.map(([x, y], i) => (
<PolyPoint
key={i}
boundingRef={boundingRef}
index={i}
onMove={handleMovePoint}
onRemove={handleRemovePoint}
x={x + MaskInset}
y={y + MaskInset}
/>
))}
<PolyPoint
key={i}
boundingRef={boundingRef}
index={i}
onMove={handleMovePoint}
onRemove={handleRemovePoint}
x={x + MaskInset}
y={y + MaskInset}
/>
))}
<div className="absolute inset-0 right-0 bottom-0" onClick={handleAddPoint} ref={boundingRef} />
<svg
width="100%"
@@ -569,8 +578,6 @@ function MaskValues({
[onAdd]
);
return (
<div className="overflow-hidden" onMouseOver={handleMousein} onMouseOut={handleMouseout}>
<div className="flex space-x-4">

View File

@@ -5,24 +5,41 @@ import CameraImage from '../components/CameraImage';
import AudioIcon from '../icons/Audio';
import ClipIcon from '../icons/Clip';
import MotionIcon from '../icons/Motion';
import SettingsIcon from '../icons/Settings';
import SnapshotIcon from '../icons/Snapshot';
import { useAudioState, useDetectState, useRecordingsState, useSnapshotsState } from '../api/ws';
import { useMemo } from 'preact/hooks';
import useSWR from 'swr';
import { useRef, useState } from 'react';
import { useResizeObserver } from '../hooks';
import Dialog from '../components/Dialog';
import Switch from '../components/Switch';
import Heading from '../components/Heading';
import Button from '../components/Button';
export default function Cameras() {
const { data: config } = useSWR('config');
const containerRef = useRef(null);
const [{ width: containerWidth }] = useResizeObserver(containerRef);
// Add scrollbar width (when visible) to the available observer width to eliminate screen juddering.
// https://github.com/blakeblackshear/frigate/issues/1657
let scrollBarWidth = 0;
if (window.innerWidth && document.body.offsetWidth) {
scrollBarWidth = window.innerWidth - document.body.offsetWidth;
}
const availableWidth = scrollBarWidth ? containerWidth + scrollBarWidth : containerWidth;
return !config ? (
<ActivityIndicator />
) : (
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4 p-2 px-4">
<SortedCameras config={config} unsortedCameras={config.cameras} />
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4 p-2 px-4" ref={containerRef}>
<SortedCameras config={config} unsortedCameras={config.cameras} availableWidth={availableWidth} />
</div>
);
}
function SortedCameras({ config, unsortedCameras }) {
function SortedCameras({ config, unsortedCameras, availableWidth }) {
const sortedCameras = useMemo(
() =>
Object.entries(unsortedCameras)
@@ -34,17 +51,20 @@ function SortedCameras({ config, unsortedCameras }) {
return (
<Fragment>
{sortedCameras.map(([camera, conf]) => (
<Camera key={camera} name={camera} config={config.cameras[camera]} conf={conf} />
<Camera key={camera} name={camera} config={config.cameras[camera]} conf={conf} availableWidth={availableWidth} />
))}
</Fragment>
);
}
function Camera({ name, config }) {
function Camera({ name, config, availableWidth }) {
const { payload: detectValue, send: sendDetect } = useDetectState(name);
const { payload: recordValue, send: sendRecordings } = useRecordingsState(name);
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
const { payload: audioValue, send: sendAudio } = useAudioState(name);
const [cameraOptions, setCameraOptions] = useState('');
const href = `/cameras/${name}`;
const buttons = useMemo(() => {
return [
@@ -56,7 +76,15 @@ function Camera({ name, config }) {
return `${name.replaceAll('_', ' ')}`;
}, [name]);
const icons = useMemo(
() => [
() => (availableWidth < 448 ? [
{
icon: SettingsIcon,
color: 'gray',
onClick: () => {
setCameraOptions(config.name);
},
},
] : [
{
name: `Toggle detect ${detectValue === 'ON' ? 'off' : 'on'}`,
icon: MotionIcon,
@@ -95,17 +123,64 @@ function Camera({ name, config }) {
},
}
: null,
].filter((button) => button != null),
[config, audioValue, sendAudio, detectValue, sendDetect, recordValue, sendRecordings, snapshotValue, sendSnapshots]
]).filter((button) => button != null),
[config, availableWidth, setCameraOptions, audioValue, sendAudio, detectValue, sendDetect, recordValue, sendRecordings, snapshotValue, sendSnapshots]
);
return (
<Card
buttons={buttons}
href={href}
header={cleanName}
icons={icons}
media={<CameraImage camera={name} stretch />}
/>
<Fragment>
{cameraOptions && (
<Dialog>
<div className="p-4">
<Heading size="md">{`${name.replaceAll('_', ' ')} Settings`}</Heading>
<Switch
className="my-3"
checked={detectValue == 'ON'}
id="detect"
onChange={() => sendDetect(detectValue === 'ON' ? 'OFF' : 'ON', true)}
label="Detect"
labelPosition="before"
/>
{config.record.enabled_in_config && <Switch
className="my-3"
checked={recordValue == 'ON'}
id="record"
onChange={() => sendRecordings(recordValue === 'ON' ? 'OFF' : 'ON', true)}
label="Recordings"
labelPosition="before"
/>}
<Switch
className="my-3"
checked={snapshotValue == 'ON'}
id="snapshot"
onChange={() => sendSnapshots(snapshotValue === 'ON' ? 'OFF' : 'ON', true)}
label="Snapshots"
labelPosition="before"
/>
{config.audio.enabled_in_config && <Switch
className="my-3"
checked={audioValue == 'ON'}
id="audio"
onChange={() => sendAudio(audioValue === 'ON' ? 'OFF' : 'ON', true)}
label="Audio Detection"
labelPosition="before"
/>}
</div>
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button className="ml-2" onClick={() => setCameraOptions('')} type="text">
Close
</Button>
</div>
</Dialog>
)}
<Card
buttons={buttons}
href={href}
header={cleanName}
icons={icons}
media={<CameraImage camera={name} stretch />}
/>
</Fragment>
);
}

View File

@@ -28,10 +28,13 @@ export default function Config() {
})
.then((response) => {
if (response.status === 200) {
setSuccess(response.data);
setError('');
setSuccess(response.data.message);
}
})
.catch((error) => {
setSuccess('');
if (error.response) {
setError(error.response.data.message);
} else {
@@ -58,9 +61,9 @@ export default function Config() {
let yamlModel;
if (editor.getModels().length > 0) {
yamlModel = editor.getModel(modelUri)
yamlModel = editor.getModel(modelUri);
} else {
yamlModel = editor.createModel(config, 'yaml', modelUri)
yamlModel = editor.createModel(config, 'yaml', modelUri);
}
setDiagnosticsOptions({
@@ -71,7 +74,7 @@ export default function Config() {
format: true,
schemas: [
{
uri: `${apiHost}/api/config/schema.json`,
uri: `${apiHost}api/config/schema.json`,
fileMatch: [String(modelUri)],
},
],
@@ -97,10 +100,10 @@ export default function Config() {
<Button className="mx-2" onClick={(e) => handleCopyConfig(e)}>
Copy Config
</Button>
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, "restart")}>
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, 'restart')}>
Save & Restart
</Button>
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, "saveonly")}>
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, 'saveonly')}>
Save Only
</Button>
</div>

View File

@@ -48,6 +48,8 @@ const monthsAgo = (num) => {
export default function Events({ path, ...props }) {
const apiHost = useApiHost();
const { data: config } = useSWR('config');
const timezone = useMemo(() => config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config]);
const [searchParams, setSearchParams] = useState({
before: null,
after: null,
@@ -55,7 +57,10 @@ export default function Events({ path, ...props }) {
labels: props.labels ?? 'all',
zones: props.zones ?? 'all',
sub_labels: props.sub_labels ?? 'all',
time_range: '00:00,24:00',
timezone,
favorites: props.favorites ?? 0,
event: props.event,
});
const [state, setState] = useState({
showDownloadMenu: false,
@@ -69,7 +74,7 @@ export default function Events({ path, ...props }) {
validBox: null,
});
const [uploading, setUploading] = useState([]);
const [viewEvent, setViewEvent] = useState();
const [viewEvent, setViewEvent] = useState(props.event);
const [eventOverlay, setEventOverlay] = useState();
const [eventDetailType, setEventDetailType] = useState('clip');
const [downloadEvent, setDownloadEvent] = useState({
@@ -86,10 +91,17 @@ export default function Events({ path, ...props }) {
showDeleteFavorite: false,
});
const eventsFetcher = useCallback((path, params) => {
params = { ...params, include_thumbnails: 0, limit: API_LIMIT };
return axios.get(path, { params }).then((res) => res.data);
}, []);
const eventsFetcher = useCallback(
(path, params) => {
if (searchParams.event) {
path = `${path}/${searchParams.event}`;
return axios.get(path).then((res) => [res.data]);
}
params = { ...params, include_thumbnails: 0, limit: API_LIMIT };
return axios.get(path, { params }).then((res) => res.data);
},
[searchParams]
);
const getKey = useCallback(
(index, prevData) => {
@@ -106,8 +118,6 @@ export default function Events({ path, ...props }) {
const { data: eventPages, mutate, size, setSize, isValidating } = useSWRInfinite(getKey, eventsFetcher);
const { data: config } = useSWR('config');
const { data: allLabels } = useSWR(['labels']);
const { data: allSubLabels } = useSWR(['sub_labels', { split_joined: 1 }]);
@@ -234,6 +244,13 @@ export default function Events({ path, ...props }) {
[searchParams, setSearchParams, state, setState]
);
const handleSelectTimeRange = useCallback(
(timeRange) => {
setSearchParams({ ...searchParams, time_range: timeRange });
},
[searchParams]
);
const onFilter = useCallback(
(name, value) => {
const updatedParams = { ...searchParams, [name]: value };
@@ -260,12 +277,16 @@ export default function Events({ path, ...props }) {
(node) => {
if (isValidating) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isDone) {
setSize(size + 1);
}
});
if (node) observer.current.observe(node);
try {
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isDone) {
setSize(size + 1);
}
});
if (node) observer.current.observe(node);
} catch (e) {
// no op
}
},
[size, setSize, isValidating, isDone]
);
@@ -355,6 +376,11 @@ export default function Events({ path, ...props }) {
onSelectSingle={(item) => onFilter('sub_labels', item)}
/>
)}
{searchParams.event && (
<Button className="ml-2" onClick={() => onFilter('event', null)} type="text">
View All
</Button>
)}
<StarRecording
className="h-10 w-10 text-yellow-300 cursor-pointer ml-auto"
@@ -376,7 +402,7 @@ export default function Events({ path, ...props }) {
icon={Snapshot}
label="Download Snapshot"
value="snapshot"
href={`${apiHost}/api/events/${downloadEvent.id}/snapshot.jpg?download=true`}
href={`${apiHost}api/events/${downloadEvent.id}/snapshot.jpg?download=true`}
download
/>
)}
@@ -385,18 +411,21 @@ export default function Events({ path, ...props }) {
icon={Clip}
label="Download Clip"
value="clip"
href={`${apiHost}/api/events/${downloadEvent.id}/clip.mp4?download=true`}
href={`${apiHost}api/events/${downloadEvent.id}/clip.mp4?download=true`}
download
/>
)}
{(event?.data?.type || "object") == "object" && downloadEvent.end_time && downloadEvent.has_snapshot && !downloadEvent.plus_id && (
<MenuItem
icon={UploadPlus}
label={uploading.includes(downloadEvent.id) ? 'Uploading...' : 'Send to Frigate+'}
value="plus"
onSelect={() => showSubmitToPlus(downloadEvent.id, downloadEvent.label, downloadEvent.box)}
/>
)}
{(event?.data?.type || 'object') == 'object' &&
downloadEvent.end_time &&
downloadEvent.has_snapshot &&
!downloadEvent.plus_id && (
<MenuItem
icon={UploadPlus}
label={uploading.includes(downloadEvent.id) ? 'Uploading...' : 'Send to Frigate+'}
value="plus"
onSelect={() => showSubmitToPlus(downloadEvent.id, downloadEvent.label, downloadEvent.box)}
/>
)}
{downloadEvent.plus_id && (
<MenuItem
icon={UploadPlus}
@@ -449,10 +478,7 @@ export default function Events({ path, ...props }) {
dateRange={{ before: searchParams.before * 1000 || null, after: searchParams.after * 1000 || null }}
close={() => setState({ ...state, showCalendar: false })}
>
<Timepicker
dateRange={{ before: searchParams.before * 1000 || null, after: searchParams.after * 1000 || null }}
onChange={handleSelectDateRange}
/>
<Timepicker timeRange={searchParams.time_range} onChange={handleSelectTimeRange} />
</Calendar>
</Menu>
</span>
@@ -466,7 +492,7 @@ export default function Events({ path, ...props }) {
<img
className="flex-grow-0"
src={`${apiHost}/api/events/${plusSubmitEvent.id}/snapshot.jpg`}
src={`${apiHost}api/events/${plusSubmitEvent.id}/snapshot.jpg`}
alt={`${plusSubmitEvent.label}`}
/>
@@ -556,6 +582,13 @@ export default function Events({ path, ...props }) {
<p className="mb-2">Confirm deletion of saved event.</p>
</div>
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button
className="ml-2"
onClick={() => setDeleteFavoriteState({ ...state, showDeleteFavorite: false })}
type="text"
>
Cancel
</Button>
<Button
className="ml-2"
color="red"
@@ -586,7 +619,7 @@ export default function Events({ path, ...props }) {
<div
className="relative rounded-l flex-initial min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center"
style={{
'background-image': `url(${apiHost}/api/events/${event.id}/thumbnail.jpg)`,
'background-image': `url(${apiHost}api/events/${event.id}/thumbnail.jpg)`,
}}
>
<StarRecording
@@ -622,10 +655,12 @@ export default function Events({ path, ...props }) {
<Camera className="h-5 w-5 mr-2 inline" />
{event.camera.replaceAll('_', ' ')}
</div>
{event.zones.length ? <div className="capitalize text-sm flex align-center">
<Zone className="w-5 h-5 mr-2 inline" />
{event.zones.join(', ').replaceAll('_', ' ')}
</div> : null}
{event.zones.length ? (
<div className="capitalize text-sm flex align-center">
<Zone className="w-5 h-5 mr-2 inline" />
{event.zones.join(', ').replaceAll('_', ' ')}
</div>
) : null}
<div className="capitalize text-sm flex align-center">
<Score className="w-5 h-5 mr-2 inline" />
{(event?.data?.top_score || event.top_score || 0) == 0
@@ -637,7 +672,7 @@ export default function Events({ path, ...props }) {
</div>
</div>
<div class="hidden sm:flex flex-col justify-end mr-2">
{event.end_time && event.has_snapshot && (event?.data?.type || "object") == "object" && (
{event.end_time && event.has_snapshot && (event?.data?.type || 'object') == 'object' && (
<Fragment>
{event.plus_id ? (
<div className="uppercase text-xs underline">
@@ -741,8 +776,8 @@ export default function Events({ path, ...props }) {
className="flex-grow-0"
src={
event.has_snapshot
? `${apiHost}/api/events/${event.id}/snapshot.jpg`
: `${apiHost}/api/events/${event.id}/thumbnail.jpg`
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.jpg`
}
alt={`${event.label} at ${((event?.data?.top_score || event.top_score) * 100).toFixed(
0

View File

@@ -1,16 +1,22 @@
import Heading from '../components/Heading';
import { useState } from 'preact/hooks';
import useSWR from 'swr';
import useSWR, { mutate } from 'swr';
import Button from '../components/Button';
import axios from 'axios';
import { baseUrl } from '../api/baseUrl';
import { Fragment } from 'preact';
import ActivityIndicator from '../components/ActivityIndicator';
import { Play } from '../icons/Play';
import { Delete } from '../icons/Delete';
import LargeDialog from '../components/DialogLarge';
import VideoPlayer from '../components/VideoPlayer';
import Dialog from '../components/Dialog';
export default function Export() {
const { data: config } = useSWR('config');
const { data: exports } = useSWR('exports/', (url) => axios({ baseURL: baseUrl, url }).then((res) => res.data));
// Export States
const [camera, setCamera] = useState('select');
const [playback, setPlayback] = useState('select');
const [message, setMessage] = useState({ text: '', error: false });
@@ -22,9 +28,14 @@ export default function Export() {
const localISODate = localDate.toISOString().split('T')[0];
const [startDate, setStartDate] = useState(localISODate);
const [startTime, setStartTime] = useState('00:00');
const [startTime, setStartTime] = useState('00:00:00');
const [endDate, setEndDate] = useState(localISODate);
const [endTime, setEndTime] = useState('23:59');
const [endTime, setEndTime] = useState('23:59:59');
// Export States
const [selectedClip, setSelectedClip] = useState();
const [deleteClip, setDeleteClip] = useState();
const onHandleExport = () => {
if (camera == 'select') {
@@ -58,7 +69,7 @@ export default function Export() {
}
})
.catch((error) => {
if (error.response) {
if (error.response?.data?.message) {
setMessage({ text: `Failed to start export: ${error.response.data.message}`, error: true });
} else {
setMessage({ text: `Failed to start export: ${error.message}`, error: true });
@@ -66,6 +77,15 @@ export default function Export() {
});
};
const onHandleDelete = (clip) => {
axios.delete(`export/${clip}`).then((response) => {
if (response.status == 200) {
setDeleteClip();
mutate();
}
});
};
return (
<div className="space-y-4 p-2 px-4 w-full">
<Heading>Export</Heading>
@@ -74,6 +94,55 @@ export default function Export() {
<div className={`max-h-20 ${message.error ? 'text-red-500' : 'text-green-500'}`}>{message.text}</div>
)}
{selectedClip && (
<LargeDialog>
<div>
<Heading className="p-2">Playback</Heading>
<VideoPlayer
options={{
preload: 'auto',
autoplay: true,
sources: [
{
src: `${baseUrl}exports/${selectedClip}`,
type: 'video/mp4',
},
],
}}
seekOptions={{ forward: 10, backward: 5 }}
onReady={(player) => {
this.player = player;
}}
onDispose={() => {
this.player = null;
}}
/>
</div>
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button className="ml-2" onClick={() => setSelectedClip('')} type="text">
Close
</Button>
</div>
</LargeDialog>
)}
{deleteClip && (
<Dialog>
<div className="p-4">
<Heading size="lg">Delete Export?</Heading>
<p className="py-4 mb-2">Confirm deletion of {deleteClip}.</p>
</div>
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button className="ml-2" onClick={() => setDeleteClip('')} type="text">
Close
</Button>
<Button className="ml-2" color="red" onClick={() => onHandleDelete(deleteClip)} type="text">
Delete
</Button>
</div>
</Dialog>
)}
<div className="xl:flex justify-between">
<div>
<div>
@@ -116,6 +185,7 @@ export default function Export() {
id="startTime"
type="time"
value={startTime}
step="1"
onChange={(e) => setStartTime(e.target.value)}
/>
<Heading className="py-2" size="sm">
@@ -133,6 +203,7 @@ export default function Export() {
id="endTime"
type="time"
value={endTime}
step="1"
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
@@ -144,7 +215,11 @@ export default function Export() {
{exports && (
<div className="p-4 bg-gray-800 xl:w-1/2">
<Heading size="md">Exports</Heading>
<Exports exports={exports} />
<Exports
exports={exports}
onSetClip={(clip) => setSelectedClip(clip)}
onDeleteClip={(clip) => setDeleteClip(clip)}
/>
</div>
)}
</div>
@@ -152,7 +227,7 @@ export default function Export() {
);
}
function Exports({ exports }) {
function Exports({ exports, onSetClip, onDeleteClip }) {
return (
<Fragment>
{exports.map((item) => (
@@ -166,9 +241,19 @@ function Exports({ exports }) {
</div>
) : (
<div className="flex justify-start items-center">
<a className="text-blue-500 hover:underline" href={`${baseUrl}exports/${item.name}`} download>
<Button type="iconOnly" onClick={() => onSetClip(item.name)}>
<Play className="h-6 w-6 text-green-600" />
</Button>
<a
className="text-blue-500 hover:underline overflow-hidden"
href={`${baseUrl}exports/${item.name}`}
download
>
{item.name.substring(0, item.name.length - 4)}
</a>
<Button className="ml-auto" type="iconOnly" onClick={() => onDeleteClip(item.name)}>
<Delete className="h-6 w-6" stroke="#f87171" />
</Button>
</div>
)}
</div>

View File

@@ -27,8 +27,9 @@ export default function Storage() {
const getUnitSize = (MB) => {
if (isNaN(MB) || MB < 0) return 'Invalid number';
if (MB < 1024) return `${MB} MiB`;
if (MB < 1048576) return `${(MB / 1024).toFixed(2)} GiB`;
return `${(MB / 1024).toFixed(2)} GiB`;
return `${(MB / 1048576).toFixed(2)} TiB`;
};
let storage_usage;

View File

@@ -301,12 +301,16 @@ export default function System() {
<Tr>
<Th>GPU %</Th>
<Th>Memory %</Th>
{'dec' in gpu_usages[gpu] && (<Th>Decoder %</Th>)}
{'enc' in gpu_usages[gpu] && (<Th>Encoder %</Th>)}
</Tr>
</Thead>
<Tbody>
<Tr>
<Td>{gpu_usages[gpu]['gpu']}</Td>
<Td>{gpu_usages[gpu]['mem']}</Td>
{'dec' in gpu_usages[gpu] && (<Td>{gpu_usages[gpu]['dec']}</Td>)}
{'enc' in gpu_usages[gpu] && (<Td>{gpu_usages[gpu]['enc']}</Td>)}
</Tr>
</Tbody>
</Table>
@@ -334,80 +338,86 @@ export default function System() {
<ActivityIndicator />
) : (
<div data-testid="cameras" className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
{cameraNames.map((camera) => ( config.cameras[camera]["enabled"] && (
<div key={camera} className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow">
<div className="capitalize text-lg flex justify-between p-4">
<Link href={`/cameras/${camera}`}>{camera.replaceAll('_', ' ')}</Link>
<Button onClick={(e) => onHandleFfprobe(camera, e)}>ffprobe</Button>
</div>
<div className="p-2">
<Table className="w-full">
<Thead>
<Tr>
<Th>Process</Th>
<Th>P-ID</Th>
<Th>FPS</Th>
<Th>CPU %</Th>
<Th>Memory %</Th>
{config.telemetry.network_bandwidth && <Th>Network Bandwidth</Th>}
</Tr>
</Thead>
<Tbody>
<Tr key="ffmpeg" index="0">
<Td>
ffmpeg
<Button
className="rounded-full"
type="text"
color="gray"
aria-label={cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cmdline']}
onClick={() => copy(cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cmdline'])}
>
<About className="w-3" />
</Button>
</Td>
<Td>{cameras[camera]['ffmpeg_pid'] || '- '}</Td>
<Td>{cameras[camera]['camera_fps'] || '- '}</Td>
<Td>{cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['ffmpeg_pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && (
<Td>{bandwidth_usages[cameras[camera]['ffmpeg_pid']]?.['bandwidth'] || '- '}KB/s</Td>
)}
</Tr>
<Tr key="capture" index="1">
<Td>Capture</Td>
<Td>{cameras[camera]['capture_pid'] || '- '}</Td>
<Td>{cameras[camera]['process_fps'] || '- '}</Td>
<Td>{cpu_usages[cameras[camera]['capture_pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['capture_pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && <Td>-</Td>}
</Tr>
<Tr key="detect" index="2">
<Td>Detect</Td>
<Td>{cameras[camera]['pid'] || '- '}</Td>
{cameraNames.map(
(camera) =>
config.cameras[camera]['enabled'] && (
<div
key={camera}
className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow"
>
<div className="capitalize text-lg flex justify-between p-4">
<Link href={`/cameras/${camera}`}>{camera.replaceAll('_', ' ')}</Link>
<Button onClick={(e) => onHandleFfprobe(camera, e)}>ffprobe</Button>
</div>
<div className="p-2">
<Table className="w-full">
<Thead>
<Tr>
<Th>Process</Th>
<Th>P-ID</Th>
<Th>FPS</Th>
<Th>CPU %</Th>
<Th>Memory %</Th>
{config.telemetry.network_bandwidth && <Th>Network Bandwidth</Th>}
</Tr>
</Thead>
<Tbody>
<Tr key="ffmpeg" index="0">
<Td>
ffmpeg
<Button
className="rounded-full"
type="text"
color="gray"
aria-label={cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cmdline']}
onClick={() => copy(cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cmdline'])}
>
<About className="w-3" />
</Button>
</Td>
<Td>{cameras[camera]['ffmpeg_pid'] || '- '}</Td>
<Td>{cameras[camera]['camera_fps'] || '- '}</Td>
<Td>{cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['ffmpeg_pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && (
<Td>{bandwidth_usages[cameras[camera]['ffmpeg_pid']]?.['bandwidth'] || '- '}KB/s</Td>
)}
</Tr>
<Tr key="capture" index="1">
<Td>Capture</Td>
<Td>{cameras[camera]['capture_pid'] || '- '}</Td>
<Td>{cameras[camera]['process_fps'] || '- '}</Td>
<Td>{cpu_usages[cameras[camera]['capture_pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['capture_pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && <Td>-</Td>}
</Tr>
<Tr key="detect" index="2">
<Td>Detect</Td>
<Td>{cameras[camera]['pid'] || '- '}</Td>
{(() => {
if (cameras[camera]['pid'] && cameras[camera]['detection_enabled'] == 1)
return (
<Td>
{cameras[camera]['detection_fps']} ({cameras[camera]['skipped_fps']} skipped)
</Td>
);
else if (cameras[camera]['pid'] && cameras[camera]['detection_enabled'] == 0)
return <Td>disabled</Td>;
{(() => {
if (cameras[camera]['pid'] && cameras[camera]['detection_enabled'] == 1)
return (
<Td>
{cameras[camera]['detection_fps']} ({cameras[camera]['skipped_fps']} skipped)
</Td>
);
else if (cameras[camera]['pid'] && cameras[camera]['detection_enabled'] == 0)
return <Td>disabled</Td>;
return <Td>- </Td>;
})()}
return <Td>- </Td>;
})()}
<Td>{cpu_usages[cameras[camera]['pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && <Td>-</Td>}
</Tr>
</Tbody>
</Table>
</div>
</div> )
))}
<Td>{cpu_usages[cameras[camera]['pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && <Td>-</Td>}
</Tr>
</Tbody>
</Table>
</div>
</div>
)
)}
</div>
)}

View File

@@ -1,5 +1,6 @@
import { h } from 'preact';
import * as CameraImage from '../../components/CameraImage';
import * as Hooks from '../../hooks';
import * as WS from '../../api/ws';
import Cameras from '../Cameras';
import { fireEvent, render, screen, waitForElementToBeRemoved } from 'testing-library';
@@ -8,6 +9,7 @@ describe('Cameras Route', () => {
beforeEach(() => {
vi.spyOn(CameraImage, 'default').mockImplementation(() => <div data-testid="camera-image" />);
vi.spyOn(WS, 'useWs').mockImplementation(() => ({ value: { payload: 'OFF' }, send: vi.fn() }));
vi.spyOn(Hooks, 'useResizeObserver').mockImplementation(() => [{ width: 1000 }]);
});
test('shows an ActivityIndicator if not yet loaded', async () => {

View File

@@ -1,6 +1,7 @@
import { h } from 'preact';
import * as CameraImage from '../../components/CameraImage';
import * as WS from '../../api/ws';
import * as Hooks from '../../hooks';
import Cameras from '../Cameras';
import { render, screen, waitForElementToBeRemoved } from 'testing-library';
@@ -8,6 +9,7 @@ describe('Recording Route', () => {
beforeEach(() => {
vi.spyOn(CameraImage, 'default').mockImplementation(() => <div data-testid="camera-image" />);
vi.spyOn(WS, 'useWs').mockImplementation(() => ({ value: { payload: 'OFF' }, send: jest.fn() }));
vi.spyOn(Hooks, 'useResizeObserver').mockImplementation(() => [{ width: 1000 }]);
});
test('shows an ActivityIndicator if not yet loaded', async () => {

View File

@@ -9,6 +9,13 @@ export default defineConfig({
define: {
'import.meta.vitest': 'undefined',
},
server: {
proxy: {
'/api': {
target: 'http://localhost:5000'
}
}
},
plugins: [
preact(),
monacoEditorPlugin.default({