forked from Github/frigate
Compare commits
56 Commits
v0.13.0-be
...
update_cal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91ac4c4cee | ||
|
|
b85c488d7e | ||
|
|
fa6c6c50d0 | ||
|
|
4c7ea01137 | ||
|
|
34b315cc8c | ||
|
|
9b687d77ce | ||
|
|
e32bd4ab15 | ||
|
|
e19c0668e7 | ||
|
|
869bb2b177 | ||
|
|
3869b274e2 | ||
|
|
2379e6fd1b | ||
|
|
dcafcc1320 | ||
|
|
d508088bd0 | ||
|
|
97e5a98b95 | ||
|
|
68ebd55425 | ||
|
|
a82334ca1c | ||
|
|
d7ddcea951 | ||
|
|
cc6e049966 | ||
|
|
dbd21eb6fa | ||
|
|
e1a6398219 | ||
|
|
7f5fba08b7 | ||
|
|
c35e8371be | ||
|
|
49e7723405 | ||
|
|
52cc707eb8 | ||
|
|
c47b02d2fe | ||
|
|
3a460133d4 | ||
|
|
67a5a7d21a | ||
|
|
5d2b87e077 | ||
|
|
8298806028 | ||
|
|
8cc7acd591 | ||
|
|
8bde914939 | ||
|
|
fe9fcf3eaa | ||
|
|
20c2ab39bc | ||
|
|
08ef69bac4 | ||
|
|
79fabbb6b0 | ||
|
|
8941aa5311 | ||
|
|
14d2b79c72 | ||
|
|
9a4f970337 | ||
|
|
22b9507797 | ||
|
|
37379e6fba | ||
|
|
232588636f | ||
|
|
e77fedc445 | ||
|
|
ead03c381b | ||
|
|
0048cd5edc | ||
|
|
56dfcd7a32 | ||
|
|
9f3ac19e05 | ||
|
|
50f13b7196 | ||
|
|
50b17031c4 | ||
|
|
d11c1a2066 | ||
|
|
27144eb0b9 | ||
|
|
64705c065f | ||
|
|
08eefd8385 | ||
|
|
705ee54315 | ||
|
|
e26bb94007 | ||
|
|
1aba8c1ef5 | ||
|
|
f92237c9c1 |
@@ -1,4 +1,4 @@
|
|||||||
# syntax=docker/dockerfile:1.4
|
# syntax=docker/dockerfile:1.6
|
||||||
|
|
||||||
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
@@ -121,13 +121,15 @@ RUN apt-get -qq update \
|
|||||||
apt-transport-https \
|
apt-transport-https \
|
||||||
gnupg \
|
gnupg \
|
||||||
wget \
|
wget \
|
||||||
&& apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 648ACFD622F3D138 \
|
# the key fingerprint can be obtained from https://ftp-master.debian.org/keys.html
|
||||||
&& echo "deb http://deb.debian.org/debian bullseye main contrib non-free" | tee /etc/apt/sources.list.d/raspi.list \
|
&& 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 update \
|
||||||
&& apt-get -qq install -y \
|
&& apt-get -qq install -y \
|
||||||
python3.9 \
|
python3.9 \
|
||||||
python3.9-dev \
|
python3.9-dev \
|
||||||
wget \
|
|
||||||
# opencv dependencies
|
# opencv dependencies
|
||||||
build-essential cmake git pkg-config libgtk-3-dev \
|
build-essential cmake git pkg-config libgtk-3-dev \
|
||||||
libavcodec-dev libavformat-dev libswscale-dev libv4l-dev \
|
libavcodec-dev libavformat-dev libswscale-dev libv4l-dev \
|
||||||
@@ -199,6 +201,9 @@ ENV S6_LOGGING_SCRIPT="T 1 n0 s10000000 T"
|
|||||||
ENTRYPOINT ["/init"]
|
ENTRYPOINT ["/init"]
|
||||||
CMD []
|
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
|
# Frigate deps with Node.js and NPM for devcontainer
|
||||||
FROM deps AS devcontainer
|
FROM deps AS devcontainer
|
||||||
|
|
||||||
|
|||||||
@@ -55,13 +55,20 @@ fi
|
|||||||
|
|
||||||
# arch specific packages
|
# arch specific packages
|
||||||
if [[ "${TARGETARCH}" == "amd64" ]]; then
|
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
|
echo 'deb http://deb.debian.org/debian testing main non-free' >/etc/apt/sources.list.d/debian-testing.list
|
||||||
apt-get -qq update
|
apt-get -qq update
|
||||||
# intel-opencl-icd specifically for GPU support in OpenVino
|
# intel-opencl-icd specifically for GPU support in OpenVino
|
||||||
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
||||||
intel-opencl-icd \
|
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
|
# 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 \
|
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
||||||
i965-va-driver-shaders
|
i965-va-driver-shaders
|
||||||
|
|||||||
@@ -93,10 +93,6 @@ http {
|
|||||||
secure_token $args;
|
secure_token $args;
|
||||||
secure_token_types application/vnd.apple.mpegurl;
|
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";
|
add_header Cache-Control "no-store";
|
||||||
expires off;
|
expires off;
|
||||||
}
|
}
|
||||||
@@ -104,16 +100,6 @@ http {
|
|||||||
location /stream/ {
|
location /stream/ {
|
||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
expires off;
|
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 {
|
types {
|
||||||
application/dash+xml mpd;
|
application/dash+xml mpd;
|
||||||
@@ -126,16 +112,6 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /clips/ {
|
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 {
|
types {
|
||||||
video/mp4 mp4;
|
video/mp4 mp4;
|
||||||
@@ -152,17 +128,6 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /recordings/ {
|
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 {
|
types {
|
||||||
video/mp4 mp4;
|
video/mp4 mp4;
|
||||||
}
|
}
|
||||||
@@ -173,17 +138,6 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /exports/ {
|
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 {
|
types {
|
||||||
video/mp4 mp4;
|
video/mp4 mp4;
|
||||||
}
|
}
|
||||||
@@ -235,8 +189,6 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ~* /api/.*\.(jpg|jpeg|png)$ {
|
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;
|
rewrite ^/api/(.*)$ $1 break;
|
||||||
proxy_pass http://frigate_api;
|
proxy_pass http://frigate_api;
|
||||||
proxy_pass_request_headers on;
|
proxy_pass_request_headers on;
|
||||||
@@ -248,10 +200,6 @@ http {
|
|||||||
location /api/ {
|
location /api/ {
|
||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
expires off;
|
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 http://frigate_api/;
|
||||||
proxy_pass_request_headers on;
|
proxy_pass_request_headers on;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# syntax=docker/dockerfile:1.4
|
# syntax=docker/dockerfile:1.6
|
||||||
|
|
||||||
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
@@ -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 --from=trt-deps /usr/local/src/tensorrt_demos /usr/local/src/tensorrt_demos
|
||||||
COPY docker/tensorrt/detector/rootfs/ /
|
COPY docker/tensorrt/detector/rootfs/ /
|
||||||
ENV YOLO_MODELS="yolov7-320"
|
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
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ cameras:
|
|||||||
|
|
||||||
### Configuring Minimum Volume
|
### 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 widelely 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.
|
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
|
:::tip
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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 behavior
|
## 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.
|
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:
|
autotracking:
|
||||||
# Optional: enable/disable object autotracking. (default: shown below)
|
# Optional: enable/disable object autotracking. (default: shown below)
|
||||||
enabled: False
|
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)
|
# Optional: list of objects to track from labelmap.txt (default: shown below)
|
||||||
track:
|
track:
|
||||||
- person
|
- person
|
||||||
@@ -60,17 +79,47 @@ cameras:
|
|||||||
return_preset: home
|
return_preset: home
|
||||||
# Optional: Seconds to delay before returning to preset. (default: shown below)
|
# Optional: Seconds to delay before returning to preset. (default: shown below)
|
||||||
timeout: 10
|
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
|
## 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.
|
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.
|
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.
|

|
||||||
|
|
||||||
|
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
|
## Usage applications
|
||||||
|
|
||||||
|
|||||||
@@ -150,3 +150,7 @@ ffmpeg:
|
|||||||
record: preset-record-ubiquiti
|
record: preset-record-ubiquiti
|
||||||
rtmp: preset-rtmp-ubiquiti # recommend using go2rtc instead
|
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.`.
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ 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:
|
Each role can only be assigned to one input per camera. The options for roles are as follows:
|
||||||
|
|
||||||
| Role | Description |
|
| Role | Description |
|
||||||
| ---------- | ---------------------------------------------------------------------------------------- |
|
| -------- | ---------------------------------------------------------------------------------------- |
|
||||||
| `detect` | Main feed for object detection |
|
| `detect` | Main feed for object detection. [docs](object_detectors.md) |
|
||||||
| `record` | Saves segments of the video feed based on configuration settings. [docs](record.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) |
|
| `rtmp` | Deprecated: Broadcast as an RTMP feed for other services to consume. [docs](restream.md) |
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -51,13 +52,18 @@ For camera model specific settings check the [camera specific](camera_specific.m
|
|||||||
|
|
||||||
## Setting up camera PTZ controls
|
## 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
|
```yaml
|
||||||
cameras:
|
cameras:
|
||||||
back:
|
back:
|
||||||
ffmpeg:
|
ffmpeg: ...
|
||||||
...
|
|
||||||
onvif:
|
onvif:
|
||||||
host: 10.0.10.10
|
host: 10.0.10.10
|
||||||
port: 8000
|
port: 8000
|
||||||
@@ -65,6 +71,25 @@ cameras:
|
|||||||
password: password
|
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.
|
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 |
|
||||||
|
|||||||
@@ -64,11 +64,10 @@ ffmpeg:
|
|||||||
|
|
||||||
### Configuring Intel GPU Stats in Docker
|
### 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.
|
1. Run the container as privileged.
|
||||||
2. Adding the `CAP_PERFMON` capability.
|
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.)
|
||||||
3. Setting the `perf_event_paranoid` low enough to allow access to the performance event system.
|
|
||||||
|
|
||||||
#### Run as privileged
|
#### 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.
|
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
|
## AMD/ATI GPUs (Radeon HD 2000 and newer GPUs) via libva-mesa-driver
|
||||||
|
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ motion:
|
|||||||
# Low values will cause things like moving shadows to be detected as motion for longer.
|
# 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/
|
# https://www.geeksforgeeks.org/background-subtraction-in-an-image-using-concept-of-running-average/
|
||||||
frame_alpha: 0.01
|
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.
|
# 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.
|
# Lower values result in less CPU, but small changes may not register as motion.
|
||||||
frame_height: 100
|
frame_height: 100
|
||||||
@@ -527,7 +527,7 @@ cameras:
|
|||||||
# Required: List of x,y coordinates to define the polygon of the zone.
|
# 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.
|
# 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
|
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
|
inertia: 3
|
||||||
# Optional: List of objects that can trigger this zone (default: all tracked objects)
|
# Optional: List of objects that can trigger this zone (default: all tracked objects)
|
||||||
objects:
|
objects:
|
||||||
@@ -584,6 +584,23 @@ cameras:
|
|||||||
autotracking:
|
autotracking:
|
||||||
# Optional: enable/disable object autotracking. (default: shown below)
|
# Optional: enable/disable object autotracking. (default: shown below)
|
||||||
enabled: False
|
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)
|
# Optional: list of objects to track from labelmap.txt (default: shown below)
|
||||||
track:
|
track:
|
||||||
- person
|
- person
|
||||||
@@ -591,9 +608,11 @@ cameras:
|
|||||||
required_zones:
|
required_zones:
|
||||||
- zone_name
|
- zone_name
|
||||||
# Required: Name of ONVIF preset in camera's firmware to return to when tracking is over. (default: shown below)
|
# 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)
|
# Optional: Seconds to delay before returning to preset. (default: shown below)
|
||||||
timeout: 10
|
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.
|
# Optional: Configuration for how to sort the cameras in the Birdseye view.
|
||||||
birdseye:
|
birdseye:
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ 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:
|
Live view options can be selected while viewing the live stream. The options are:
|
||||||
|
|
||||||
| Source | Latency | Frame Rate | Resolution | Audio | Requires go2rtc | Other Limitations |
|
| 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 |
|
| 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 |
|
| 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 |
|
| webrtc | lowest | native | native | yes (depends on audio codec) | yes | requires extra config, doesn't support h.265 |
|
||||||
|
|
||||||
### Audio Support
|
### Audio Support
|
||||||
@@ -37,12 +37,12 @@ There may be some cameras that you would prefer to use the sub stream for live v
|
|||||||
```yaml
|
```yaml
|
||||||
go2rtc:
|
go2rtc:
|
||||||
streams:
|
streams:
|
||||||
rtsp_cam:
|
test_cam:
|
||||||
- rtsp://192.168.1.5:554/live0 # <- stream which supports video & aac audio.
|
- 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
|
- "ffmpeg:test_cam#audio=opus" # <- copy of the stream which transcodes audio to opus for webrtc
|
||||||
rtsp_cam_sub:
|
test_cam_sub:
|
||||||
- rtsp://192.168.1.5:554/substream # <- stream which supports video & aac audio.
|
- 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:
|
cameras:
|
||||||
test_cam:
|
test_cam:
|
||||||
@@ -59,7 +59,7 @@ cameras:
|
|||||||
roles:
|
roles:
|
||||||
- detect
|
- detect
|
||||||
live:
|
live:
|
||||||
stream_name: rtsp_cam_sub
|
stream_name: test_cam_sub
|
||||||
```
|
```
|
||||||
|
|
||||||
### WebRTC extra configuration:
|
### WebRTC extra configuration:
|
||||||
|
|||||||
@@ -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.
|
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
|
||||||
|
```
|
||||||
|
|||||||
@@ -155,10 +155,6 @@ cd web && npm install
|
|||||||
cd web && npm run dev
|
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
|
#### 4. Making changes
|
||||||
|
|
||||||
The Web UI is built using [Vite](https://vitejs.dev/), [Preact](https://preactjs.com), and [Tailwind CSS](https://tailwindcss.com).
|
The Web UI is built using [Vite](https://vitejs.dev/), [Preact](https://preactjs.com), and [Tailwind CSS](https://tailwindcss.com).
|
||||||
|
|||||||
@@ -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).
|
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
|
## Synology NAS on DSM 7
|
||||||
|
|
||||||
These settings were tested on DSM 7.1.1-42962 Update 4
|
These settings were tested on DSM 7.1.1-42962 Update 4
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ Version info
|
|||||||
Events from the database. Accepts the following query string parameters:
|
Events from the database. Accepts the following query string parameters:
|
||||||
|
|
||||||
| param | Type | Description |
|
| param | Type | Description |
|
||||||
| -------------------- | ---- | --------------------------------------------- |
|
| -------------------- | ----- | ----------------------------------------------------- |
|
||||||
| `before` | int | Epoch time |
|
| `before` | int | Epoch time |
|
||||||
| `after` | int | Epoch time |
|
| `after` | int | Epoch time |
|
||||||
| `cameras` | str | , separated list of cameras |
|
| `cameras` | str | , separated list of cameras |
|
||||||
@@ -167,6 +167,13 @@ Events from the database. Accepts the following query string parameters:
|
|||||||
| `has_clip` | int | Filter to events that have clips (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) |
|
| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) |
|
||||||
| `in_progress` | int | Limit to events in progress (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`
|
### `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.
|
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.
|
Returns the snapshot image from the specific point in that cameras recordings.
|
||||||
|
|
||||||
@@ -315,11 +322,17 @@ Get PTZ info for the camera.
|
|||||||
|
|
||||||
Create a manual event 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:**
|
**Optional Body:**
|
||||||
|
|
||||||
```json
|
```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
|
"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
|
"include_recording": true, // whether the event should save recordings along with the snapshot that is taken
|
||||||
"draw": {
|
"draw": {
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ Information on how to integrate Frigate+ with Frigate can be found in the [integ
|
|||||||
|
|
||||||
## Frequently asked questions
|
## Frequently asked questions
|
||||||
|
|
||||||
While developing these models, there were some common questions that arose.
|
### 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?
|
### Are my video feeds sent to the cloud for analysis when using Frigate+ models?
|
||||||
|
|
||||||
@@ -64,10 +70,10 @@ objects:
|
|||||||
fedex:
|
fedex:
|
||||||
min_score: .75
|
min_score: .75
|
||||||
person:
|
person:
|
||||||
min_score: .8
|
min_score: .65
|
||||||
threshold: .85
|
threshold: .85
|
||||||
car:
|
car:
|
||||||
min_score: .8
|
min_score: .65
|
||||||
threshold: .85
|
threshold: .85
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -79,6 +85,23 @@ Frigate+ models support a more relevant set of objects for security cameras. Cur
|
|||||||
|
|
||||||
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.
|
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.
|
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.
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -21,8 +21,8 @@ module.exports = {
|
|||||||
{
|
{
|
||||||
type: "link",
|
type: "link",
|
||||||
label: "Go2RTC Configuration Reference",
|
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: [
|
Detectors: [
|
||||||
"configuration/object_detectors",
|
"configuration/object_detectors",
|
||||||
@@ -57,16 +57,11 @@ module.exports = {
|
|||||||
"integrations/mqtt",
|
"integrations/mqtt",
|
||||||
"integrations/third_party_extensions",
|
"integrations/third_party_extensions",
|
||||||
],
|
],
|
||||||
"Frigate+": [
|
"Frigate+": ["plus/index"],
|
||||||
"plus/index"
|
Troubleshooting: ["troubleshooting/faqs", "troubleshooting/recordings"],
|
||||||
],
|
|
||||||
Troubleshooting: [
|
|
||||||
"troubleshooting/faqs",
|
|
||||||
"troubleshooting/recordings",
|
|
||||||
],
|
|
||||||
Development: [
|
Development: [
|
||||||
"development/contributing",
|
"development/contributing",
|
||||||
"development/contributing-boards"
|
"development/contributing-boards",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
BIN
docs/static/img/autotracking-debug.gif
vendored
Normal file
BIN
docs/static/img/autotracking-debug.gif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 MiB |
BIN
docs/static/img/frigate-autotracking-example.gif
vendored
Normal file
BIN
docs/static/img/frigate-autotracking-example.gif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 MiB |
@@ -163,6 +163,8 @@ class FrigateApp:
|
|||||||
"frame_queue": mp.Queue(maxsize=2),
|
"frame_queue": mp.Queue(maxsize=2),
|
||||||
"capture_process": None,
|
"capture_process": None,
|
||||||
"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] = {
|
self.ptz_metrics[camera_name] = {
|
||||||
"ptz_autotracker_enabled": mp.Value( # type: ignore[typeddict-item]
|
"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]
|
"ptz_stop_time": mp.Value("d", 0.0), # type: ignore[typeddict-item]
|
||||||
# issue https://github.com/python/typeshed/issues/8799
|
# issue https://github.com/python/typeshed/issues/8799
|
||||||
# from mypy 0.981 onwards
|
# 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.ptz_metrics[camera_name]["ptz_stopped"].set()
|
||||||
self.feature_metrics[camera_name] = {
|
self.feature_metrics[camera_name] = {
|
||||||
@@ -494,6 +502,7 @@ class FrigateApp:
|
|||||||
args=(
|
args=(
|
||||||
self.config,
|
self.config,
|
||||||
self.audio_recordings_info_queue,
|
self.audio_recordings_info_queue,
|
||||||
|
self.camera_metrics,
|
||||||
self.feature_metrics,
|
self.feature_metrics,
|
||||||
self.inter_process_communicator,
|
self.inter_process_communicator,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from pydantic import BaseModel, Extra, Field, parse_obj_as, validator
|
|||||||
from pydantic.fields import PrivateAttr
|
from pydantic.fields import PrivateAttr
|
||||||
|
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
|
ALL_ATTRIBUTE_LABELS,
|
||||||
AUDIO_MIN_CONFIDENCE,
|
AUDIO_MIN_CONFIDENCE,
|
||||||
CACHE_DIR,
|
CACHE_DIR,
|
||||||
DEFAULT_DB_PATH,
|
DEFAULT_DB_PATH,
|
||||||
@@ -138,8 +139,26 @@ class MqttConfig(FrigateBaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ZoomingModeEnum(str, Enum):
|
||||||
|
disabled = "disabled"
|
||||||
|
absolute = "absolute"
|
||||||
|
relative = "relative"
|
||||||
|
|
||||||
|
|
||||||
class PtzAutotrackConfig(FrigateBaseModel):
|
class PtzAutotrackConfig(FrigateBaseModel):
|
||||||
enabled: bool = Field(default=False, title="Enable PTZ object autotracking.")
|
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.")
|
track: List[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.")
|
||||||
required_zones: List[str] = Field(
|
required_zones: List[str] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
@@ -152,6 +171,27 @@ class PtzAutotrackConfig(FrigateBaseModel):
|
|||||||
timeout: int = Field(
|
timeout: int = Field(
|
||||||
default=10, title="Seconds to delay before returning to preset."
|
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):
|
class OnvifConfig(FrigateBaseModel):
|
||||||
@@ -434,7 +474,7 @@ class ZoneConfig(BaseModel):
|
|||||||
|
|
||||||
class ObjectConfig(FrigateBaseModel):
|
class ObjectConfig(FrigateBaseModel):
|
||||||
track: List[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.")
|
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.")
|
mask: Union[str, List[str]] = Field(default="", title="Object mask.")
|
||||||
|
|
||||||
|
|
||||||
@@ -1038,6 +1078,13 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
config.mqtt.user = config.mqtt.user.format(**FRIGATE_ENV_VARS)
|
config.mqtt.user = config.mqtt.user.format(**FRIGATE_ENV_VARS)
|
||||||
config.mqtt.password = config.mqtt.password.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 to propagate down to camera level
|
||||||
global_config = config.dict(
|
global_config = config.dict(
|
||||||
include={
|
include={
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import requests
|
|||||||
from setproctitle import setproctitle
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
from frigate.comms.inter_process import InterProcessCommunicator
|
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 (
|
from frigate.const import (
|
||||||
AUDIO_DURATION,
|
AUDIO_DURATION,
|
||||||
AUDIO_FORMAT,
|
AUDIO_FORMAT,
|
||||||
@@ -26,7 +26,7 @@ from frigate.const import (
|
|||||||
from frigate.ffmpeg_presets import parse_preset_input
|
from frigate.ffmpeg_presets import parse_preset_input
|
||||||
from frigate.log import LogPipe
|
from frigate.log import LogPipe
|
||||||
from frigate.object_detection import load_labels
|
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.builtin import get_ffmpeg_arg_list
|
||||||
from frigate.util.services import listen
|
from frigate.util.services import listen
|
||||||
from frigate.video import start_or_restart_ffmpeg, stop_ffmpeg
|
from frigate.video import start_or_restart_ffmpeg, stop_ffmpeg
|
||||||
@@ -39,19 +39,36 @@ except ModuleNotFoundError:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_ffmpeg_command(input_args: list[str], input_path: str) -> list[str]:
|
def get_ffmpeg_command(ffmpeg: FfmpegConfig) -> list[str]:
|
||||||
return get_ffmpeg_arg_list(
|
ffmpeg_input: CameraInput = [i for i in ffmpeg.inputs if "audio" in i.roles][0]
|
||||||
f"ffmpeg {{}} -i {{}} -f {AUDIO_FORMAT} -ar {AUDIO_SAMPLE_RATE} -ac 1 -y {{}}".format(
|
input_args = get_ffmpeg_arg_list(ffmpeg.global_args) + (
|
||||||
" ".join(input_args),
|
parse_preset_input(ffmpeg_input.input_args, 1)
|
||||||
input_path,
|
or ffmpeg_input.input_args
|
||||||
"pipe:",
|
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(
|
def listen_to_audio(
|
||||||
config: FrigateConfig,
|
config: FrigateConfig,
|
||||||
recordings_info_queue: mp.Queue,
|
recordings_info_queue: mp.Queue,
|
||||||
|
camera_metrics: dict[str, CameraMetricsTypes],
|
||||||
process_info: dict[str, FeatureMetricsTypes],
|
process_info: dict[str, FeatureMetricsTypes],
|
||||||
inter_process_communicator: InterProcessCommunicator,
|
inter_process_communicator: InterProcessCommunicator,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -80,6 +97,7 @@ def listen_to_audio(
|
|||||||
audio = AudioEventMaintainer(
|
audio = AudioEventMaintainer(
|
||||||
camera,
|
camera,
|
||||||
recordings_info_queue,
|
recordings_info_queue,
|
||||||
|
camera_metrics,
|
||||||
process_info,
|
process_info,
|
||||||
stop_event,
|
stop_event,
|
||||||
inter_process_communicator,
|
inter_process_communicator,
|
||||||
@@ -153,6 +171,7 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
self,
|
self,
|
||||||
camera: CameraConfig,
|
camera: CameraConfig,
|
||||||
recordings_info_queue: mp.Queue,
|
recordings_info_queue: mp.Queue,
|
||||||
|
camera_metrics: dict[str, CameraMetricsTypes],
|
||||||
feature_metrics: dict[str, FeatureMetricsTypes],
|
feature_metrics: dict[str, FeatureMetricsTypes],
|
||||||
stop_event: mp.Event,
|
stop_event: mp.Event,
|
||||||
inter_process_communicator: InterProcessCommunicator,
|
inter_process_communicator: InterProcessCommunicator,
|
||||||
@@ -161,19 +180,16 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
self.name = f"{camera.name}_audio_event_processor"
|
self.name = f"{camera.name}_audio_event_processor"
|
||||||
self.config = camera
|
self.config = camera
|
||||||
self.recordings_info_queue = recordings_info_queue
|
self.recordings_info_queue = recordings_info_queue
|
||||||
|
self.camera_metrics = camera_metrics
|
||||||
self.feature_metrics = feature_metrics
|
self.feature_metrics = feature_metrics
|
||||||
self.inter_process_communicator = inter_process_communicator
|
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.stop_event = stop_event
|
||||||
self.detector = AudioTfl(stop_event, self.config.audio.num_threads)
|
self.detector = AudioTfl(stop_event, self.config.audio.num_threads)
|
||||||
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
|
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
|
||||||
self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2))
|
self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2))
|
||||||
self.logger = logging.getLogger(f"audio.{self.config.name}")
|
self.logger = logging.getLogger(f"audio.{self.config.name}")
|
||||||
self.ffmpeg_cmd = get_ffmpeg_command(
|
self.ffmpeg_cmd = get_ffmpeg_command(self.config.ffmpeg)
|
||||||
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.logpipe = LogPipe(f"ffmpeg.{self.config.name}.audio")
|
self.logpipe = LogPipe(f"ffmpeg.{self.config.name}.audio")
|
||||||
self.audio_listener = None
|
self.audio_listener = None
|
||||||
|
|
||||||
@@ -184,6 +200,9 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
audio_as_float = audio.astype(np.float32)
|
audio_as_float = audio.astype(np.float32)
|
||||||
rms, dBFS = self.calculate_audio_levels(audio_as_float)
|
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
|
# only run audio detection when volume is above min_volume
|
||||||
if rms >= self.config.audio.min_volume:
|
if rms >= self.config.audio.min_volume:
|
||||||
# add audio info to recordings queue
|
# add audio info to recordings queue
|
||||||
@@ -196,6 +215,8 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
model_detections = self.detector.detect(waveform)
|
model_detections = self.detector.detect(waveform)
|
||||||
|
|
||||||
for label, score, _ in model_detections:
|
for label, score, _ in model_detections:
|
||||||
|
logger.debug(f"Heard {label} with a score of {score}")
|
||||||
|
|
||||||
if label not in self.config.audio.listen:
|
if label not in self.config.audio.listen:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ def should_update_state(prev_event: Event, current_event: Event) -> bool:
|
|||||||
if prev_event["stationary"] != current_event["stationary"]:
|
if prev_event["stationary"] != current_event["stationary"]:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
if prev_event["attributes"] != current_event["attributes"]:
|
||||||
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -256,13 +256,6 @@ PRESETS_INPUT = {
|
|||||||
"-use_wallclock_as_timestamps",
|
"-use_wallclock_as_timestamps",
|
||||||
"1",
|
"1",
|
||||||
],
|
],
|
||||||
"preset-rtsp-audio-only": [
|
|
||||||
"-rtsp_transport",
|
|
||||||
"tcp",
|
|
||||||
TIMEOUT_PARAM,
|
|
||||||
"5000000",
|
|
||||||
"-vn",
|
|
||||||
],
|
|
||||||
"preset-rtsp-restream": _user_agent_args
|
"preset-rtsp-restream": _user_agent_args
|
||||||
+ [
|
+ [
|
||||||
"-rtsp_transport",
|
"-rtsp_transport",
|
||||||
|
|||||||
259
frigate/http.py
259
frigate/http.py
@@ -20,6 +20,7 @@ from flask import (
|
|||||||
Flask,
|
Flask,
|
||||||
Response,
|
Response,
|
||||||
current_app,
|
current_app,
|
||||||
|
escape,
|
||||||
jsonify,
|
jsonify,
|
||||||
make_response,
|
make_response,
|
||||||
request,
|
request,
|
||||||
@@ -28,6 +29,7 @@ from peewee import DoesNotExist, fn, operator
|
|||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
from playhouse.sqliteq import SqliteQueueDatabase
|
from playhouse.sqliteq import SqliteQueueDatabase
|
||||||
from tzlocal import get_localzone_name
|
from tzlocal import get_localzone_name
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
@@ -56,6 +58,8 @@ from frigate.version import VERSION
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_TIME_RANGE = "00:00,24:00"
|
||||||
|
|
||||||
bp = Blueprint("frigate", __name__)
|
bp = Blueprint("frigate", __name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -71,6 +75,13 @@ def create_app(
|
|||||||
):
|
):
|
||||||
app = Flask(__name__)
|
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
|
@app.before_request
|
||||||
def _db_connect():
|
def _db_connect():
|
||||||
if database.is_closed():
|
if database.is_closed():
|
||||||
@@ -530,10 +541,14 @@ def event_thumbnail(id, max_cache_age=2592000):
|
|||||||
if tracked_obj is not None:
|
if tracked_obj is not None:
|
||||||
thumbnail_bytes = tracked_obj.get_thumbnail()
|
thumbnail_bytes = tracked_obj.get_thumbnail()
|
||||||
except Exception:
|
except Exception:
|
||||||
return "Event not found", 404
|
return make_response(
|
||||||
|
jsonify({"success": False, "message": "Event not found"}), 404
|
||||||
|
)
|
||||||
|
|
||||||
if thumbnail_bytes is None:
|
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
|
# android notifications prefer a 2:1 ratio
|
||||||
if format == "android":
|
if format == "android":
|
||||||
@@ -628,7 +643,9 @@ def event_snapshot(id):
|
|||||||
event = Event.get(Event.id == id, Event.end_time != None)
|
event = Event.get(Event.id == id, Event.end_time != None)
|
||||||
event_complete = True
|
event_complete = True
|
||||||
if not event.has_snapshot:
|
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
|
# read snapshot from disk
|
||||||
with open(
|
with open(
|
||||||
os.path.join(CLIPS_DIR, f"{event.camera}-{id}.jpg"), "rb"
|
os.path.join(CLIPS_DIR, f"{event.camera}-{id}.jpg"), "rb"
|
||||||
@@ -650,12 +667,18 @@ def event_snapshot(id):
|
|||||||
quality=request.args.get("quality", default=70, type=int),
|
quality=request.args.get("quality", default=70, type=int),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return "Event not found", 404
|
return make_response(
|
||||||
|
jsonify({"success": False, "message": "Event not found"}), 404
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return "Event not found", 404
|
return make_response(
|
||||||
|
jsonify({"success": False, "message": "Event not found"}), 404
|
||||||
|
)
|
||||||
|
|
||||||
if jpg_bytes is None:
|
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 = make_response(jpg_bytes)
|
||||||
response.headers["Content-Type"] = "image/jpeg"
|
response.headers["Content-Type"] = "image/jpeg"
|
||||||
@@ -708,10 +731,14 @@ def event_clip(id):
|
|||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == id)
|
event: Event = Event.get(Event.id == id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return "Event not found.", 404
|
return make_response(
|
||||||
|
jsonify({"success": False, "message": "Event not found"}), 404
|
||||||
|
)
|
||||||
|
|
||||||
if not event.has_clip:
|
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"
|
file_name = f"{event.camera}-{id}.mp4"
|
||||||
clip_path = os.path.join(CLIPS_DIR, file_name)
|
clip_path = os.path.join(CLIPS_DIR, file_name)
|
||||||
@@ -769,11 +796,17 @@ def events():
|
|||||||
limit = request.args.get("limit", 100)
|
limit = request.args.get("limit", 100)
|
||||||
after = request.args.get("after", type=float)
|
after = request.args.get("after", type=float)
|
||||||
before = request.args.get("before", 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_clip = request.args.get("has_clip", type=int)
|
||||||
has_snapshot = request.args.get("has_snapshot", type=int)
|
has_snapshot = request.args.get("has_snapshot", type=int)
|
||||||
in_progress = request.args.get("in_progress", type=int)
|
in_progress = request.args.get("in_progress", type=int)
|
||||||
include_thumbnails = request.args.get("include_thumbnails", default=1, type=int)
|
include_thumbnails = request.args.get("include_thumbnails", default=1, type=int)
|
||||||
favorites = request.args.get("favorites", 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 = []
|
clauses = []
|
||||||
|
|
||||||
@@ -851,6 +884,36 @@ def events():
|
|||||||
if before:
|
if before:
|
||||||
clauses.append((Event.start_time < 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:
|
if has_clip is not None:
|
||||||
clauses.append((Event.has_clip == has_clip))
|
clauses.append((Event.has_clip == has_clip))
|
||||||
|
|
||||||
@@ -866,6 +929,24 @@ def events():
|
|||||||
if favorites:
|
if favorites:
|
||||||
clauses.append((Event.retain_indefinitely == 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:
|
if len(clauses) == 0:
|
||||||
clauses.append((True))
|
clauses.append((True))
|
||||||
|
|
||||||
@@ -986,7 +1067,9 @@ def config_raw():
|
|||||||
config_file = config_file_yaml
|
config_file = config_file_yaml
|
||||||
|
|
||||||
if not os.path.isfile(config_file):
|
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:
|
with open(config_file, "r") as f:
|
||||||
raw_config = f.read()
|
raw_config = f.read()
|
||||||
@@ -1002,7 +1085,12 @@ def config_save():
|
|||||||
new_config = request.get_data().decode()
|
new_config = request.get_data().decode()
|
||||||
|
|
||||||
if not new_config:
|
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
|
# Validate the config schema
|
||||||
try:
|
try:
|
||||||
@@ -1012,7 +1100,7 @@ def config_save():
|
|||||||
jsonify(
|
jsonify(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": f"\nConfig Error:\n\n{str(traceback.format_exc())}",
|
"message": f"\nConfig Error:\n\n{escape(str(traceback.format_exc()))}",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
400,
|
400,
|
||||||
@@ -1047,14 +1135,30 @@ def config_save():
|
|||||||
restart_frigate()
|
restart_frigate()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error restarting Frigate: {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 (
|
return make_response(
|
||||||
"Config successfully saved, restarting (this can take up to one minute)...",
|
jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "Config successfully saved, restarting (this can take up to one minute)...",
|
||||||
|
}
|
||||||
|
),
|
||||||
200,
|
200,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return "Config successfully saved.", 200
|
return make_response(
|
||||||
|
jsonify({"success": True, "message": "Config successfully saved."}),
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config/set", methods=["PUT"])
|
@bp.route("/config/set", methods=["PUT"])
|
||||||
@@ -1094,9 +1198,20 @@ def config_set():
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error updating config: {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")
|
@bp.route("/config/schema.json")
|
||||||
@@ -1146,7 +1261,10 @@ def mjpeg_feed(camera_name):
|
|||||||
mimetype="multipart/x-mixed-replace; boundary=frame",
|
mimetype="multipart/x-mixed-replace; boundary=frame",
|
||||||
)
|
)
|
||||||
else:
|
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")
|
@bp.route("/<camera_name>/ptz/info")
|
||||||
@@ -1154,7 +1272,10 @@ def camera_ptz_info(camera_name):
|
|||||||
if camera_name in current_app.frigate_config.cameras:
|
if camera_name in current_app.frigate_config.cameras:
|
||||||
return jsonify(current_app.onvif.get_camera_info(camera_name))
|
return jsonify(current_app.onvif.get_camera_info(camera_name))
|
||||||
else:
|
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")
|
@bp.route("/<camera_name>/latest.jpg")
|
||||||
@@ -1196,7 +1317,10 @@ def latest_frame(camera_name):
|
|||||||
width = int(height * frame.shape[1] / frame.shape[0])
|
width = int(height * frame.shape[1] / frame.shape[0])
|
||||||
|
|
||||||
if frame is None:
|
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:
|
if height < 1 or width < 1:
|
||||||
return (
|
return (
|
||||||
@@ -1232,7 +1356,10 @@ def latest_frame(camera_name):
|
|||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
return response
|
return response
|
||||||
else:
|
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")
|
@bp.route("/<camera_name>/recordings/<frame_time>/snapshot.png")
|
||||||
@@ -1247,7 +1374,10 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str):
|
|||||||
Recordings.start_time,
|
Recordings.start_time,
|
||||||
)
|
)
|
||||||
.where(
|
.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)
|
.where(Recordings.camera == camera_name)
|
||||||
)
|
)
|
||||||
@@ -1282,7 +1412,15 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str):
|
|||||||
response.headers["Content-Type"] = "image/png"
|
response.headers["Content-Type"] = "image/png"
|
||||||
return response
|
return response
|
||||||
except DoesNotExist:
|
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"])
|
@bp.route("/recordings/storage", methods=["GET"])
|
||||||
@@ -1484,7 +1622,15 @@ def recording_clip(camera_name, start_ts, end_ts):
|
|||||||
|
|
||||||
if p.returncode != 0:
|
if p.returncode != 0:
|
||||||
logger.error(p.stderr)
|
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:
|
else:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Ignoring subsequent request for {path} as it already exists in the cache."
|
f"Ignoring subsequent request for {path} as it already exists in the cache."
|
||||||
@@ -1540,7 +1686,15 @@ def vod_ts(camera_name, start_ts, end_ts):
|
|||||||
|
|
||||||
if not clips:
|
if not clips:
|
||||||
logger.error("No recordings found for the requested time range")
|
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)
|
hour_ago = datetime.now() - timedelta(hours=1)
|
||||||
return jsonify(
|
return jsonify(
|
||||||
@@ -1583,11 +1737,27 @@ def vod_event(id):
|
|||||||
event: Event = Event.get(Event.id == id)
|
event: Event = Event.get(Event.id == id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
logger.error(f"Event not found: {id}")
|
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:
|
if not event.has_clip:
|
||||||
logger.error(f"Event does not have recordings: {id}")
|
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")
|
clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{id}.mp4")
|
||||||
|
|
||||||
@@ -1664,12 +1834,21 @@ def export_recording(camera_name: str, start_time, end_time):
|
|||||||
else PlaybackFactorEnum.realtime,
|
else PlaybackFactorEnum.realtime,
|
||||||
)
|
)
|
||||||
exporter.start()
|
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"])
|
@bp.route("/export/<file_name>", methods=["DELETE"])
|
||||||
def export_delete(file_name: str):
|
def export_delete(file_name: str):
|
||||||
file = os.path.join(EXPORT_DIR, file_name)
|
safe_file_name = secure_filename(file_name)
|
||||||
|
file = os.path.join(EXPORT_DIR, safe_file_name)
|
||||||
|
|
||||||
if not os.path.exists(file):
|
if not os.path.exists(file):
|
||||||
return make_response(
|
return make_response(
|
||||||
@@ -1678,7 +1857,15 @@ def export_delete(file_name: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
os.unlink(file)
|
os.unlink(file)
|
||||||
return "Successfully deleted file", 200
|
return make_response(
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "Successfully deleted file.",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
|
def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
|
||||||
@@ -1778,8 +1965,11 @@ def logs(service: str):
|
|||||||
}
|
}
|
||||||
service_location = log_locations.get(service)
|
service_location = log_locations.get(service)
|
||||||
|
|
||||||
if not service:
|
if not service_location:
|
||||||
return f"{service} is not a valid service", 404
|
return make_response(
|
||||||
|
jsonify({"success": False, "message": "Not a valid service"}),
|
||||||
|
404,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file = open(service_location, "r")
|
file = open(service_location, "r")
|
||||||
@@ -1787,4 +1977,7 @@ def logs(service: str):
|
|||||||
file.close()
|
file.close()
|
||||||
return contents, 200
|
return contents, 200
|
||||||
except FileNotFoundError as e:
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -20,3 +20,7 @@ class MotionDetector(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def detect(self, frame):
|
def detect(self, frame):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_calibrating(self):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ class FrigateMotionDetector(MotionDetector):
|
|||||||
self.threshold = threshold
|
self.threshold = threshold
|
||||||
self.contour_area = contour_area
|
self.contour_area = contour_area
|
||||||
|
|
||||||
|
def is_calibrating(self):
|
||||||
|
return False
|
||||||
|
|
||||||
def detect(self, frame):
|
def detect(self, frame):
|
||||||
motion_boxes = []
|
motion_boxes = []
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ class ImprovedMotionDetector(MotionDetector):
|
|||||||
self.contrast_values[:, 1:2] = 255
|
self.contrast_values[:, 1:2] = 255
|
||||||
self.contrast_values_index = 0
|
self.contrast_values_index = 0
|
||||||
|
|
||||||
|
def is_calibrating(self):
|
||||||
|
return self.calibrating
|
||||||
|
|
||||||
def detect(self, frame):
|
def detect(self, frame):
|
||||||
motion_boxes = []
|
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 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:
|
if self.calibrating or pct_motion > self.config.lightning_threshold:
|
||||||
motion_boxes = []
|
|
||||||
self.calibrating = True
|
self.calibrating = True
|
||||||
|
|
||||||
if self.save_images:
|
if self.save_images:
|
||||||
|
|||||||
@@ -232,6 +232,9 @@ class TrackedObject:
|
|||||||
if self.obj_data["position_changes"] != obj_data["position_changes"]:
|
if self.obj_data["position_changes"] != obj_data["position_changes"]:
|
||||||
significant_change = True
|
significant_change = True
|
||||||
|
|
||||||
|
if self.obj_data["attributes"] != obj_data["attributes"]:
|
||||||
|
significant_change = True
|
||||||
|
|
||||||
# if the motionless_count reaches the stationary threshold
|
# if the motionless_count reaches the stationary threshold
|
||||||
if (
|
if (
|
||||||
self.obj_data["motionless_count"]
|
self.obj_data["motionless_count"]
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from frigate.util.image import (
|
|||||||
logger = logging.getLogger(__name__)
|
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."""
|
"""Ensure that only standard aspect ratios are used."""
|
||||||
known_aspects = [
|
known_aspects = [
|
||||||
(16, 9),
|
(16, 9),
|
||||||
@@ -52,6 +52,22 @@ def get_standard_aspect_ratio(width, height) -> tuple[int, int]:
|
|||||||
return known_aspects[known_aspects_ratios.index(closest)]
|
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:
|
class Canvas:
|
||||||
def __init__(self, canvas_width: int, canvas_height: int) -> None:
|
def __init__(self, canvas_width: int, canvas_height: int) -> None:
|
||||||
gcd = math.gcd(canvas_width, canvas_height)
|
gcd = math.gcd(canvas_width, canvas_height)
|
||||||
@@ -226,8 +242,7 @@ class BirdsEyeFrameManager:
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.mode = config.birdseye.mode
|
self.mode = config.birdseye.mode
|
||||||
self.frame_manager = frame_manager
|
self.frame_manager = frame_manager
|
||||||
width = config.birdseye.width
|
width, height = get_canvas_shape(config.birdseye.width, config.birdseye.height)
|
||||||
height = config.birdseye.height
|
|
||||||
self.frame_shape = (height, width)
|
self.frame_shape = (height, width)
|
||||||
self.yuv_shape = (height * 3 // 2, width)
|
self.yuv_shape = (height * 3 // 2, width)
|
||||||
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
|
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
import math
|
import os
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -11,11 +11,17 @@ from multiprocessing.synchronize import Event as MpEvent
|
|||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
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.ptz.onvif import OnvifController
|
||||||
from frigate.types import PTZMetricsTypes
|
from frigate.types import PTZMetricsTypes
|
||||||
|
from frigate.util.builtin import update_yaml_file
|
||||||
from frigate.util.image import SharedMemoryFrameManager, intersection_over_union
|
from frigate.util.image import SharedMemoryFrameManager, intersection_over_union
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# for non ptz/autotracking cameras, this will always return False
|
||||||
# ptz_start_time is initialized to 0 on startup and only changes
|
# ptz_start_time is initialized to 0 on startup and only changes
|
||||||
# when autotracking movements are made
|
# when autotracking movements are made
|
||||||
|
return (ptz_start_time != 0.0 and frame_time > ptz_start_time) and (
|
||||||
# the offset "primes" the motion estimator with a few frames before movement
|
ptz_stop_time == 0.0 or (ptz_start_time <= frame_time <= ptz_stop_time)
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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 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():
|
if self.ptz_metrics["ptz_reset"].is_set():
|
||||||
self.ptz_metrics["ptz_reset"].clear()
|
self.ptz_metrics["ptz_reset"].clear()
|
||||||
logger.debug("Motion estimator reset")
|
|
||||||
# homography is nice (zooming) but slow, translation is pan/tilt only but fast.
|
# 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(
|
self.norfair_motion_estimator = MotionEstimator(
|
||||||
transformations_getter=TranslationTransformationGetter(),
|
transformations_getter=transformation_type,
|
||||||
min_distance=30,
|
min_distance=30,
|
||||||
max_points=900,
|
max_points=900,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.coord_transformations = None
|
self.coord_transformations = None
|
||||||
|
|
||||||
if ptz_moving_at_frame_time(
|
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
|
# Norfair estimator function needs color so it can convert it right back to gray
|
||||||
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGRA)
|
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGRA)
|
||||||
|
|
||||||
|
try:
|
||||||
self.coord_transformations = self.norfair_motion_estimator.update(
|
self.coord_transformations = self.norfair_motion_estimator.update(
|
||||||
frame, mask
|
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)
|
self.frame_manager.close(frame_id)
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"Motion estimator transformation: {self.coord_transformations.rel_to_abs((0,0))}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.coord_transformations
|
return self.coord_transformations
|
||||||
|
|
||||||
|
|
||||||
@@ -147,12 +166,18 @@ class PtzAutoTracker:
|
|||||||
self.ptz_metrics = ptz_metrics
|
self.ptz_metrics = ptz_metrics
|
||||||
self.tracked_object: dict[str, object] = {}
|
self.tracked_object: dict[str, object] = {}
|
||||||
self.tracked_object_previous: dict[str, object] = {}
|
self.tracked_object_previous: dict[str, object] = {}
|
||||||
self.previous_frame_time = None
|
self.previous_frame_time: dict[str, object] = {}
|
||||||
self.object_types = {}
|
self.object_types: dict[str, object] = {}
|
||||||
self.required_zones = {}
|
self.required_zones: dict[str, object] = {}
|
||||||
self.move_queues = {}
|
self.move_queues: dict[str, object] = {}
|
||||||
self.move_threads = {}
|
self.move_queue_locks: dict[str, object] = {}
|
||||||
self.autotracker_init = {}
|
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
|
# if cam is set to autotrack, onvif should be set up
|
||||||
for camera_name, cam in self.config.cameras.items():
|
for camera_name, cam in self.config.cameras.items():
|
||||||
@@ -168,11 +193,18 @@ class PtzAutoTracker:
|
|||||||
|
|
||||||
self.object_types[camera_name] = cam.onvif.autotracking.track
|
self.object_types[camera_name] = cam.onvif.autotracking.track
|
||||||
self.required_zones[camera_name] = cam.onvif.autotracking.required_zones
|
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[camera_name] = None
|
||||||
self.tracked_object_previous[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_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.cams[camera_name]["init"]:
|
||||||
if not self.onvif._init_onvif(camera_name):
|
if not self.onvif._init_onvif(camera_name):
|
||||||
@@ -182,7 +214,7 @@ class PtzAutoTracker:
|
|||||||
|
|
||||||
return
|
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
|
cam.onvif.autotracking.enabled = False
|
||||||
self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value = False
|
self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value = False
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -191,8 +223,20 @@ class PtzAutoTracker:
|
|||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
self.onvif.get_camera_status(camera_name)
|
||||||
|
|
||||||
# movement thread per camera
|
# movement thread per camera
|
||||||
if not self.move_threads or not self.move_threads[camera_name]:
|
|
||||||
self.move_threads[camera_name] = threading.Thread(
|
self.move_threads[camera_name] = threading.Thread(
|
||||||
name=f"move_thread_{camera_name}",
|
name=f"move_thread_{camera_name}",
|
||||||
target=partial(self._process_move_queue, camera_name),
|
target=partial(self._process_move_queue, camera_name),
|
||||||
@@ -200,13 +244,145 @@ class PtzAutoTracker:
|
|||||||
self.move_threads[camera_name].daemon = True
|
self.move_threads[camera_name].daemon = True
|
||||||
self.move_threads[camera_name].start()
|
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
|
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):
|
def _process_move_queue(self, camera):
|
||||||
while True:
|
while True:
|
||||||
try:
|
|
||||||
move_data = self.move_queues[camera].get()
|
move_data = self.move_queues[camera].get()
|
||||||
frame_time, pan, tilt = move_data
|
|
||||||
|
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 we're receiving move requests during a PTZ move, ignore them
|
||||||
if ptz_moving_at_frame_time(
|
if ptz_moving_at_frame_time(
|
||||||
@@ -217,50 +393,234 @@ class PtzAutoTracker:
|
|||||||
# instead of dequeueing this might be a good place to preemptively move based
|
# instead of dequeueing this might be a good place to preemptively move based
|
||||||
# on an estimate - for fast moving objects, etc.
|
# on an estimate - for fast moving objects, etc.
|
||||||
logger.debug(
|
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
|
continue
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# on some cameras with cheaper motors it seems like small values can cause jerky movement
|
if (
|
||||||
# TODO: double check, might not need this
|
self.config.cameras[camera].onvif.autotracking.zooming
|
||||||
if abs(pan) > 0.02 or abs(tilt) > 0.02:
|
== ZoomingModeEnum.relative
|
||||||
self.onvif._move_relative(camera, pan, tilt, 1)
|
):
|
||||||
|
self.onvif._move_relative(camera, pan, tilt, zoom, 1)
|
||||||
else:
|
else:
|
||||||
logger.debug(
|
if zoom > 0:
|
||||||
f"Not moving, pan and tilt too small: {pan}, {tilt}"
|
self.onvif._zoom_absolute(camera, zoom, 1)
|
||||||
)
|
else:
|
||||||
|
self.onvif._move_relative(camera, pan, tilt, 0, 1)
|
||||||
|
|
||||||
# Wait until the camera finishes moving
|
# Wait until the camera finishes moving
|
||||||
while not self.ptz_metrics[camera]["ptz_stopped"].is_set():
|
while not self.ptz_metrics[camera]["ptz_stopped"].is_set():
|
||||||
# check if ptz is moving
|
# check if ptz is moving
|
||||||
self.onvif.get_camera_status(camera)
|
self.onvif.get_camera_status(camera)
|
||||||
|
|
||||||
except queue.Empty:
|
if self.config.cameras[camera].onvif.autotracking.movement_weights:
|
||||||
continue
|
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 (
|
if (
|
||||||
frame_time > self.ptz_metrics[camera]["ptz_start_time"].value
|
frame_time > self.ptz_metrics[camera]["ptz_start_time"].value
|
||||||
and frame_time > self.ptz_metrics[camera]["ptz_stop_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}")
|
# 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)
|
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):
|
def _autotrack_move_ptz(self, camera, obj):
|
||||||
camera_config = self.config.cameras[camera]
|
camera_config = self.config.cameras[camera]
|
||||||
|
average_velocity = (0,) * 4
|
||||||
|
|
||||||
# # frame width and height
|
# # frame width and height
|
||||||
camera_width = camera_config.frame_shape[1]
|
camera_width = camera_config.frame_shape[1]
|
||||||
camera_height = camera_config.frame_shape[0]
|
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).
|
# 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
|
pan = ((centroid_x / camera_width) - 0.5) * 2
|
||||||
tilt = (0.5 - (obj.obj_data["centroid"][1] / camera_height)) * 2
|
tilt = (0.5 - (centroid_y / camera_height)) * 2
|
||||||
|
|
||||||
# ideas: check object velocity for camera speed?
|
if (
|
||||||
self._enqueue_move(camera, obj.obj_data["frame_time"], pan, tilt)
|
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):
|
def autotrack_object(self, camera, obj):
|
||||||
camera_config = self.config.cameras[camera]
|
camera_config = self.config.cameras[camera]
|
||||||
@@ -269,6 +629,10 @@ class PtzAutoTracker:
|
|||||||
if not self.autotracker_init[camera]:
|
if not self.autotracker_init[camera]:
|
||||||
self._autotracker_setup(self.config.cameras[camera], 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,
|
# 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
|
# and is not initially motionless - or one we're already tracking, which assumes all those things are already true
|
||||||
if (
|
if (
|
||||||
@@ -287,7 +651,7 @@ class PtzAutoTracker:
|
|||||||
)
|
)
|
||||||
self.tracked_object[camera] = obj
|
self.tracked_object[camera] = obj
|
||||||
self.tracked_object_previous[camera] = copy.deepcopy(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)
|
self._autotrack_move_ptz(camera, obj)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -299,7 +663,7 @@ class PtzAutoTracker:
|
|||||||
and obj.obj_data["id"] == self.tracked_object[camera].obj_data["id"]
|
and obj.obj_data["id"] == self.tracked_object[camera].obj_data["id"]
|
||||||
and obj.obj_data["frame_time"] != self.previous_frame_time
|
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
|
# 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,
|
# less than 15% of the of the larger dimension (width or height) of the frame,
|
||||||
# multiplied by a scaling factor for object size.
|
# multiplied by a scaling factor for object size.
|
||||||
@@ -307,10 +671,11 @@ class PtzAutoTracker:
|
|||||||
# more often to keep the object in the center. Raising the percentage will cause less
|
# 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.
|
# movement and will be more flexible with objects not quite being centered.
|
||||||
# TODO: there's probably a better way to approach this
|
# TODO: there's probably a better way to approach this
|
||||||
distance = math.sqrt(
|
distance = np.linalg.norm(
|
||||||
(obj.obj_data["centroid"][0] - camera_config.detect.width / 2) ** 2
|
[
|
||||||
+ (obj.obj_data["centroid"][1] - camera_config.detect.height / 2)
|
obj.obj_data["centroid"][0] - camera_config.detect.width / 2,
|
||||||
** 2
|
obj.obj_data["centroid"][1] - camera_config.detect.height / 2,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
obj_width = obj.obj_data["box"][2] - obj.obj_data["box"][0]
|
obj_width = obj.obj_data["box"][2] - obj.obj_data["box"][0]
|
||||||
@@ -337,6 +702,10 @@ class PtzAutoTracker:
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Autotrack: Existing object (do NOT move ptz): {obj.obj_data['id']} {obj.obj_data['box']} {obj.obj_data['frame_time']}"
|
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
|
return
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -345,6 +714,9 @@ class PtzAutoTracker:
|
|||||||
self.tracked_object_previous[camera] = copy.deepcopy(obj)
|
self.tracked_object_previous[camera] = copy.deepcopy(obj)
|
||||||
self._autotrack_move_ptz(camera, obj)
|
self._autotrack_move_ptz(camera, obj)
|
||||||
|
|
||||||
|
# try absolute zooming too
|
||||||
|
self._autotrack_zoom_only(camera, obj)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -356,10 +728,9 @@ class PtzAutoTracker:
|
|||||||
and obj.obj_data["label"] in self.object_types[camera]
|
and obj.obj_data["label"] in self.object_types[camera]
|
||||||
and not obj.previous["false_positive"]
|
and not obj.previous["false_positive"]
|
||||||
and not obj.false_positive
|
and not obj.false_positive
|
||||||
and obj.obj_data["motionless_count"] == 0
|
|
||||||
and self.tracked_object_previous[camera] is not None
|
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 (
|
if (
|
||||||
intersection_over_union(
|
intersection_over_union(
|
||||||
self.tracked_object_previous[camera].obj_data["region"],
|
self.tracked_object_previous[camera].obj_data["region"],
|
||||||
@@ -388,6 +759,12 @@ class PtzAutoTracker:
|
|||||||
self.tracked_object[camera] = None
|
self.tracked_object[camera] = None
|
||||||
|
|
||||||
def camera_maintenance(self, camera):
|
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
|
# calls get_camera_status to check/update ptz movement
|
||||||
# returns camera to preset after timeout when tracking is over
|
# returns camera to preset after timeout when tracking is over
|
||||||
autotracker_config = self.config.cameras[camera].onvif.autotracking
|
autotracker_config = self.config.cameras[camera].onvif.autotracking
|
||||||
@@ -404,19 +781,26 @@ class PtzAutoTracker:
|
|||||||
and self.tracked_object_previous[camera] is not None
|
and self.tracked_object_previous[camera] is not None
|
||||||
and (
|
and (
|
||||||
# might want to use a different timestamp here?
|
# 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"]
|
- self.tracked_object_previous[camera].obj_data["frame_time"]
|
||||||
> autotracker_config.timeout
|
> autotracker_config.timeout
|
||||||
)
|
)
|
||||||
and autotracker_config.return_preset
|
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()
|
self.ptz_metrics[camera]["ptz_stopped"].wait()
|
||||||
logger.debug(
|
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(
|
self.onvif._move_to_preset(
|
||||||
camera,
|
camera,
|
||||||
autotracker_config.return_preset.lower(),
|
autotracker_config.return_preset.lower(),
|
||||||
)
|
)
|
||||||
self.ptz_metrics[camera]["ptz_reset"].set()
|
self.ptz_metrics[camera]["ptz_reset"].set()
|
||||||
self.tracked_object_previous[camera] = None
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Configure and control camera via onvif."""
|
"""Configure and control camera via onvif."""
|
||||||
|
|
||||||
import datetime
|
|
||||||
import logging
|
import logging
|
||||||
import site
|
import site
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -8,8 +7,9 @@ from enum import Enum
|
|||||||
import numpy
|
import numpy
|
||||||
from onvif import ONVIFCamera, ONVIFError
|
from onvif import ONVIFCamera, ONVIFError
|
||||||
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig, ZoomingModeEnum
|
||||||
from frigate.types import PTZMetricsTypes
|
from frigate.types import PTZMetricsTypes
|
||||||
|
from frigate.util.builtin import find_by_key
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ class OnvifController:
|
|||||||
self, config: FrigateConfig, ptz_metrics: dict[str, PTZMetricsTypes]
|
self, config: FrigateConfig, ptz_metrics: dict[str, PTZMetricsTypes]
|
||||||
) -> None:
|
) -> None:
|
||||||
self.cams: dict[str, ONVIFCamera] = {}
|
self.cams: dict[str, ONVIFCamera] = {}
|
||||||
|
self.config = config
|
||||||
self.ptz_metrics = ptz_metrics
|
self.ptz_metrics = ptz_metrics
|
||||||
|
|
||||||
for cam_name, cam in config.cameras.items():
|
for cam_name, cam in config.cameras.items():
|
||||||
@@ -73,11 +74,20 @@ class OnvifController:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
ptz = onvif.create_ptz_service()
|
ptz = onvif.create_ptz_service()
|
||||||
|
|
||||||
|
request = ptz.create_type("GetConfigurations")
|
||||||
|
configs = ptz.GetConfigurations(request)[0]
|
||||||
|
|
||||||
request = ptz.create_type("GetConfigurationOptions")
|
request = ptz.create_type("GetConfigurationOptions")
|
||||||
request.ConfigurationToken = profile.PTZConfiguration.token
|
request.ConfigurationToken = profile.PTZConfiguration.token
|
||||||
ptz_config = ptz.GetConfigurationOptions(request)
|
ptz_config = ptz.GetConfigurationOptions(request)
|
||||||
logger.debug(f"Onvif config for {camera_name}: {ptz_config}")
|
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(
|
fov_space_id = next(
|
||||||
(
|
(
|
||||||
i
|
i
|
||||||
@@ -89,6 +99,20 @@ class OnvifController:
|
|||||||
None,
|
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
|
# setup continuous moving request
|
||||||
move_request = ptz.create_type("ContinuousMove")
|
move_request = ptz.create_type("ContinuousMove")
|
||||||
move_request.ProfileToken = profile.token
|
move_request.ProfileToken = profile.token
|
||||||
@@ -105,19 +129,33 @@ class OnvifController:
|
|||||||
"RelativePanTiltTranslationSpace"
|
"RelativePanTiltTranslationSpace"
|
||||||
][fov_space_id]["URI"]
|
][fov_space_id]["URI"]
|
||||||
|
|
||||||
|
# try setting relative zoom translation space
|
||||||
try:
|
try:
|
||||||
|
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"][
|
move_request.Translation.Zoom.space = ptz_config["Spaces"][
|
||||||
"RelativeZoomTranslationSpace"
|
"RelativeZoomTranslationSpace"
|
||||||
][0]["URI"]
|
][0]["URI"]
|
||||||
except Exception:
|
except Exception:
|
||||||
# camera does not support relative zoom
|
if (
|
||||||
pass
|
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:
|
if move_request.Speed is None:
|
||||||
move_request.Speed = ptz.GetStatus({"ProfileToken": profile.token}).Position
|
move_request.Speed = ptz.GetStatus({"ProfileToken": profile.token}).Position
|
||||||
self.cams[camera_name]["relative_move_request"] = move_request
|
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 = ptz.create_type("AbsoluteMove")
|
||||||
move_request.ProfileToken = profile.token
|
move_request.ProfileToken = profile.token
|
||||||
self.cams[camera_name]["absolute_move_request"] = move_request
|
self.cams[camera_name]["absolute_move_request"] = move_request
|
||||||
@@ -126,6 +164,8 @@ class OnvifController:
|
|||||||
status_request = ptz.create_type("GetStatus")
|
status_request = ptz.create_type("GetStatus")
|
||||||
status_request.ProfileToken = profile.token
|
status_request.ProfileToken = profile.token
|
||||||
self.cams[camera_name]["status_request"] = status_request
|
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
|
# setup existing presets
|
||||||
try:
|
try:
|
||||||
@@ -135,7 +175,9 @@ class OnvifController:
|
|||||||
presets = []
|
presets = []
|
||||||
|
|
||||||
for preset in 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
|
# get list of supported features
|
||||||
ptz_config = ptz.GetConfigurationOptions(request)
|
ptz_config = ptz.GetConfigurationOptions(request)
|
||||||
@@ -153,14 +195,28 @@ class OnvifController:
|
|||||||
if ptz_config.Spaces and ptz_config.Spaces.RelativeZoomTranslationSpace:
|
if ptz_config.Spaces and ptz_config.Spaces.RelativeZoomTranslationSpace:
|
||||||
supported_features.append("zoom-r")
|
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:
|
if fov_space_id is not None:
|
||||||
supported_features.append("pt-r-fov")
|
supported_features.append("pt-r-fov")
|
||||||
self.cams[camera_name][
|
self.cams[camera_name][
|
||||||
"relative_fov_range"
|
"relative_fov_range"
|
||||||
] = ptz_config.Spaces.RelativePanTiltTranslationSpace[fov_space_id]
|
] = 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]["features"] = supported_features
|
||||||
|
|
||||||
self.cams[camera_name]["init"] = True
|
self.cams[camera_name]["init"] = True
|
||||||
@@ -210,8 +266,8 @@ class OnvifController:
|
|||||||
|
|
||||||
onvif.get_service("ptz").ContinuousMove(move_request)
|
onvif.get_service("ptz").ContinuousMove(move_request)
|
||||||
|
|
||||||
def _move_relative(self, camera_name: str, pan, tilt, speed) -> None:
|
def _move_relative(self, camera_name: str, pan, tilt, zoom, speed) -> None:
|
||||||
if not self.cams[camera_name]["relative_fov_supported"]:
|
if "pt-r-fov" not in self.cams[camera_name]["features"]:
|
||||||
logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
|
logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -225,10 +281,12 @@ class OnvifController:
|
|||||||
|
|
||||||
self.cams[camera_name]["active"] = True
|
self.cams[camera_name]["active"] = True
|
||||||
self.ptz_metrics[camera_name]["ptz_stopped"].clear()
|
self.ptz_metrics[camera_name]["ptz_stopped"].clear()
|
||||||
logger.debug(f"PTZ start time: {datetime.datetime.now().timestamp()}")
|
logger.debug(
|
||||||
self.ptz_metrics[camera_name][
|
f"PTZ start time: {self.ptz_metrics[camera_name]['ptz_frame_time'].value}"
|
||||||
"ptz_start_time"
|
)
|
||||||
].value = datetime.datetime.now().timestamp()
|
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
|
self.ptz_metrics[camera_name]["ptz_stop_time"].value = 0
|
||||||
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
||||||
move_request = self.cams[camera_name]["relative_move_request"]
|
move_request = self.cams[camera_name]["relative_move_request"]
|
||||||
@@ -257,15 +315,30 @@ class OnvifController:
|
|||||||
"x": speed,
|
"x": speed,
|
||||||
"y": speed,
|
"y": speed,
|
||||||
},
|
},
|
||||||
"Zoom": 0,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
move_request.Translation.PanTilt.x = pan
|
move_request.Translation.PanTilt.x = pan
|
||||||
move_request.Translation.PanTilt.y = tilt
|
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)
|
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
|
self.cams[camera_name]["active"] = False
|
||||||
|
|
||||||
def _move_to_preset(self, camera_name: str, preset: str) -> None:
|
def _move_to_preset(self, camera_name: str, preset: str) -> None:
|
||||||
@@ -305,6 +378,50 @@ class OnvifController:
|
|||||||
|
|
||||||
onvif.get_service("ptz").ContinuousMove(move_request)
|
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(
|
def handle_command(
|
||||||
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
|
self, camera_name: str, command: OnvifCommandEnum, param: str = ""
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -344,7 +461,30 @@ class OnvifController:
|
|||||||
"presets": list(self.cams[camera_name]["presets"].keys()),
|
"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():
|
if camera_name not in self.cams.keys():
|
||||||
logger.error(f"Onvif is not setup for {camera_name}")
|
logger.error(f"Onvif is not setup for {camera_name}")
|
||||||
return {}
|
return {}
|
||||||
@@ -356,32 +496,66 @@ class OnvifController:
|
|||||||
status_request = self.cams[camera_name]["status_request"]
|
status_request = self.cams[camera_name]["status_request"]
|
||||||
status = onvif.get_service("ptz").GetStatus(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
|
self.cams[camera_name]["active"] = False
|
||||||
if not self.ptz_metrics[camera_name]["ptz_stopped"].is_set():
|
if not self.ptz_metrics[camera_name]["ptz_stopped"].is_set():
|
||||||
self.ptz_metrics[camera_name]["ptz_stopped"].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][
|
self.ptz_metrics[camera_name]["ptz_stop_time"].value = self.ptz_metrics[
|
||||||
"ptz_stop_time"
|
camera_name
|
||||||
].value = datetime.datetime.now().timestamp()
|
]["ptz_frame_time"].value
|
||||||
else:
|
else:
|
||||||
self.cams[camera_name]["active"] = True
|
self.cams[camera_name]["active"] = True
|
||||||
if self.ptz_metrics[camera_name]["ptz_stopped"].is_set():
|
if self.ptz_metrics[camera_name]["ptz_stopped"].is_set():
|
||||||
self.ptz_metrics[camera_name]["ptz_stopped"].clear()
|
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][
|
self.ptz_metrics[camera_name][
|
||||||
"ptz_start_time"
|
"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
|
self.ptz_metrics[camera_name]["ptz_stop_time"].value = 0
|
||||||
|
|
||||||
return {
|
if (
|
||||||
"pan": status.Position.PanTilt.x,
|
self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||||
"tilt": status.Position.PanTilt.y,
|
== ZoomingModeEnum.absolute
|
||||||
"zoom": status.Position.Zoom.x,
|
):
|
||||||
"pantilt_moving": status.MoveStatus.PanTilt,
|
# store absolute zoom level as 0 to 1 interpolated from the values of the camera
|
||||||
"zoom_moving": status.MoveStatus.Zoom,
|
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}'
|
||||||
|
)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class RecordingExporter(threading.Thread):
|
|||||||
|
|
||||||
def get_datetime_from_timestamp(self, timestamp: int) -> str:
|
def get_datetime_from_timestamp(self, timestamp: int) -> str:
|
||||||
"""Convenience fun to get a simple date time from timestamp."""
|
"""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:
|
def run(self) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
"+faststart",
|
"+faststart",
|
||||||
file_path,
|
file_path,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
await p.wait()
|
await p.wait()
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ async def set_gpu_stats(
|
|||||||
stats[nvidia_usage[i]["name"]] = {
|
stats[nvidia_usage[i]["name"]] = {
|
||||||
"gpu": str(round(float(nvidia_usage[i]["gpu"]), 2)) + "%",
|
"gpu": str(round(float(nvidia_usage[i]["gpu"]), 2)) + "%",
|
||||||
"mem": str(round(float(nvidia_usage[i]["mem"]), 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:
|
else:
|
||||||
@@ -266,6 +268,8 @@ def stats_snapshot(
|
|||||||
"pid": pid,
|
"pid": pid,
|
||||||
"capture_pid": cpid,
|
"capture_pid": cpid,
|
||||||
"ffmpeg_pid": ffmpeg_pid,
|
"ffmpeg_pid": ffmpeg_pid,
|
||||||
|
"audio_rms": round(camera_stats["audio_rms"].value, 4),
|
||||||
|
"audio_dBFS": round(camera_stats["audio_dBFS"].value, 4),
|
||||||
}
|
}
|
||||||
|
|
||||||
stats["detectors"] = {}
|
stats["detectors"] = {}
|
||||||
|
|||||||
47
frigate/test/test_birdseye.py
Normal file
47
frigate/test/test_birdseye.py
Normal 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
|
||||||
@@ -1027,7 +1027,12 @@ class TestConfig(unittest.TestCase):
|
|||||||
"roles": ["detect"],
|
"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": {
|
"snapshots": {
|
||||||
"height": 100,
|
"height": 100,
|
||||||
},
|
},
|
||||||
@@ -1107,7 +1117,12 @@ class TestConfig(unittest.TestCase):
|
|||||||
"roles": ["detect"],
|
"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": {
|
"snapshots": {
|
||||||
"height": 150,
|
"height": 150,
|
||||||
"enabled": True,
|
"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"],
|
"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": {
|
"rtmp": {
|
||||||
"enabled": True,
|
"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"],
|
"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": {
|
"live": {
|
||||||
"quality": 7,
|
"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"],
|
"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},
|
"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": {
|
"zones": {
|
||||||
"steps": {
|
"steps": {
|
||||||
"coordinates": "0,0,0,0",
|
"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"].min_ratio == 0.2
|
||||||
assert runtime_config.cameras["back"].objects.filters["dog"].max_ratio == 10.1
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
@@ -236,6 +236,44 @@ class TestHttp(unittest.TestCase):
|
|||||||
assert event["id"] == id
|
assert event["id"] == id
|
||||||
assert event["retain_indefinitely"] is False
|
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):
|
def test_set_delete_sub_label(self):
|
||||||
app = create_app(
|
app = create_app(
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
@@ -351,14 +389,17 @@ class TestHttp(unittest.TestCase):
|
|||||||
assert stats == self.test_stats
|
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."""
|
"""Inserts a basic event model with a given id."""
|
||||||
return Event.insert(
|
return Event.insert(
|
||||||
id=id,
|
id=id,
|
||||||
label="Mock",
|
label="Mock",
|
||||||
camera="front_door",
|
camera="front_door",
|
||||||
start_time=datetime.datetime.now().timestamp(),
|
start_time=start_time,
|
||||||
end_time=datetime.datetime.now().timestamp() + 20,
|
end_time=start_time + 20,
|
||||||
top_score=100,
|
top_score=100,
|
||||||
false_positive=False,
|
false_positive=False,
|
||||||
zones=list(),
|
zones=list(),
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class TimelineProcessor(threading.Thread):
|
|||||||
camera_config.detect.height,
|
camera_config.detect.height,
|
||||||
event_data["region"],
|
event_data["region"],
|
||||||
),
|
),
|
||||||
|
"attribute": "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if event_type == "start":
|
if event_type == "start":
|
||||||
@@ -93,6 +94,12 @@ class TimelineProcessor(threading.Thread):
|
|||||||
"stationary" if event_data["stationary"] else "active"
|
"stationary" if event_data["stationary"] else "active"
|
||||||
)
|
)
|
||||||
Timeline.insert(timeline_entry).execute()
|
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":
|
elif event_type == "end":
|
||||||
timeline_entry[Timeline.class_type] = "gone"
|
timeline_entry[Timeline.class_type] = "gone"
|
||||||
Timeline.insert(timeline_entry).execute()
|
Timeline.insert(timeline_entry).execute()
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class NorfairTracker(ObjectTracker):
|
|||||||
self.tracker = Tracker(
|
self.tracker = Tracker(
|
||||||
distance_function=frigate_distance,
|
distance_function=frigate_distance,
|
||||||
distance_threshold=2.5,
|
distance_threshold=2.5,
|
||||||
initialization_delay=0,
|
initialization_delay=config.detect.fps / 2,
|
||||||
hit_counter_max=self.max_disappeared,
|
hit_counter_max=self.max_disappeared,
|
||||||
)
|
)
|
||||||
if self.ptz_autotracker_enabled.value:
|
if self.ptz_autotracker_enabled.value:
|
||||||
@@ -106,11 +106,6 @@ class NorfairTracker(ObjectTracker):
|
|||||||
"ymax": self.detect_config.height,
|
"ymax": self.detect_config.height,
|
||||||
}
|
}
|
||||||
|
|
||||||
# start object with a hit count of `fps` to avoid quick detection -> loss
|
|
||||||
next(
|
|
||||||
(o for o in self.tracker.tracked_objects if o.global_id == track_id)
|
|
||||||
).hit_counter = self.camera_config.detect.fps
|
|
||||||
|
|
||||||
def deregister(self, id, track_id):
|
def deregister(self, id, track_id):
|
||||||
del self.tracked_objects[id]
|
del self.tracked_objects[id]
|
||||||
del self.disappeared[id]
|
del self.disappeared[id]
|
||||||
@@ -278,9 +273,11 @@ class NorfairTracker(ObjectTracker):
|
|||||||
min(self.detect_config.width - 1, estimate[2]),
|
min(self.detect_config.width - 1, estimate[2]),
|
||||||
min(self.detect_config.height - 1, estimate[3]),
|
min(self.detect_config.height - 1, estimate[3]),
|
||||||
)
|
)
|
||||||
|
estimate_velocity = tuple(t.estimate_velocity.flatten().astype(int))
|
||||||
obj = {
|
obj = {
|
||||||
**t.last_detection.data,
|
**t.last_detection.data,
|
||||||
"estimate": estimate,
|
"estimate": estimate,
|
||||||
|
"estimate_velocity": estimate_velocity,
|
||||||
}
|
}
|
||||||
active_ids.append(t.global_id)
|
active_ids.append(t.global_id)
|
||||||
if t.global_id not in self.track_id_map:
|
if t.global_id not in self.track_id_map:
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class CameraMetricsTypes(TypedDict):
|
|||||||
process_fps: Synchronized
|
process_fps: Synchronized
|
||||||
read_start: Synchronized
|
read_start: Synchronized
|
||||||
skipped_fps: Synchronized
|
skipped_fps: Synchronized
|
||||||
|
audio_rms: Synchronized
|
||||||
|
audio_dBFS: Synchronized
|
||||||
|
|
||||||
|
|
||||||
class PTZMetricsTypes(TypedDict):
|
class PTZMetricsTypes(TypedDict):
|
||||||
@@ -31,6 +33,8 @@ class PTZMetricsTypes(TypedDict):
|
|||||||
ptz_reset: Event
|
ptz_reset: Event
|
||||||
ptz_start_time: Synchronized
|
ptz_start_time: Synchronized
|
||||||
ptz_stop_time: Synchronized
|
ptz_stop_time: Synchronized
|
||||||
|
ptz_frame_time: Synchronized
|
||||||
|
ptz_zoom_level: Synchronized
|
||||||
|
|
||||||
|
|
||||||
class FeatureMetricsTypes(TypedDict):
|
class FeatureMetricsTypes(TypedDict):
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ def load_config_with_no_duplicates(raw_config) -> dict:
|
|||||||
"""Get config ensuring duplicate keys are not allowed."""
|
"""Get config ensuring duplicate keys are not allowed."""
|
||||||
|
|
||||||
# https://stackoverflow.com/a/71751051
|
# https://stackoverflow.com/a/71751051
|
||||||
class PreserveDuplicatesLoader(yaml.loader.Loader):
|
# important to use SafeLoader here to avoid RCE
|
||||||
|
class PreserveDuplicatesLoader(yaml.loader.SafeLoader):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def map_constructor(loader, node, deep=False):
|
def map_constructor(loader, node, deep=False):
|
||||||
@@ -249,3 +250,15 @@ def update_yaml(data, key_path, new_value):
|
|||||||
temp[last_key] = new_value
|
temp[last_key] = new_value
|
||||||
|
|
||||||
return data
|
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
|
||||||
|
|||||||
@@ -293,6 +293,8 @@ def get_nvidia_gpu_stats() -> dict[int, dict]:
|
|||||||
handle = nvml.nvmlDeviceGetHandleByIndex(i)
|
handle = nvml.nvmlDeviceGetHandleByIndex(i)
|
||||||
meminfo = try_get_info(nvml.nvmlDeviceGetMemoryInfo, handle)
|
meminfo = try_get_info(nvml.nvmlDeviceGetMemoryInfo, handle)
|
||||||
util = try_get_info(nvml.nvmlDeviceGetUtilizationRates, 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":
|
if util != "N/A":
|
||||||
gpu_util = util.gpu
|
gpu_util = util.gpu
|
||||||
else:
|
else:
|
||||||
@@ -303,10 +305,22 @@ def get_nvidia_gpu_stats() -> dict[int, dict]:
|
|||||||
else:
|
else:
|
||||||
gpu_mem_util = -1
|
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] = {
|
results[i] = {
|
||||||
"name": nvml.nvmlDeviceGetName(handle),
|
"name": nvml.nvmlDeviceGetName(handle),
|
||||||
"gpu": gpu_util,
|
"gpu": gpu_util,
|
||||||
"mem": gpu_mem_util,
|
"mem": gpu_mem_util,
|
||||||
|
"enc": enc_util,
|
||||||
|
"dec": dec_util,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from frigate.log import LogPipe
|
|||||||
from frigate.motion import MotionDetector
|
from frigate.motion import MotionDetector
|
||||||
from frigate.motion.improved_motion import ImprovedMotionDetector
|
from frigate.motion.improved_motion import ImprovedMotionDetector
|
||||||
from frigate.object_detection import RemoteObjectDetector
|
from frigate.object_detection import RemoteObjectDetector
|
||||||
from frigate.ptz.autotrack import ptz_moving_at_frame_time
|
|
||||||
from frigate.track import ObjectTracker
|
from frigate.track import ObjectTracker
|
||||||
from frigate.track.norfair_tracker import NorfairTracker
|
from frigate.track.norfair_tracker import NorfairTracker
|
||||||
from frigate.types import PTZMetricsTypes
|
from frigate.types import PTZMetricsTypes
|
||||||
@@ -767,6 +766,7 @@ def process_frames(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
current_frame_time.value = frame_time
|
current_frame_time.value = frame_time
|
||||||
|
ptz_metrics["ptz_frame_time"].value = frame_time
|
||||||
|
|
||||||
frame = frame_manager.get(
|
frame = frame_manager.get(
|
||||||
f"{camera_name}{frame_time}", (frame_shape[0] * 3 // 2, frame_shape[1])
|
f"{camera_name}{frame_time}", (frame_shape[0] * 3 // 2, frame_shape[1])
|
||||||
@@ -776,19 +776,8 @@ def process_frames(
|
|||||||
logger.info(f"{camera_name}: frame {frame_time} is not in memory store.")
|
logger.info(f"{camera_name}: frame {frame_time} is not in memory store.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# look for motion if enabled and ptz is not moving
|
# look for motion if enabled
|
||||||
# ptz_moving_at_frame_time() always returns False for
|
motion_boxes = motion_detector.detect(frame) if motion_enabled.value else []
|
||||||
# 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 []
|
|
||||||
)
|
|
||||||
|
|
||||||
regions = []
|
regions = []
|
||||||
consolidated_detections = []
|
consolidated_detections = []
|
||||||
@@ -813,8 +802,10 @@ def process_frames(
|
|||||||
)
|
)
|
||||||
# and it hasn't disappeared
|
# and it hasn't disappeared
|
||||||
and object_tracker.disappeared[obj["id"]] == 0
|
and object_tracker.disappeared[obj["id"]] == 0
|
||||||
# and it doesn't overlap with any current motion boxes
|
# and it doesn't overlap with any current motion boxes when not calibrating
|
||||||
and not intersects_any(obj["box"], motion_boxes)
|
and not intersects_any(
|
||||||
|
obj["box"], [] if motion_detector.is_calibrating() else motion_boxes
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
# get tracked object boxes that aren't stationary
|
# get tracked object boxes that aren't stationary
|
||||||
@@ -824,7 +815,10 @@ def process_frames(
|
|||||||
if obj["id"] not in stationary_object_ids
|
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(
|
cluster_candidates = get_cluster_candidates(
|
||||||
frame_shape, region_min_size, combined_boxes
|
frame_shape, region_min_size, combined_boxes
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
},
|
},
|
||||||
"ignorePatterns": ["*.d.ts"],
|
"ignorePatterns": ["*.d.ts"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"indent": ["error", 2, { "SwitchCase": 1 }],
|
|
||||||
"comma-dangle": [
|
"comma-dangle": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"printWidth": 120,
|
"printWidth": 120,
|
||||||
|
"trailingComma": "es5",
|
||||||
"singleQuote": true
|
"singleQuote": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { rest } from 'msw';
|
import { rest } from 'msw';
|
||||||
import { API_HOST } from '../src/env';
|
// import { API_HOST } from '../src/env';
|
||||||
|
|
||||||
export const handlers = [
|
export const handlers = [
|
||||||
rest.get(`${API_HOST}api/config`, (req, res, ctx) => {
|
rest.get(`api/config`, (req, res, ctx) => {
|
||||||
return res(
|
return res(
|
||||||
ctx.status(200),
|
ctx.status(200),
|
||||||
ctx.json({
|
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(
|
return res(
|
||||||
ctx.status(200),
|
ctx.status(200),
|
||||||
ctx.json({
|
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(
|
return res(
|
||||||
ctx.status(200),
|
ctx.status(200),
|
||||||
ctx.json(
|
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(
|
return res(
|
||||||
ctx.status(200),
|
ctx.status(200),
|
||||||
ctx.json([
|
ctx.json([
|
||||||
|
|||||||
1290
web/package-lock.json
generated
1290
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,7 @@
|
|||||||
"eslint-config-preact": "^1.3.0",
|
"eslint-config-preact": "^1.3.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-jest": "^27.2.3",
|
"eslint-plugin-jest": "^27.2.3",
|
||||||
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
"eslint-plugin-vitest-globals": "^1.4.0",
|
"eslint-plugin-vitest-globals": "^1.4.0",
|
||||||
"fake-indexeddb": "^4.0.1",
|
"fake-indexeddb": "^4.0.1",
|
||||||
"jsdom": "^22.0.0",
|
"jsdom": "^22.0.0",
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export const ENV = 'test';
|
export const ENV = 'test';
|
||||||
export const API_HOST = 'http://base-url.local:5000/';
|
|
||||||
|
|||||||
@@ -18,6 +18,6 @@ describe('useApiHost', () => {
|
|||||||
<Test />
|
<Test />
|
||||||
</ApiProvider>
|
</ApiProvider>
|
||||||
);
|
);
|
||||||
expect(screen.queryByText('http://base-url.local:5000/')).toBeInTheDocument();
|
expect(screen.queryByText('http://localhost:3000/')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
import { API_HOST } from '../env';
|
export const baseUrl = `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`;
|
||||||
export const baseUrl = API_HOST || `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`;
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { WsProvider } from './ws';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
axios.defaults.baseURL = `${baseUrl}api/`;
|
axios.defaults.baseURL = `${baseUrl}api/`;
|
||||||
|
axios.defaults.headers.common = {
|
||||||
|
'X-CSRF-TOKEN': 1,
|
||||||
|
};
|
||||||
|
|
||||||
export function ApiProvider({ children, options }) {
|
export function ApiProvider({ children, options }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function CameraImage({ camera, onload, searchParams = '', stretch
|
|||||||
if (!config || scaledHeight === 0 || !canvasRef.current) {
|
if (!config || scaledHeight === 0 || !canvasRef.current) {
|
||||||
return;
|
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]);
|
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function LargeDialog({ children, portalRootID = 'dialogs' }) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="modal"
|
role="modal"
|
||||||
className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 w-4/5 max-w-7xl text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
|
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' : ''
|
show ? 'scale-100 opacity-100' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ export const HistoryVideo = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
video.src({
|
video.src({
|
||||||
src: `${apiHost}/vod/event/${id}/master.m3u8`,
|
src: `${apiHost}vod/event/${id}/master.m3u8`,
|
||||||
type: 'application/vnd.apple.mpegurl',
|
type: 'application/vnd.apple.mpegurl',
|
||||||
});
|
});
|
||||||
video.poster(`${apiHost}/api/events/${id}/snapshot.jpg`);
|
video.poster(`${apiHost}api/events/${id}/snapshot.jpg`);
|
||||||
if (videoIsPlaying) {
|
if (videoIsPlaying) {
|
||||||
video.play();
|
video.play();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,12 +157,9 @@ class VideoRTC extends HTMLElement {
|
|||||||
if (this.ws) this.ws.send(JSON.stringify(value));
|
if (this.ws) this.ws.send(JSON.stringify(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
codecs(type) {
|
/** @param {Function} isSupported */
|
||||||
const test =
|
codecs(isSupported) {
|
||||||
type === 'mse'
|
return this.CODECS.filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
|
||||||
? (codec) => MediaSource.isTypeSupported(`video/mp4; codecs="${codec}"`)
|
|
||||||
: (codec) => this.video.canPlayType(`video/mp4; codecs="${codec}"`);
|
|
||||||
return this.CODECS.filter(test).join();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -311,7 +308,7 @@ class VideoRTC extends HTMLElement {
|
|||||||
|
|
||||||
const modes = [];
|
const modes = [];
|
||||||
|
|
||||||
if (this.mode.indexOf('mse') >= 0 && 'MediaSource' in window) {
|
if (this.mode.indexOf('mse') >= 0 && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
|
||||||
// iPhone
|
// iPhone
|
||||||
modes.push('mse');
|
modes.push('mse');
|
||||||
this.onmse();
|
this.onmse();
|
||||||
@@ -363,18 +360,29 @@ class VideoRTC extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onmse() {
|
onmse() {
|
||||||
const ms = new MediaSource();
|
/** @type {MediaSource} */
|
||||||
ms.addEventListener(
|
let ms;
|
||||||
'sourceopen',
|
|
||||||
() => {
|
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);
|
URL.revokeObjectURL(this.video.src);
|
||||||
this.send({ type: 'mse', value: this.codecs('mse') });
|
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
|
||||||
},
|
}, {once: true});
|
||||||
{ once: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
this.video.src = URL.createObjectURL(ms);
|
this.video.src = URL.createObjectURL(ms);
|
||||||
this.video.srcObject = null;
|
this.video.srcObject = null;
|
||||||
|
}
|
||||||
this.play();
|
this.play();
|
||||||
|
|
||||||
this.mseCodecs = '';
|
this.mseCodecs = '';
|
||||||
@@ -580,7 +588,7 @@ class VideoRTC extends HTMLElement {
|
|||||||
video2.src = `data:video/mp4;base64,${VideoRTC.btoa(data)}`;
|
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) {
|
static btoa(buffer) {
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import Menu from './Menu';
|
|||||||
import { ArrowDropdown } from '../icons/ArrowDropdown';
|
import { ArrowDropdown } from '../icons/ArrowDropdown';
|
||||||
import Heading from './Heading';
|
import Heading from './Heading';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
import CameraIcon from '../icons/Camera';
|
import SelectOnlyIcon from '../icons/SelectOnly';
|
||||||
import SpeakerIcon from '../icons/Speaker';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
|
|
||||||
export default function MultiSelect({ className, title, options, selection, onToggle, onShowAll, onSelectSingle }) {
|
export default function MultiSelect({ className, title, options, selection, onToggle, onShowAll, onSelectSingle }) {
|
||||||
const popupRef = useRef(null);
|
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 menuHeight = Math.round(window.innerHeight * 0.55);
|
||||||
const { data: config } = useSWR('config');
|
|
||||||
return (
|
return (
|
||||||
<div className={`${className} p-2`} ref={popupRef}>
|
<div className={`${className} p-2`} ref={popupRef}>
|
||||||
<div className="flex justify-between min-w-[120px]" onClick={() => setState({ showMenu: true })}>
|
<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"
|
className="max-h-[35px] mx-2"
|
||||||
onClick={() => onSelectSingle(item)}
|
onClick={() => onSelectSingle(item)}
|
||||||
>
|
>
|
||||||
{ (title === "Labels" && config.audio.listen.includes(item)) ? ( <SpeakerIcon /> ) : ( <CameraIcon /> ) }
|
{ ( <SelectOnlyIcon /> ) }
|
||||||
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export function EventCard({ camera, event }) {
|
|||||||
<Link className="" href={`/recording/${camera}/${format(start, 'yyyy-MM-dd/HH/mm/ss')}`}>
|
<Link className="" href={`/recording/${camera}/${format(start, 'yyyy-MM-dd/HH/mm/ss')}`}>
|
||||||
<div className="flex flex-row mb-2">
|
<div className="flex flex-row mb-2">
|
||||||
<div className="w-28 mr-4">
|
<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>
|
||||||
<div className="flex flex-row w-full border-b">
|
<div className="flex flex-row w-full border-b">
|
||||||
<div className="w-full text-gray-700 font-semibold relative pt-0">
|
<div className="w-full text-gray-700 font-semibold relative pt-0">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { useCallback, useState } from 'preact/hooks';
|
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 [isFocused, setFocused] = useState(false);
|
||||||
|
|
||||||
const handleChange = useCallback(() => {
|
const handleChange = useCallback(() => {
|
||||||
@@ -21,7 +21,7 @@ export default function Switch({ checked, id, onChange, label, labelPosition = '
|
|||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
htmlFor={id}
|
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' ? (
|
{label && labelPosition === 'before' ? (
|
||||||
<div data-testid={`${id}-label`} className="inline-flex flex-grow">
|
<div data-testid={`${id}-label`} className="inline-flex flex-grow">
|
||||||
|
|||||||
@@ -1,182 +1,18 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { ArrowDropdown } from '../icons/ArrowDropdown';
|
import { ArrowDropdown } from '../icons/ArrowDropdown';
|
||||||
import { ArrowDropup } from '../icons/ArrowDropup';
|
import { ArrowDropup } from '../icons/ArrowDropup';
|
||||||
|
import Heading from './Heading';
|
||||||
|
|
||||||
const TimePicker = ({ dateRange, onChange }) => {
|
const TimePicker = ({ timeRange, onChange }) => {
|
||||||
const [error, setError] = useState(null);
|
const times = timeRange.split(',');
|
||||||
const [timeRange, setTimeRange] = useState(new Set());
|
const [after, setAfter] = useState(times[0]);
|
||||||
const [hoverIdx, setHoverIdx] = useState(null);
|
const [before, setBefore] = useState(times[1]);
|
||||||
const [reset, setReset] = useState(false);
|
|
||||||
|
|
||||||
/**
|
// Create repeating array with the number of hours for 1 day ...23,24,0,1,2...
|
||||||
* Initializes two variables before and after with date objects,
|
const hoursInDays = Array.from({ length: 24 }, (_, i) => String(i % 24).padStart(2, '0'));
|
||||||
* 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]
|
|
||||||
);
|
|
||||||
|
|
||||||
// background colors for each day
|
// background colors for each day
|
||||||
const isSelectedCss = 'bg-blue-600 transition duration-300 ease-in-out hover:rounded-none';
|
|
||||||
function randomGrayTone(shade) {
|
function randomGrayTone(shade) {
|
||||||
const grayTones = [
|
const grayTones = [
|
||||||
'bg-[#212529]/50',
|
'bg-[#212529]/50',
|
||||||
@@ -193,45 +29,73 @@ const TimePicker = ({ dateRange, onChange }) => {
|
|||||||
return grayTones[shade % grayTones.length];
|
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 (
|
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="mt-2 pr-3 hidden xs:block" aria-label="Calendar timepicker, select a time range">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<ArrowDropup className="w-10 text-center" />
|
<ArrowDropup className="w-10 text-center" />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-20 px-1">
|
<div className="px-1 flex justify-between">
|
||||||
|
<div>
|
||||||
|
<Heading className="text-center" size="sm">
|
||||||
|
After
|
||||||
|
</Heading>
|
||||||
<div
|
<div
|
||||||
className="border border-gray-400/50 cursor-pointer hide-scroll shadow-md rounded-md"
|
className="w-20 border border-gray-400/50 cursor-pointer hide-scroll shadow-md rounded-md"
|
||||||
style={{ maxHeight: '17rem', overflowY: 'scroll' }}
|
style={{ maxHeight: '17rem', overflowY: 'scroll' }}
|
||||||
>
|
>
|
||||||
{hoursInDays.map((_, idx) => (
|
{hoursInDays.map((time, idx) => (
|
||||||
<div
|
<div className={`${isSelected(time, after) ? isSelectedCss : ''}`} key={idx} id={`timeIndex-${idx}`}>
|
||||||
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
|
<div
|
||||||
className={`
|
className={`
|
||||||
text-gray-300 w-full font-light border border-transparent hover:border hover:rounded-md hover:border-gray-600 text-center text-sm
|
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)])}`}
|
${randomGrayTone([Math.floor(idx / 24)])}`}
|
||||||
onClick={() => handleTime(idx)}
|
onClick={() => handleTime(`${time}:00`, before)}
|
||||||
>
|
>
|
||||||
<span aria-label={`${idx}:00`}>{hoursInDays[idx]}:00</span>
|
<span aria-label={`${idx}:00`}>{hoursInDays[idx]}:00</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<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">
|
<div className="flex items-center justify-center">
|
||||||
<ArrowDropdown className="w-10 text-center" />
|
<ArrowDropdown className="w-10 text-center" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function TimelineEventOverlay({ eventOverlay, cameraConfig }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{isHovering && (
|
{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>{`Area: ${getObjectArea()} px`}</div>
|
||||||
<div>{`Ratio: ${getObjectRatio()}`}</div>
|
<div>{`Ratio: ${getObjectRatio()}`}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import ActiveObjectIcon from '../icons/ActiveObject';
|
|||||||
import PlayIcon from '../icons/Play';
|
import PlayIcon from '../icons/Play';
|
||||||
import ExitIcon from '../icons/Exit';
|
import ExitIcon from '../icons/Exit';
|
||||||
import StationaryObjectIcon from '../icons/StationaryObject';
|
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 { useMemo, useState } from 'preact/hooks';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
|
|
||||||
@@ -88,7 +91,7 @@ export default function TimelineSummary({ event, onFrameSelected }) {
|
|||||||
aria-label={window.innerWidth > 640 ? getTimelineItemDescription(config, item, event) : ''}
|
aria-label={window.innerWidth > 640 ? getTimelineItemDescription(config, item, event) : ''}
|
||||||
onClick={() => onSelectMoment(index)}
|
onClick={() => onSelectMoment(index)}
|
||||||
>
|
>
|
||||||
{getTimelineIcon(item.class_type)}
|
{getTimelineIcon(item)}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -113,8 +116,8 @@ export default function TimelineSummary({ event, onFrameSelected }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTimelineIcon(classType) {
|
function getTimelineIcon(timelineItem) {
|
||||||
switch (classType) {
|
switch (timelineItem.class_type) {
|
||||||
case 'visible':
|
case 'visible':
|
||||||
return <PlayIcon className="w-8" />;
|
return <PlayIcon className="w-8" />;
|
||||||
case 'gone':
|
case 'gone':
|
||||||
@@ -124,7 +127,16 @@ function getTimelineIcon(classType) {
|
|||||||
case 'stationary':
|
case 'stationary':
|
||||||
return <StationaryObjectIcon className="w-8" />;
|
return <StationaryObjectIcon className="w-8" />;
|
||||||
case 'entered_zone':
|
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_style: 'medium',
|
||||||
time_format: config.ui.time_format,
|
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':
|
case 'gone':
|
||||||
return `${event.label} left at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
|
return `${event.label} left at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
|
||||||
date_style: 'short',
|
date_style: 'short',
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export const ENV = import.meta.env.MODE;
|
export const ENV = import.meta.env.MODE;
|
||||||
export const API_HOST = ENV === 'production' ? '' : 'http://localhost:5000/';
|
|
||||||
|
|||||||
15
web/src/icons/DeliveryTruck.jsx
Normal file
15
web/src/icons/DeliveryTruck.jsx
Normal 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
22
web/src/icons/Face.jsx
Normal 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);
|
||||||
15
web/src/icons/LicensePlate.jsx
Normal file
15
web/src/icons/LicensePlate.jsx
Normal 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);
|
||||||
21
web/src/icons/SelectOnly.jsx
Normal file
21
web/src/icons/SelectOnly.jsx
Normal 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);
|
||||||
@@ -35,7 +35,7 @@ export default function Birdseye() {
|
|||||||
let player;
|
let player;
|
||||||
const playerClass = ptzCameras.length || isMaxWidth ? 'w-full' : 'max-w-5xl xl:w-1/2';
|
const playerClass = ptzCameras.length || isMaxWidth ? 'w-full' : 'max-w-5xl xl:w-1/2';
|
||||||
if (viewSource == 'mse' && config.birdseye.restream) {
|
if (viewSource == 'mse' && config.birdseye.restream) {
|
||||||
if ('MediaSource' in window) {
|
if ('MediaSource' in window || 'ManagedMediaSource' in window) {
|
||||||
player = (
|
player = (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div className={playerClass}>
|
<div className={playerClass}>
|
||||||
@@ -50,7 +50,7 @@ export default function Birdseye() {
|
|||||||
player = (
|
player = (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div className="w-5xl text-center text-sm">
|
<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>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export default function Camera({ camera }) {
|
|||||||
let player;
|
let player;
|
||||||
if (viewMode === 'live') {
|
if (viewMode === 'live') {
|
||||||
if (viewSource == 'mse' && restreamEnabled) {
|
if (viewSource == 'mse' && restreamEnabled) {
|
||||||
if ('MediaSource' in window) {
|
if ('MediaSource' in window || 'ManagedMediaSource' in window) {
|
||||||
player = (
|
player = (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div className="max-w-5xl">
|
<div className="max-w-5xl">
|
||||||
@@ -133,7 +133,7 @@ export default function Camera({ camera }) {
|
|||||||
player = (
|
player = (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div className="w-5xl text-center text-sm">
|
<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>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
@@ -212,7 +212,7 @@ export default function Camera({ camera }) {
|
|||||||
key={objectType}
|
key={objectType}
|
||||||
header={objectType}
|
header={objectType}
|
||||||
href={`/events?cameras=${camera}&labels=${encodeURIComponent(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>
|
</div>
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export default function CameraMasks({ camera }) {
|
|||||||
const endpoint = `config/set?${queryParameters}`;
|
const endpoint = `config/set?${queryParameters}`;
|
||||||
const response = await axios.put(endpoint);
|
const response = await axios.put(endpoint);
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
setSuccess(response.data);
|
setSuccess(response.data.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
@@ -146,7 +146,6 @@ export default function CameraMasks({ camera }) {
|
|||||||
}
|
}
|
||||||
}, [camera, motionMaskPoints]);
|
}, [camera, motionMaskPoints]);
|
||||||
|
|
||||||
|
|
||||||
// Zone methods
|
// Zone methods
|
||||||
const handleEditZone = useCallback(
|
const handleEditZone = useCallback(
|
||||||
(key) => {
|
(key) => {
|
||||||
@@ -177,7 +176,9 @@ export default function CameraMasks({ camera }) {
|
|||||||
${Object.keys(zonePoints)
|
${Object.keys(zonePoints)
|
||||||
.map(
|
.map(
|
||||||
(zoneName) => ` ${zoneName}:
|
(zoneName) => ` ${zoneName}:
|
||||||
coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`).join('\n')}`;
|
coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`
|
||||||
|
)
|
||||||
|
.join('\n')}`;
|
||||||
|
|
||||||
if (window.navigator.clipboard && window.navigator.clipboard.writeText) {
|
if (window.navigator.clipboard && window.navigator.clipboard.writeText) {
|
||||||
// Use Clipboard API if available
|
// Use Clipboard API if available
|
||||||
@@ -207,7 +208,10 @@ ${Object.keys(zonePoints)
|
|||||||
const handleSaveZones = useCallback(async () => {
|
const handleSaveZones = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const queryParameters = Object.keys(zonePoints)
|
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('&');
|
.join('&');
|
||||||
const endpoint = `config/set?${queryParameters}`;
|
const endpoint = `config/set?${queryParameters}`;
|
||||||
const response = await axios.put(endpoint);
|
const response = await axios.put(endpoint);
|
||||||
@@ -266,7 +270,12 @@ ${Object.keys(objectMaskPoints)
|
|||||||
try {
|
try {
|
||||||
const queryParameters = Object.keys(objectMaskPoints)
|
const queryParameters = Object.keys(objectMaskPoints)
|
||||||
.filter((objectName) => objectMaskPoints[objectName].length > 0)
|
.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('&');
|
.join('&');
|
||||||
const endpoint = `config/set?${queryParameters}`;
|
const endpoint = `config/set?${queryParameters}`;
|
||||||
const response = await axios.put(endpoint);
|
const response = await axios.put(endpoint);
|
||||||
@@ -324,8 +333,8 @@ ${Object.keys(objectMaskPoints)
|
|||||||
<Card
|
<Card
|
||||||
content={
|
content={
|
||||||
<p>
|
<p>
|
||||||
When done, copy each mask configuration into your <code className="font-mono">config.yml</code> file
|
When done, copy each mask configuration into your <code className="font-mono">config.yml</code> file restart
|
||||||
restart your Frigate instance to save your changes.
|
your Frigate instance to save your changes.
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
header="Warning"
|
header="Warning"
|
||||||
@@ -336,7 +345,7 @@ ${Object.keys(objectMaskPoints)
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<img ref={imageRef} src={`${apiHost}/api/${camera}/latest.jpg`} />
|
<img ref={imageRef} src={`${apiHost}api/${camera}/latest.jpg`} />
|
||||||
<EditableMask
|
<EditableMask
|
||||||
onChange={handleUpdateEditable}
|
onChange={handleUpdateEditable}
|
||||||
points={'subkey' in editing ? editing.set[editing.key][editing.subkey] : editing.set[editing.key]}
|
points={'subkey' in editing ? editing.set[editing.key][editing.subkey] : editing.set[editing.key]}
|
||||||
@@ -569,8 +578,6 @@ function MaskValues({
|
|||||||
[onAdd]
|
[onAdd]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden" onMouseOver={handleMousein} onMouseOut={handleMouseout}>
|
<div className="overflow-hidden" onMouseOver={handleMousein} onMouseOut={handleMouseout}>
|
||||||
<div className="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
|
|||||||
@@ -5,24 +5,41 @@ import CameraImage from '../components/CameraImage';
|
|||||||
import AudioIcon from '../icons/Audio';
|
import AudioIcon from '../icons/Audio';
|
||||||
import ClipIcon from '../icons/Clip';
|
import ClipIcon from '../icons/Clip';
|
||||||
import MotionIcon from '../icons/Motion';
|
import MotionIcon from '../icons/Motion';
|
||||||
|
import SettingsIcon from '../icons/Settings';
|
||||||
import SnapshotIcon from '../icons/Snapshot';
|
import SnapshotIcon from '../icons/Snapshot';
|
||||||
import { useAudioState, useDetectState, useRecordingsState, useSnapshotsState } from '../api/ws';
|
import { useAudioState, useDetectState, useRecordingsState, useSnapshotsState } from '../api/ws';
|
||||||
import { useMemo } from 'preact/hooks';
|
import { useMemo } from 'preact/hooks';
|
||||||
import useSWR from 'swr';
|
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() {
|
export default function Cameras() {
|
||||||
const { data: config } = useSWR('config');
|
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 ? (
|
return !config ? (
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4 p-2 px-4">
|
<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} />
|
<SortedCameras config={config} unsortedCameras={config.cameras} availableWidth={availableWidth} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortedCameras({ config, unsortedCameras }) {
|
function SortedCameras({ config, unsortedCameras, availableWidth }) {
|
||||||
const sortedCameras = useMemo(
|
const sortedCameras = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.entries(unsortedCameras)
|
Object.entries(unsortedCameras)
|
||||||
@@ -34,17 +51,20 @@ function SortedCameras({ config, unsortedCameras }) {
|
|||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
{sortedCameras.map(([camera, conf]) => (
|
{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>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Camera({ name, config }) {
|
function Camera({ name, config, availableWidth }) {
|
||||||
const { payload: detectValue, send: sendDetect } = useDetectState(name);
|
const { payload: detectValue, send: sendDetect } = useDetectState(name);
|
||||||
const { payload: recordValue, send: sendRecordings } = useRecordingsState(name);
|
const { payload: recordValue, send: sendRecordings } = useRecordingsState(name);
|
||||||
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
|
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
|
||||||
const { payload: audioValue, send: sendAudio } = useAudioState(name);
|
const { payload: audioValue, send: sendAudio } = useAudioState(name);
|
||||||
|
|
||||||
|
const [cameraOptions, setCameraOptions] = useState('');
|
||||||
|
|
||||||
const href = `/cameras/${name}`;
|
const href = `/cameras/${name}`;
|
||||||
const buttons = useMemo(() => {
|
const buttons = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@@ -56,7 +76,15 @@ function Camera({ name, config }) {
|
|||||||
return `${name.replaceAll('_', ' ')}`;
|
return `${name.replaceAll('_', ' ')}`;
|
||||||
}, [name]);
|
}, [name]);
|
||||||
const icons = useMemo(
|
const icons = useMemo(
|
||||||
() => [
|
() => (availableWidth < 448 ? [
|
||||||
|
{
|
||||||
|
icon: SettingsIcon,
|
||||||
|
color: 'gray',
|
||||||
|
onClick: () => {
|
||||||
|
setCameraOptions(config.name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] : [
|
||||||
{
|
{
|
||||||
name: `Toggle detect ${detectValue === 'ON' ? 'off' : 'on'}`,
|
name: `Toggle detect ${detectValue === 'ON' ? 'off' : 'on'}`,
|
||||||
icon: MotionIcon,
|
icon: MotionIcon,
|
||||||
@@ -95,11 +123,57 @@ function Camera({ name, config }) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
].filter((button) => button != null),
|
]).filter((button) => button != null),
|
||||||
[config, audioValue, sendAudio, detectValue, sendDetect, recordValue, sendRecordings, snapshotValue, sendSnapshots]
|
[config, availableWidth, setCameraOptions, audioValue, sendAudio, detectValue, sendDetect, recordValue, sendRecordings, snapshotValue, sendSnapshots]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<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
|
<Card
|
||||||
buttons={buttons}
|
buttons={buttons}
|
||||||
href={href}
|
href={href}
|
||||||
@@ -107,5 +181,6 @@ function Camera({ name, config }) {
|
|||||||
icons={icons}
|
icons={icons}
|
||||||
media={<CameraImage camera={name} stretch />}
|
media={<CameraImage camera={name} stretch />}
|
||||||
/>
|
/>
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function Config() {
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
setError('');
|
setError('');
|
||||||
setSuccess(response.data);
|
setSuccess(response.data.message);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -61,9 +61,9 @@ export default function Config() {
|
|||||||
|
|
||||||
let yamlModel;
|
let yamlModel;
|
||||||
if (editor.getModels().length > 0) {
|
if (editor.getModels().length > 0) {
|
||||||
yamlModel = editor.getModel(modelUri)
|
yamlModel = editor.getModel(modelUri);
|
||||||
} else {
|
} else {
|
||||||
yamlModel = editor.createModel(config, 'yaml', modelUri)
|
yamlModel = editor.createModel(config, 'yaml', modelUri);
|
||||||
}
|
}
|
||||||
|
|
||||||
setDiagnosticsOptions({
|
setDiagnosticsOptions({
|
||||||
@@ -74,7 +74,7 @@ export default function Config() {
|
|||||||
format: true,
|
format: true,
|
||||||
schemas: [
|
schemas: [
|
||||||
{
|
{
|
||||||
uri: `${apiHost}/api/config/schema.json`,
|
uri: `${apiHost}api/config/schema.json`,
|
||||||
fileMatch: [String(modelUri)],
|
fileMatch: [String(modelUri)],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -100,10 +100,10 @@ export default function Config() {
|
|||||||
<Button className="mx-2" onClick={(e) => handleCopyConfig(e)}>
|
<Button className="mx-2" onClick={(e) => handleCopyConfig(e)}>
|
||||||
Copy Config
|
Copy Config
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, "restart")}>
|
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, 'restart')}>
|
||||||
Save & Restart
|
Save & Restart
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, "saveonly")}>
|
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, 'saveonly')}>
|
||||||
Save Only
|
Save Only
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ const monthsAgo = (num) => {
|
|||||||
|
|
||||||
export default function Events({ path, ...props }) {
|
export default function Events({ path, ...props }) {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
|
const { data: config } = useSWR('config');
|
||||||
|
const timezone = useMemo(() => config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config]);
|
||||||
const [searchParams, setSearchParams] = useState({
|
const [searchParams, setSearchParams] = useState({
|
||||||
before: null,
|
before: null,
|
||||||
after: null,
|
after: null,
|
||||||
@@ -55,6 +57,8 @@ export default function Events({ path, ...props }) {
|
|||||||
labels: props.labels ?? 'all',
|
labels: props.labels ?? 'all',
|
||||||
zones: props.zones ?? 'all',
|
zones: props.zones ?? 'all',
|
||||||
sub_labels: props.sub_labels ?? 'all',
|
sub_labels: props.sub_labels ?? 'all',
|
||||||
|
time_range: '00:00,24:00',
|
||||||
|
timezone,
|
||||||
favorites: props.favorites ?? 0,
|
favorites: props.favorites ?? 0,
|
||||||
event: props.event,
|
event: props.event,
|
||||||
});
|
});
|
||||||
@@ -87,14 +91,17 @@ export default function Events({ path, ...props }) {
|
|||||||
showDeleteFavorite: false,
|
showDeleteFavorite: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventsFetcher = useCallback((path, params) => {
|
const eventsFetcher = useCallback(
|
||||||
|
(path, params) => {
|
||||||
if (searchParams.event) {
|
if (searchParams.event) {
|
||||||
path = `${path}/${searchParams.event}`;
|
path = `${path}/${searchParams.event}`;
|
||||||
return axios.get(path).then((res) => [res.data]);
|
return axios.get(path).then((res) => [res.data]);
|
||||||
}
|
}
|
||||||
params = { ...params, include_thumbnails: 0, limit: API_LIMIT };
|
params = { ...params, include_thumbnails: 0, limit: API_LIMIT };
|
||||||
return axios.get(path, { params }).then((res) => res.data);
|
return axios.get(path, { params }).then((res) => res.data);
|
||||||
}, [searchParams]);
|
},
|
||||||
|
[searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
const getKey = useCallback(
|
const getKey = useCallback(
|
||||||
(index, prevData) => {
|
(index, prevData) => {
|
||||||
@@ -111,8 +118,6 @@ export default function Events({ path, ...props }) {
|
|||||||
|
|
||||||
const { data: eventPages, mutate, size, setSize, isValidating } = useSWRInfinite(getKey, eventsFetcher);
|
const { data: eventPages, mutate, size, setSize, isValidating } = useSWRInfinite(getKey, eventsFetcher);
|
||||||
|
|
||||||
const { data: config } = useSWR('config');
|
|
||||||
|
|
||||||
const { data: allLabels } = useSWR(['labels']);
|
const { data: allLabels } = useSWR(['labels']);
|
||||||
const { data: allSubLabels } = useSWR(['sub_labels', { split_joined: 1 }]);
|
const { data: allSubLabels } = useSWR(['sub_labels', { split_joined: 1 }]);
|
||||||
|
|
||||||
@@ -239,6 +244,13 @@ export default function Events({ path, ...props }) {
|
|||||||
[searchParams, setSearchParams, state, setState]
|
[searchParams, setSearchParams, state, setState]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSelectTimeRange = useCallback(
|
||||||
|
(timeRange) => {
|
||||||
|
setSearchParams({ ...searchParams, time_range: timeRange });
|
||||||
|
},
|
||||||
|
[searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
const onFilter = useCallback(
|
const onFilter = useCallback(
|
||||||
(name, value) => {
|
(name, value) => {
|
||||||
const updatedParams = { ...searchParams, [name]: value };
|
const updatedParams = { ...searchParams, [name]: value };
|
||||||
@@ -265,12 +277,16 @@ export default function Events({ path, ...props }) {
|
|||||||
(node) => {
|
(node) => {
|
||||||
if (isValidating) return;
|
if (isValidating) return;
|
||||||
if (observer.current) observer.current.disconnect();
|
if (observer.current) observer.current.disconnect();
|
||||||
|
try {
|
||||||
observer.current = new IntersectionObserver((entries) => {
|
observer.current = new IntersectionObserver((entries) => {
|
||||||
if (entries[0].isIntersecting && !isDone) {
|
if (entries[0].isIntersecting && !isDone) {
|
||||||
setSize(size + 1);
|
setSize(size + 1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (node) observer.current.observe(node);
|
if (node) observer.current.observe(node);
|
||||||
|
} catch (e) {
|
||||||
|
// no op
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[size, setSize, isValidating, isDone]
|
[size, setSize, isValidating, isDone]
|
||||||
);
|
);
|
||||||
@@ -361,7 +377,7 @@ export default function Events({ path, ...props }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{searchParams.event && (
|
{searchParams.event && (
|
||||||
<Button className="ml-2" onClick={() => onFilter('event',null)} type="text">
|
<Button className="ml-2" onClick={() => onFilter('event', null)} type="text">
|
||||||
View All
|
View All
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -386,7 +402,7 @@ export default function Events({ path, ...props }) {
|
|||||||
icon={Snapshot}
|
icon={Snapshot}
|
||||||
label="Download Snapshot"
|
label="Download Snapshot"
|
||||||
value="snapshot"
|
value="snapshot"
|
||||||
href={`${apiHost}/api/events/${downloadEvent.id}/snapshot.jpg?download=true`}
|
href={`${apiHost}api/events/${downloadEvent.id}/snapshot.jpg?download=true`}
|
||||||
download
|
download
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -395,11 +411,14 @@ export default function Events({ path, ...props }) {
|
|||||||
icon={Clip}
|
icon={Clip}
|
||||||
label="Download Clip"
|
label="Download Clip"
|
||||||
value="clip"
|
value="clip"
|
||||||
href={`${apiHost}/api/events/${downloadEvent.id}/clip.mp4?download=true`}
|
href={`${apiHost}api/events/${downloadEvent.id}/clip.mp4?download=true`}
|
||||||
download
|
download
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(event?.data?.type || "object") == "object" && downloadEvent.end_time && downloadEvent.has_snapshot && !downloadEvent.plus_id && (
|
{(event?.data?.type || 'object') == 'object' &&
|
||||||
|
downloadEvent.end_time &&
|
||||||
|
downloadEvent.has_snapshot &&
|
||||||
|
!downloadEvent.plus_id && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={UploadPlus}
|
icon={UploadPlus}
|
||||||
label={uploading.includes(downloadEvent.id) ? 'Uploading...' : 'Send to Frigate+'}
|
label={uploading.includes(downloadEvent.id) ? 'Uploading...' : 'Send to Frigate+'}
|
||||||
@@ -459,10 +478,7 @@ export default function Events({ path, ...props }) {
|
|||||||
dateRange={{ before: searchParams.before * 1000 || null, after: searchParams.after * 1000 || null }}
|
dateRange={{ before: searchParams.before * 1000 || null, after: searchParams.after * 1000 || null }}
|
||||||
close={() => setState({ ...state, showCalendar: false })}
|
close={() => setState({ ...state, showCalendar: false })}
|
||||||
>
|
>
|
||||||
<Timepicker
|
<Timepicker timeRange={searchParams.time_range} onChange={handleSelectTimeRange} />
|
||||||
dateRange={{ before: searchParams.before * 1000 || null, after: searchParams.after * 1000 || null }}
|
|
||||||
onChange={handleSelectDateRange}
|
|
||||||
/>
|
|
||||||
</Calendar>
|
</Calendar>
|
||||||
</Menu>
|
</Menu>
|
||||||
</span>
|
</span>
|
||||||
@@ -476,7 +492,7 @@ export default function Events({ path, ...props }) {
|
|||||||
|
|
||||||
<img
|
<img
|
||||||
className="flex-grow-0"
|
className="flex-grow-0"
|
||||||
src={`${apiHost}/api/events/${plusSubmitEvent.id}/snapshot.jpg`}
|
src={`${apiHost}api/events/${plusSubmitEvent.id}/snapshot.jpg`}
|
||||||
alt={`${plusSubmitEvent.label}`}
|
alt={`${plusSubmitEvent.label}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -566,7 +582,11 @@ export default function Events({ path, ...props }) {
|
|||||||
<p className="mb-2">Confirm deletion of saved event.</p>
|
<p className="mb-2">Confirm deletion of saved event.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
||||||
<Button className="ml-2" onClick={() => setDeleteFavoriteState({ ...state, showDeleteFavorite: false })} type="text">
|
<Button
|
||||||
|
className="ml-2"
|
||||||
|
onClick={() => setDeleteFavoriteState({ ...state, showDeleteFavorite: false })}
|
||||||
|
type="text"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -599,7 +619,7 @@ export default function Events({ path, ...props }) {
|
|||||||
<div
|
<div
|
||||||
className="relative rounded-l flex-initial min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center"
|
className="relative rounded-l flex-initial min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center"
|
||||||
style={{
|
style={{
|
||||||
'background-image': `url(${apiHost}/api/events/${event.id}/thumbnail.jpg)`,
|
'background-image': `url(${apiHost}api/events/${event.id}/thumbnail.jpg)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StarRecording
|
<StarRecording
|
||||||
@@ -635,10 +655,12 @@ export default function Events({ path, ...props }) {
|
|||||||
<Camera className="h-5 w-5 mr-2 inline" />
|
<Camera className="h-5 w-5 mr-2 inline" />
|
||||||
{event.camera.replaceAll('_', ' ')}
|
{event.camera.replaceAll('_', ' ')}
|
||||||
</div>
|
</div>
|
||||||
{event.zones.length ? <div className="capitalize text-sm flex align-center">
|
{event.zones.length ? (
|
||||||
|
<div className="capitalize text-sm flex align-center">
|
||||||
<Zone className="w-5 h-5 mr-2 inline" />
|
<Zone className="w-5 h-5 mr-2 inline" />
|
||||||
{event.zones.join(', ').replaceAll('_', ' ')}
|
{event.zones.join(', ').replaceAll('_', ' ')}
|
||||||
</div> : null}
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="capitalize text-sm flex align-center">
|
<div className="capitalize text-sm flex align-center">
|
||||||
<Score className="w-5 h-5 mr-2 inline" />
|
<Score className="w-5 h-5 mr-2 inline" />
|
||||||
{(event?.data?.top_score || event.top_score || 0) == 0
|
{(event?.data?.top_score || event.top_score || 0) == 0
|
||||||
@@ -650,7 +672,7 @@ export default function Events({ path, ...props }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden sm:flex flex-col justify-end mr-2">
|
<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>
|
<Fragment>
|
||||||
{event.plus_id ? (
|
{event.plus_id ? (
|
||||||
<div className="uppercase text-xs underline">
|
<div className="uppercase text-xs underline">
|
||||||
@@ -754,8 +776,8 @@ export default function Events({ path, ...props }) {
|
|||||||
className="flex-grow-0"
|
className="flex-grow-0"
|
||||||
src={
|
src={
|
||||||
event.has_snapshot
|
event.has_snapshot
|
||||||
? `${apiHost}/api/events/${event.id}/snapshot.jpg`
|
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
||||||
: `${apiHost}/api/events/${event.id}/thumbnail.jpg`
|
: `${apiHost}api/events/${event.id}/thumbnail.jpg`
|
||||||
}
|
}
|
||||||
alt={`${event.label} at ${((event?.data?.top_score || event.top_score) * 100).toFixed(
|
alt={`${event.label} at ${((event?.data?.top_score || event.top_score) * 100).toFixed(
|
||||||
0
|
0
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ export default function Export() {
|
|||||||
const localISODate = localDate.toISOString().split('T')[0];
|
const localISODate = localDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
const [startDate, setStartDate] = useState(localISODate);
|
const [startDate, setStartDate] = useState(localISODate);
|
||||||
const [startTime, setStartTime] = useState('00:00');
|
const [startTime, setStartTime] = useState('00:00:00');
|
||||||
const [endDate, setEndDate] = useState(localISODate);
|
const [endDate, setEndDate] = useState(localISODate);
|
||||||
const [endTime, setEndTime] = useState('23:59');
|
const [endTime, setEndTime] = useState('23:59:59');
|
||||||
|
|
||||||
// Export States
|
// Export States
|
||||||
|
|
||||||
@@ -185,6 +185,7 @@ export default function Export() {
|
|||||||
id="startTime"
|
id="startTime"
|
||||||
type="time"
|
type="time"
|
||||||
value={startTime}
|
value={startTime}
|
||||||
|
step="1"
|
||||||
onChange={(e) => setStartTime(e.target.value)}
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Heading className="py-2" size="sm">
|
<Heading className="py-2" size="sm">
|
||||||
@@ -202,6 +203,7 @@ export default function Export() {
|
|||||||
id="endTime"
|
id="endTime"
|
||||||
type="time"
|
type="time"
|
||||||
value={endTime}
|
value={endTime}
|
||||||
|
step="1"
|
||||||
onChange={(e) => setEndTime(e.target.value)}
|
onChange={(e) => setEndTime(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ export default function Storage() {
|
|||||||
const getUnitSize = (MB) => {
|
const getUnitSize = (MB) => {
|
||||||
if (isNaN(MB) || MB < 0) return 'Invalid number';
|
if (isNaN(MB) || MB < 0) return 'Invalid number';
|
||||||
if (MB < 1024) return `${MB} MiB`;
|
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;
|
let storage_usage;
|
||||||
|
|||||||
@@ -301,12 +301,16 @@ export default function System() {
|
|||||||
<Tr>
|
<Tr>
|
||||||
<Th>GPU %</Th>
|
<Th>GPU %</Th>
|
||||||
<Th>Memory %</Th>
|
<Th>Memory %</Th>
|
||||||
|
{'dec' in gpu_usages[gpu] && (<Th>Decoder %</Th>)}
|
||||||
|
{'enc' in gpu_usages[gpu] && (<Th>Encoder %</Th>)}
|
||||||
</Tr>
|
</Tr>
|
||||||
</Thead>
|
</Thead>
|
||||||
<Tbody>
|
<Tbody>
|
||||||
<Tr>
|
<Tr>
|
||||||
<Td>{gpu_usages[gpu]['gpu']}</Td>
|
<Td>{gpu_usages[gpu]['gpu']}</Td>
|
||||||
<Td>{gpu_usages[gpu]['mem']}</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>
|
</Tr>
|
||||||
</Tbody>
|
</Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -334,8 +338,13 @@ export default function System() {
|
|||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
) : (
|
) : (
|
||||||
<div data-testid="cameras" className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
|
<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"] && (
|
{cameraNames.map(
|
||||||
<div key={camera} className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow">
|
(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">
|
<div className="capitalize text-lg flex justify-between p-4">
|
||||||
<Link href={`/cameras/${camera}`}>{camera.replaceAll('_', ' ')}</Link>
|
<Link href={`/cameras/${camera}`}>{camera.replaceAll('_', ' ')}</Link>
|
||||||
<Button onClick={(e) => onHandleFfprobe(camera, e)}>ffprobe</Button>
|
<Button onClick={(e) => onHandleFfprobe(camera, e)}>ffprobe</Button>
|
||||||
@@ -406,8 +415,9 @@ export default function System() {
|
|||||||
</Tbody>
|
</Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div> )
|
</div>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import * as CameraImage from '../../components/CameraImage';
|
import * as CameraImage from '../../components/CameraImage';
|
||||||
|
import * as Hooks from '../../hooks';
|
||||||
import * as WS from '../../api/ws';
|
import * as WS from '../../api/ws';
|
||||||
import Cameras from '../Cameras';
|
import Cameras from '../Cameras';
|
||||||
import { fireEvent, render, screen, waitForElementToBeRemoved } from 'testing-library';
|
import { fireEvent, render, screen, waitForElementToBeRemoved } from 'testing-library';
|
||||||
@@ -8,6 +9,7 @@ describe('Cameras Route', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.spyOn(CameraImage, 'default').mockImplementation(() => <div data-testid="camera-image" />);
|
vi.spyOn(CameraImage, 'default').mockImplementation(() => <div data-testid="camera-image" />);
|
||||||
vi.spyOn(WS, 'useWs').mockImplementation(() => ({ value: { payload: 'OFF' }, send: vi.fn() }));
|
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 () => {
|
test('shows an ActivityIndicator if not yet loaded', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import * as CameraImage from '../../components/CameraImage';
|
import * as CameraImage from '../../components/CameraImage';
|
||||||
import * as WS from '../../api/ws';
|
import * as WS from '../../api/ws';
|
||||||
|
import * as Hooks from '../../hooks';
|
||||||
import Cameras from '../Cameras';
|
import Cameras from '../Cameras';
|
||||||
import { render, screen, waitForElementToBeRemoved } from 'testing-library';
|
import { render, screen, waitForElementToBeRemoved } from 'testing-library';
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ describe('Recording Route', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.spyOn(CameraImage, 'default').mockImplementation(() => <div data-testid="camera-image" />);
|
vi.spyOn(CameraImage, 'default').mockImplementation(() => <div data-testid="camera-image" />);
|
||||||
vi.spyOn(WS, 'useWs').mockImplementation(() => ({ value: { payload: 'OFF' }, send: jest.fn() }));
|
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 () => {
|
test('shows an ActivityIndicator if not yet loaded', async () => {
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ export default defineConfig({
|
|||||||
define: {
|
define: {
|
||||||
'import.meta.vitest': 'undefined',
|
'import.meta.vitest': 'undefined',
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
preact(),
|
preact(),
|
||||||
monacoEditorPlugin.default({
|
monacoEditorPlugin.default({
|
||||||
|
|||||||
Reference in New Issue
Block a user