mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-09-29 21:02:55 +08:00
Compare commits
54 Commits
update_cal
...
release_wo
Author | SHA1 | Date | |
---|---|---|---|
![]() |
1d58e419f4 | ||
![]() |
16dc9f4bf7 | ||
![]() |
52b47a3414 | ||
![]() |
139664e598 | ||
![]() |
441c605312 | ||
![]() |
def889e3a8 | ||
![]() |
613f1f6bd6 | ||
![]() |
e173377859 | ||
![]() |
86c59c1722 | ||
![]() |
a399cb09fa | ||
![]() |
5a46c36380 | ||
![]() |
36c1e00a6b | ||
![]() |
859ab0e7fa | ||
![]() |
cf2b56613f | ||
![]() |
1a9e00ee49 | ||
![]() |
b9649de327 | ||
![]() |
823550eed3 | ||
![]() |
c141362614 | ||
![]() |
e0e8a6fcc9 | ||
![]() |
0b858419d1 | ||
![]() |
2fb7200fb7 | ||
![]() |
e9376ca285 | ||
![]() |
cff4b9651f | ||
![]() |
9df5927ac5 | ||
![]() |
29f82add72 | ||
![]() |
d102ebf855 | ||
![]() |
cb3990a0ac | ||
![]() |
9fc93c72a0 | ||
![]() |
e13a176820 | ||
![]() |
1e71e36056 | ||
![]() |
18545718c1 | ||
![]() |
c8b38bdd47 | ||
![]() |
e80b6d9e5b | ||
![]() |
ee1e1b748c | ||
![]() |
0c2f3a9702 | ||
![]() |
a3c0e30502 | ||
![]() |
b4d5a3ef14 | ||
![]() |
facd557f8c | ||
![]() |
12487b3b60 | ||
![]() |
8f349a6365 | ||
![]() |
91f7d67c5e | ||
![]() |
98200b7dda | ||
![]() |
282cbf8f40 | ||
![]() |
cd35481e92 | ||
![]() |
126aed2798 | ||
![]() |
efbc094bbc | ||
![]() |
c7b2c6b95d | ||
![]() |
1bdfc380c3 | ||
![]() |
cac37e484d | ||
![]() |
4469507e5b | ||
![]() |
8626160df2 | ||
![]() |
d4d2bb2521 | ||
![]() |
e545dfc47b | ||
![]() |
9ea10f8541 |
2
.github/workflows/pull_request.yml
vendored
2
.github/workflows/pull_request.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.7.0
|
||||
uses: actions/setup-python@v4.7.1
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Install requirements
|
||||
|
62
.github/workflows/release.yml
vendored
Normal file
62
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: On release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: lowercaseRepo
|
||||
uses: ASzc/change-string-case-action@v5
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Create tag variables
|
||||
run: |
|
||||
echo "BASE=ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}" >> $GITHUB_ENV
|
||||
echo "BUILD_TAG=${{ github.ref_name }}-${GITHUB_SHA::7}" >> $GITHUB_ENV
|
||||
echo "CLEAN_VERSION=$(echo ${GITHUB_REF##*/} | tr '[:upper:]' '[:lower:]' | sed 's/^[v]//')" >> $GITHUB_ENV
|
||||
- name: Tag and push the main image
|
||||
run: |
|
||||
VERSION_TAG=${BASE}:${CLEAN_VERSION}
|
||||
PULL_TAG=${BASE}:${BUILD_TAG}
|
||||
docker pull ${PULL_TAG}
|
||||
docker tag ${PULL_TAG} ${VERSION_TAG}
|
||||
docker push ${VERSION_TAG}
|
||||
- name: Tag and push standard arm64
|
||||
run: |
|
||||
VERSION_TAG=${BASE}:${CLEAN_VERSION}-standard-arm64
|
||||
PULL_TAG=${BASE}:${BUILD_TAG}-standard-arm64
|
||||
docker pull ${PULL_TAG}
|
||||
docker tag ${PULL_TAG} ${VERSION_TAG}
|
||||
docker push ${VERSION_TAG}
|
||||
- name: Tag and push tensorrt
|
||||
run: |
|
||||
VERSION_TAG=${BASE}:${CLEAN_VERSION}-tensorrt
|
||||
PULL_TAG=${BASE}:${BUILD_TAG}-tensorrt
|
||||
docker pull ${PULL_TAG}
|
||||
docker tag ${PULL_TAG} ${VERSION_TAG}
|
||||
docker push ${VERSION_TAG}
|
||||
- name: Tag and push tensorrt-jp4
|
||||
run: |
|
||||
VERSION_TAG=${BASE}:${CLEAN_VERSION}-tensorrt-jp4
|
||||
PULL_TAG=${BASE}:${BUILD_TAG}-tensorrt-jp4
|
||||
docker pull ${PULL_TAG}
|
||||
docker tag ${PULL_TAG} ${VERSION_TAG}
|
||||
docker push ${VERSION_TAG}
|
||||
- name: Tag and push tensorrt-jp5
|
||||
run: |
|
||||
VERSION_TAG=${BASE}:${CLEAN_VERSION}-tensorrt-jp5
|
||||
PULL_TAG=${BASE}:${BUILD_TAG}-tensorrt-jp5
|
||||
docker pull ${PULL_TAG}
|
||||
docker tag ${PULL_TAG} ${VERSION_TAG}
|
||||
docker push ${VERSION_TAG}
|
@@ -33,7 +33,7 @@ RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/cache/apt \
|
||||
FROM scratch AS go2rtc
|
||||
ARG TARGETARCH
|
||||
WORKDIR /rootfs/usr/local/go2rtc/bin
|
||||
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.7.1/go2rtc_linux_${TARGETARCH}" go2rtc
|
||||
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.8.1/go2rtc_linux_${TARGETARCH}" go2rtc
|
||||
|
||||
|
||||
####
|
||||
|
@@ -55,24 +55,16 @@ fi
|
||||
|
||||
# arch specific packages
|
||||
if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||
# 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
|
||||
# use debian bookworm for hwaccel packages
|
||||
echo 'deb https://deb.debian.org/debian bookworm main contrib non-free' >/etc/apt/sources.list.d/debian-bookworm.list
|
||||
apt-get -qq update
|
||||
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
||||
mesa-va-drivers radeontop
|
||||
rm -f /etc/apt/sources.list.d/debian-bookworm.list
|
||||
|
||||
# Use debian testing repo only for intel hwaccel packages
|
||||
echo 'deb http://deb.debian.org/debian testing main non-free' >/etc/apt/sources.list.d/debian-testing.list
|
||||
apt-get -qq update
|
||||
# intel-opencl-icd specifically for GPU support in OpenVino
|
||||
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
||||
intel-opencl-icd \
|
||||
libva-drm2 intel-media-va-driver-non-free i965-va-driver libmfx1 intel-gpu-tools
|
||||
mesa-va-drivers radeontop libva-drm2 intel-media-va-driver-non-free i965-va-driver libmfx1 intel-gpu-tools
|
||||
# something about this dependency requires it to be installed in a separate call rather than in the line above
|
||||
apt-get -qq install --no-install-recommends --no-install-suggests -y \
|
||||
i965-va-driver-shaders
|
||||
rm -f /etc/apt/sources.list.d/debian-testing.list
|
||||
rm -f /etc/apt/sources.list.d/debian-bookworm.list
|
||||
fi
|
||||
|
||||
if [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||
|
@@ -1,3 +1,3 @@
|
||||
black == 23.3.*
|
||||
black == 23.10.*
|
||||
isort
|
||||
ruff
|
||||
|
@@ -2,12 +2,12 @@ click == 8.1.*
|
||||
Flask == 2.3.*
|
||||
imutils == 0.5.*
|
||||
matplotlib == 3.7.*
|
||||
mypy == 1.4.1
|
||||
mypy == 1.6.1
|
||||
numpy == 1.23.*
|
||||
onvif_zeep == 0.2.12
|
||||
opencv-python-headless == 4.7.0.*
|
||||
paho-mqtt == 1.6.*
|
||||
peewee == 3.16.*
|
||||
peewee == 3.17.*
|
||||
peewee_migrate == 1.12.*
|
||||
psutil == 5.9.*
|
||||
pydantic == 1.10.*
|
||||
@@ -15,7 +15,7 @@ git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
PyYAML == 6.0.*
|
||||
pytz == 2023.3
|
||||
ruamel.yaml == 0.17.*
|
||||
tzlocal == 5.0.*
|
||||
tzlocal == 5.1
|
||||
types-PyYAML == 6.0.*
|
||||
requests == 2.31.*
|
||||
types-requests == 2.31.*
|
||||
|
@@ -149,62 +149,55 @@ http {
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://mqtt_ws/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /live/jsmpeg/ {
|
||||
proxy_pass http://jsmpeg/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /live/mse/ {
|
||||
proxy_pass http://go2rtc/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /live/webrtc/ {
|
||||
proxy_pass http://go2rtc/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location ~* /api/go2rtc([/]?.*)$ {
|
||||
proxy_pass http://go2rtc;
|
||||
rewrite ^/api/go2rtc(.*)$ /api$1 break;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location ~* /api/.*\.(jpg|jpeg|png)$ {
|
||||
rewrite ^/api/(.*)$ $1 break;
|
||||
proxy_pass http://frigate_api;
|
||||
proxy_pass_request_headers on;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
add_header Cache-Control "no-store";
|
||||
expires off;
|
||||
proxy_pass http://frigate_api/;
|
||||
proxy_pass_request_headers on;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
include proxy.conf;
|
||||
|
||||
location /api/stats {
|
||||
access_log off;
|
||||
rewrite ^/api/(.*)$ $1 break;
|
||||
proxy_pass http://frigate_api;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /api/version {
|
||||
access_log off;
|
||||
rewrite ^/api/(.*)$ $1 break;
|
||||
proxy_pass http://frigate_api;
|
||||
include proxy.conf;
|
||||
}
|
||||
}
|
||||
|
||||
location / {
|
||||
|
4
docker/main/rootfs/usr/local/nginx/conf/proxy.conf
Normal file
4
docker/main/rootfs/usr/local/nginx/conf/proxy.conf
Normal file
@@ -0,0 +1,4 @@
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
@@ -120,7 +120,7 @@ NOTE: The folder that is mapped from the host needs to be the folder that contai
|
||||
|
||||
## Custom go2rtc version
|
||||
|
||||
Frigate currently includes go2rtc v1.7.1, there may be certain cases where you want to run a different version of go2rtc.
|
||||
Frigate currently includes go2rtc v1.8.1, there may be certain cases where you want to run a different version of go2rtc.
|
||||
|
||||
To do this:
|
||||
|
||||
@@ -128,3 +128,34 @@ To do this:
|
||||
2. Rename the build to `go2rtc`.
|
||||
3. Give `go2rtc` execute permission.
|
||||
4. Restart Frigate and the custom version will be used, you can verify by checking go2rtc logs.
|
||||
|
||||
## Validating your config.yaml file updates
|
||||
|
||||
When frigate starts up, it checks whether your config file is valid, and if it is not, the process exits. To minimize interruptions when updating your config, you have three options -- you can edit the config via the WebUI which has built in validation, use the config API, or you can validate on the command line using the frigate docker container.
|
||||
|
||||
### Via API
|
||||
|
||||
Frigate can accept a new configuration file as JSON at the `/config/save` endpoint. When updating the config this way, Frigate will validate the config before saving it, and return a `400` if the config is not valid.
|
||||
|
||||
```bash
|
||||
curl -X POST http://frigate_host:5000/config/save -d @config.json
|
||||
```
|
||||
|
||||
if you'd like you can use your yaml config directly by using [`yq`](https://github.com/mikefarah/yq) to convert it to json:
|
||||
|
||||
```bash
|
||||
yq r -j config.yml | curl -X POST http://frigate_host:5000/config/save -d @-
|
||||
```
|
||||
|
||||
### Via Command Line
|
||||
|
||||
You can also validate your config at the command line by using the docker container itself. In CI/CD, you leverage the return code to determine if your config is valid, Frigate will return `1` if the config is invalid, or `0` if it's valid.
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-v $(pwd)/config.yml:/config/config.yml \
|
||||
--entrypoint python3 \
|
||||
ghcr.io/blakeblackshear/frigate:stable \
|
||||
-u -m frigate \
|
||||
--validate_config
|
||||
```
|
||||
|
@@ -23,6 +23,8 @@ Many cheaper or older PTZs may not support this standard. Frigate will report an
|
||||
|
||||
Alternatively, you can download and run [this simple Python script](https://gist.github.com/hawkeye217/152a1d4ba80760dac95d46e143d37112), replacing the details on line 4 with your camera's IP address, ONVIF port, username, and password to check your camera.
|
||||
|
||||
A growing list of cameras and brands that have been reported by users to work with Frigate's autotracking can be found [here](cameras.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
First, set up a PTZ preset in your camera's firmware and give it a name. If you're unsure how to do this, consult the documentation for your camera manufacturer's firmware. Some tutorials for common brands: [Amcrest](https://www.youtube.com/watch?v=lJlE9-krmrM), [Reolink](https://www.youtube.com/watch?v=VAnxHUY5i5w), [Dahua](https://www.youtube.com/watch?v=7sNbc5U-k54).
|
||||
@@ -89,13 +91,23 @@ PTZ motors operate at different speeds. Performing a calibration will direct Fri
|
||||
|
||||
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.
|
||||
To begin calibration, set the `calibrate_on_startup` for your camera to `True` and restart Frigate. Frigate will then make a series of 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`.
|
||||
After calibration has ended, your PTZ will be moved to the preset specified by `return_preset`.
|
||||
|
||||
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.
|
||||
:::note
|
||||
|
||||
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.
|
||||
Frigate's web UI and all other cameras will be unresponsive while calibration is in progress. This is expected and normal to avoid excessive network traffic or CPU usage during calibration. Calibration for most PTZs will take about two minutes. The Frigate log will show calibration progress and any errors.
|
||||
|
||||
:::
|
||||
|
||||
At this point, Frigate will be running and will continue to refine and update the `movement_weights` parameter in your config automatically as the PTZ moves during autotracking and more measurements are obtained.
|
||||
|
||||
Before restarting Frigate, you should set `calibrate_on_startup` in your config file to `False`, otherwise your refined `movement_weights` will be overwritten and calibration will occur when starting again.
|
||||
|
||||
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 or if you change your camera's detect `fps` value, a recalibration is also recommended.
|
||||
|
||||
If you initially calibrate with zooming disabled and then enable zooming at a later point, you should also recalibrate.
|
||||
|
||||
## Best practices and considerations
|
||||
|
||||
@@ -109,18 +121,46 @@ A fast [detector](object_detectors.md) is recommended. CPU detectors will not pe
|
||||
|
||||
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.
|
||||
|
||||
Some users have found it helpful to adjust the zone `inertia` value. See the [configuration reference](index.md).
|
||||
|
||||
## 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.
|
||||
Zooming is a very experimental feature and may use significantly more CPU when tracking objects than panning/tilting only.
|
||||
|
||||
Absolute zooming makes zoom movements separate from pan/tilt movements. Most PTZ cameras will support absolute zooming.
|
||||
Absolute zooming makes zoom movements separate from pan/tilt movements. Most PTZ cameras will support absolute zooming. Absolute zooming was developed to be very conservative to work best with a variety of cameras and scenes. Absolute zooming usually will not occur until an object has stopped moving or is moving very slowly.
|
||||
|
||||
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.
|
||||
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, try 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.
|
||||
The range of this option is from 0.1 to 0.75. The default value of 0.3 is conservative and should be sufficient for most users. Because every PTZ and scene is different, you should experiment to determine what works best for you.
|
||||
|
||||
## Usage applications
|
||||
|
||||
In security and surveillance, it's common to use "spotter" cameras in combination with your PTZ. When your fixed spotter camera detects an object, you could use an automation platform like Home Assistant to move the PTZ to a specific preset so that Frigate can begin automatically tracking the object. For example: a residence may have fixed cameras on the east and west side of the property, capturing views up and down a street. When the spotter camera on the west side detects a person, a Home Assistant automation could move the PTZ to a camera preset aimed toward the west. When the object enters the specified zone, Frigate's autotracker could then continue to track the person as it moves out of view of any of the fixed cameras.
|
||||
|
||||
## Troubleshooting and FAQ
|
||||
|
||||
### The autotracker loses track of my object. Why?
|
||||
|
||||
There are many reasons this could be the case. If you are using experimental zooming, your `zoom_factor` value might be too high, the object might be traveling too quickly, the scene might be too dark, there are not enough details in the scene (for example, a PTZ looking down on a driveway or other monotone background without a sufficient number of hard edges or corners), or the scene is otherwise less than optimal for Frigate to maintain tracking.
|
||||
|
||||
Your camera's shutter speed may also be set too low so that blurring occurs with motion. Check your camera's firmware to see if you can increase the shutter speed.
|
||||
|
||||
Watching Frigate's debug view can help to determine a possible cause. The autotracked object will have a thicker colored box around it.
|
||||
|
||||
### I'm seeing an error in the logs that my camera "is still in ONVIF 'MOVING' status." What does this mean?
|
||||
|
||||
There are two possible known reasons for this (and perhaps others yet unknown): a slow PTZ motor or buggy camera firmware. Frigate uses an ONVIF parameter provided by the camera, `MoveStatus`, to determine when the PTZ's motor is moving or idle. According to some users, Hikvision PTZs (even with the latest firmware), are not updating this value after PTZ movement. Unfortunately there is no workaround to this bug in Hikvision firmware, so autotracking will not function correctly and should be disabled in your config. This may also be the case with other non-Hikvision cameras utilizing Hikvision firmware.
|
||||
|
||||
### I tried calibrating my camera, but the logs show that it is stuck at 0% and Frigate is not starting up.
|
||||
|
||||
This is often caused by the same reason as above - the `MoveStatus` ONVIF parameter is not changing due to a bug in your camera's firmware. Also, see the note above: Frigate's web UI and all other cameras will be unresponsive while calibration is in progress. This is expected and normal. But if you don't see log entries every few seconds for calibration progress, your camera is not compatible with autotracking.
|
||||
|
||||
### I'm seeing this error in the logs: "Autotracker: motion estimator couldn't get transformations". What does this mean?
|
||||
|
||||
To maintain object tracking during PTZ moves, Frigate tracks the motion of your camera based on the details of the frame. If you are seeing this message, it could mean that your `zoom_factor` may be set too high, the scene around your detected object does not have enough details (like hard edges or color variatons), or your camera's shutter speed is too slow and motion blur is occurring. Try reducing `zoom_factor`, finding a way to alter the scene around your object, or changing your camera's shutter speed.
|
||||
|
||||
### Calibration seems to have completed, but the camera is not actually moving to track my object. Why?
|
||||
|
||||
Some cameras have firmware that reports that FOV RelativeMove, the ONVIF command that Frigate uses for autotracking, is supported. However, if the camera does not pan or tilt when an object comes into the required zone, your camera's firmware does not actually support FOV RelativeMove. One such camera is the Uniview IPC672LR-AX4DUPK. It actually moves its zoom motor instead of panning and tilting and does not follow the ONVIF standard whatsoever.
|
||||
|
@@ -140,7 +140,7 @@ go2rtc:
|
||||
- rtspx://192.168.1.1:7441/abcdefghijk
|
||||
```
|
||||
|
||||
[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#source-rtsp)
|
||||
[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.8.1#source-rtsp)
|
||||
|
||||
In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record and rtmp if used directly with unifi protect.
|
||||
|
||||
|
@@ -91,5 +91,7 @@ This list of working and non-working PTZ cameras is based on user feedback.
|
||||
| Reolink E1 Pro | ✅ | ❌ | |
|
||||
| Reolink E1 Zoom | ✅ | ❌ | |
|
||||
| Sunba 405-D20X | ✅ | ❌ | |
|
||||
| Tapo C200 | ✅ | ❌ | Incomplete ONVIF support |
|
||||
| Tapo C210 | ❌ | ❌ | Incomplete ONVIF support |
|
||||
| Uniview IPC672LR-AX4DUPK | ✅ | ❌ | Firmware says FOV relative movement is supported, but camera doesn't actually move when sending ONVIF commands |
|
||||
| Vikylin PTZ-2804X-I2 | ❌ | ❌ | Incomplete ONVIF support |
|
||||
|
@@ -436,7 +436,7 @@ rtmp:
|
||||
enabled: False
|
||||
|
||||
# Optional: Restream configuration
|
||||
# Uses https://github.com/AlexxIT/go2rtc (v1.7.1)
|
||||
# Uses https://github.com/AlexxIT/go2rtc (v1.8.1)
|
||||
go2rtc:
|
||||
|
||||
# Optional: jsmpeg stream configuration for WebUI
|
||||
|
@@ -115,4 +115,4 @@ services:
|
||||
|
||||
:::
|
||||
|
||||
See [go2rtc WebRTC docs](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#module-webrtc) for more information about this.
|
||||
See [go2rtc WebRTC docs](https://github.com/AlexxIT/go2rtc/tree/v1.8.1#module-webrtc) for more information about this.
|
||||
|
@@ -7,7 +7,7 @@ title: Restream
|
||||
|
||||
Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://<frigate_host>:8554/<camera_name>`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate.
|
||||
|
||||
Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.7.1) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#configuration) for more advanced configurations and features.
|
||||
Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.8.1) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.8.1#configuration) for more advanced configurations and features.
|
||||
|
||||
:::note
|
||||
|
||||
@@ -138,7 +138,7 @@ cameras:
|
||||
|
||||
## Advanced Restream Configurations
|
||||
|
||||
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
|
||||
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.8.1#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
|
||||
|
||||
NOTE: The output will need to be passed with two curly braces `{{output}}`
|
||||
|
||||
|
@@ -11,7 +11,7 @@ Use of the bundled go2rtc is optional. You can still configure FFmpeg to connect
|
||||
|
||||
# Setup a go2rtc stream
|
||||
|
||||
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. If you set the stream name under go2rtc to match the name of your camera, it will automatically be mapped and you will get additional live view options for the camera. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#module-streams), not just rtsp.
|
||||
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. If you set the stream name under go2rtc to match the name of your camera, it will automatically be mapped and you will get additional live view options for the camera. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.8.1#module-streams), not just rtsp.
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
@@ -24,7 +24,7 @@ The easiest live view to get working is MSE. After adding this to the config, re
|
||||
|
||||
### What if my video doesn't play?
|
||||
|
||||
If you are unable to see your video feed, first check the go2rtc logs in the Frigate UI under Logs in the sidebar. If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log. If you do not see any errors, then the video codec of the stream may not be supported in your browser. If your camera stream is set to H265, try switching to H264. You can see more information about [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#codecs-madness) in the go2rtc documentation. If you are not able to switch your camera settings from H265 to H264 or your stream is a different format such as MJPEG, you can use go2rtc to re-encode the video using the [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.7.1#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. Here is an example of a config that will re-encode the stream to H264 without hardware acceleration:
|
||||
If you are unable to see your video feed, first check the go2rtc logs in the Frigate UI under Logs in the sidebar. If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log. If you do not see any errors, then the video codec of the stream may not be supported in your browser. If your camera stream is set to H265, try switching to H264. You can see more information about [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.8.1#codecs-madness) in the go2rtc documentation. If you are not able to switch your camera settings from H265 to H264 or your stream is a different format such as MJPEG, you can use go2rtc to re-encode the video using the [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.8.1#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. Here is an example of a config that will re-encode the stream to H264 without hardware acceleration:
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
|
@@ -3,11 +3,7 @@ id: false_positives
|
||||
title: Reducing false positives
|
||||
---
|
||||
|
||||
Tune your object filters to adjust false positives: `min_area`, `max_area`, `min_ratio`, `max_ratio`, `min_score`, `threshold`.
|
||||
|
||||
The `min_area` and `max_area` values are compared against the area (number of pixels) from a given detected object. If the area is outside this range, the object will be ignored as a false positive. This allows objects that must be too small or too large to be ignored.
|
||||
|
||||
Similarly, the `min_ratio` and `max_ratio` values are compared against a given detected object's width/height ratio (in pixels). If the ratio is outside this range, the object will be ignored as a false positive. This allows objects that are proportionally too short-and-wide (higher ratio) or too tall-and-narrow (smaller ratio) to be ignored.
|
||||
## Object Scores
|
||||
|
||||
For object filters in your configuration, any single detection below `min_score` will be ignored as a false positive. `threshold` is based on the median of the history of scores (padded to 3 values) for a tracked object. Consider the following frames when `min_score` is set to 0.6 and threshold is set to 0.85:
|
||||
|
||||
@@ -22,4 +18,32 @@ For object filters in your configuration, any single detection below `min_score`
|
||||
|
||||
In frame 2, the score is below the `min_score` value, so Frigate ignores it and it becomes a 0.0. The computed score is the median of the score history (padding to at least 3 values), and only when that computed score crosses the `threshold` is the object marked as a true positive. That happens in frame 4 in the example.
|
||||
|
||||
If you're seeing false positives from stationary objects, please see Object Masks here: https://docs.frigate.video/configuration/masks/
|
||||
### Minimum Score
|
||||
|
||||
Any detection below `min_score` will be immediately thrown out and never tracked because it is considered a false positive. If `min_score` is too low then false positives may be detected and tracked which can confuse the object tracker and may lead to wasted resources. If `min_score` is too high then lower scoring true positives like objects that are further away or partially occluded may be thrown out which can also confuse the tracker and cause valid events to be lost or disjointed.
|
||||
|
||||
### Threshold
|
||||
|
||||
`threshold` is used to determine that the object is a true positive. Once an object is detected with a score >= `threshold` object is considered a true positive. If `threshold` is too low then some higher scoring false positives may create an event. If `threshold` is too high then true positive events may be missed due to the object never scoring high enough.
|
||||
|
||||
## Object Shape
|
||||
|
||||
False positives can also be reduced by filtering a detection based on its shape.
|
||||
|
||||
### Object Area
|
||||
|
||||
`min_area` and `max_area` filter on the area of an objects bounding box in pixels and can be used to reduce false positives that are outside the range of expected sizes. For example when a leaf is detected as a dog or when a large tree is detected as a person, these can be reduced by adding a `min_area` / `max_area` filter. The recordings timeline can be used to determine the area of the bounding box in that frame by selecting a timeline item then mousing over or tapping the red box.
|
||||
|
||||
### Object Proportions
|
||||
|
||||
`min_ratio` and `max_ratio` filter on the ratio of width / height of an objects bounding box and can be used to reduce false positives. For example if a false positive is detected as very tall for a dog which is often wider, a `min_ratio` filter can be used to filter out these false positives.
|
||||
|
||||
## Other Tools
|
||||
|
||||
### Zones
|
||||
|
||||
[Required zones](/configuration/zones.md) can be a great tool to reduce false positives that may be detected in the sky or other areas that are not of interest. The required zones will only create events for objects that enter the zone.
|
||||
|
||||
### Object Masks
|
||||
|
||||
[Object Filter Masks](/configuration/masks) are a last resort but can be useful when false positives are in the relatively same place but can not be filtered due to their size or shape.
|
||||
|
@@ -220,3 +220,29 @@ Topic to turn the PTZ autotracker for a camera on and off. Expected values are `
|
||||
### `frigate/<camera_name>/ptz_autotracker/state`
|
||||
|
||||
Topic with current state of the PTZ autotracker for a camera. Published values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/birdseye/set`
|
||||
|
||||
Topic to turn Birdseye for a camera on and off. Expected values are `ON` and `OFF`. Birdseye mode
|
||||
must be enabled in the configuration.
|
||||
|
||||
### `frigate/<camera_name>/birdseye/state`
|
||||
|
||||
Topic with current state of Birdseye for a camera. Published values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/birdseye_mode/set`
|
||||
|
||||
Topic to set Birdseye mode for a camera. Birdseye offers different modes to customize under which circumstances the camera is shown.
|
||||
|
||||
_Note: Changing the value from `CONTINUOUS` -> `MOTION | OBJECTS` will take up to 30 seconds for
|
||||
the camera to be removed from the view._
|
||||
|
||||
| Command | Description |
|
||||
| ------------ | ----------------------------------------------------------------- |
|
||||
| `CONTINUOUS` | Always included |
|
||||
| `MOTION` | Show when detected motion within the last 30 seconds are included |
|
||||
| `OBJECTS` | Shown if an active object tracked within the last 30 seconds |
|
||||
|
||||
### `frigate/<camera_name>/birdseye_mode/state`
|
||||
|
||||
Topic with current state of the Birdseye mode for a camera. Published values are `CONTINUOUS`, `MOTION`, `OBJECTS`.
|
||||
|
@@ -23,6 +23,17 @@ Ensure your cameras send h264 encoded video, or [transcode them](/configuration/
|
||||
|
||||
You can open `chrome://media-internals/` in another tab and then try to playback, the media internals page will give information about why playback is failing.
|
||||
|
||||
### What do I do if my cameras sub stream is not good enough?
|
||||
|
||||
Frigate generally [recommends cameras with configurable sub streams](/frigate/hardware.md). However, if your camera does not have a sub stream that a suitable resolution, the main stream can be resized.
|
||||
|
||||
To do this efficiently the following setup is required:
|
||||
1. A GPU or iGPU must be available to do the scaling.
|
||||
2. [ffmpeg presets for hwaccel](/configuration/hardware_acceleration.md) must be used
|
||||
3. Set the desired detection resolution for `detect -> width` and `detect -> height`.
|
||||
|
||||
When this is done correctly, the GPU will do the decoding and scaling which will result in a small increase in CPU usage but with better results.
|
||||
|
||||
### My mjpeg stream or snapshots look green and crazy
|
||||
|
||||
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
|
||||
|
@@ -21,7 +21,7 @@ module.exports = {
|
||||
{
|
||||
type: "link",
|
||||
label: "Go2RTC Configuration Reference",
|
||||
href: "https://github.com/AlexxIT/go2rtc/tree/v1.7.1#configuration",
|
||||
href: "https://github.com/AlexxIT/go2rtc/tree/v1.8.1#configuration",
|
||||
},
|
||||
],
|
||||
Detectors: [
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import argparse
|
||||
import datetime
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
@@ -20,7 +21,7 @@ from frigate.comms.dispatcher import Communicator, Dispatcher
|
||||
from frigate.comms.inter_process import InterProcessCommunicator
|
||||
from frigate.comms.mqtt import MqttClient
|
||||
from frigate.comms.ws import WebSocketClient
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
||||
from frigate.const import (
|
||||
CACHE_DIR,
|
||||
CLIPS_DIR,
|
||||
@@ -36,7 +37,7 @@ from frigate.events.external import ExternalEventProcessor
|
||||
from frigate.events.maintainer import EventProcessor
|
||||
from frigate.http import create_app
|
||||
from frigate.log import log_process, root_configurer
|
||||
from frigate.models import Event, Recordings, RecordingsToDelete, Timeline
|
||||
from frigate.models import Event, Recordings, RecordingsToDelete, Regions, Timeline
|
||||
from frigate.object_detection import ObjectDetectProcess
|
||||
from frigate.object_processing import TrackedObjectProcessor
|
||||
from frigate.output import output_frames
|
||||
@@ -49,6 +50,7 @@ from frigate.stats import StatsEmitter, stats_init
|
||||
from frigate.storage import StorageMaintainer
|
||||
from frigate.timeline import TimelineProcessor
|
||||
from frigate.types import CameraMetricsTypes, FeatureMetricsTypes, PTZMetricsTypes
|
||||
from frigate.util.object import get_camera_regions_grid
|
||||
from frigate.version import VERSION
|
||||
from frigate.video import capture_camera, track_camera
|
||||
from frigate.watchdog import FrigateWatchdog
|
||||
@@ -69,6 +71,7 @@ class FrigateApp:
|
||||
self.feature_metrics: dict[str, FeatureMetricsTypes] = {}
|
||||
self.ptz_metrics: dict[str, PTZMetricsTypes] = {}
|
||||
self.processes: dict[str, int] = {}
|
||||
self.region_grids: dict[str, list[list[dict[str, int]]]] = {}
|
||||
|
||||
def set_environment_vars(self) -> None:
|
||||
for key, value in self.config.environment_vars.items():
|
||||
@@ -161,10 +164,25 @@ class FrigateApp:
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
"frame_queue": mp.Queue(maxsize=2),
|
||||
"region_grid_queue": mp.Queue(maxsize=1),
|
||||
"capture_process": None,
|
||||
"process": None,
|
||||
"audio_rms": mp.Value("d", 0.0), # type: ignore[typeddict-item]
|
||||
"audio_dBFS": mp.Value("d", 0.0), # type: ignore[typeddict-item]
|
||||
"birdseye_enabled": mp.Value( # type: ignore[typeddict-item]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
"i",
|
||||
self.config.cameras[camera_name].birdseye.enabled,
|
||||
),
|
||||
"birdseye_mode": mp.Value( # type: ignore[typeddict-item]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
"i",
|
||||
BirdseyeModeEnum.get_index(
|
||||
self.config.cameras[camera_name].birdseye.mode.value
|
||||
),
|
||||
),
|
||||
}
|
||||
self.ptz_metrics[camera_name] = {
|
||||
"ptz_autotracker_enabled": mp.Value( # type: ignore[typeddict-item]
|
||||
@@ -187,6 +205,12 @@ class FrigateApp:
|
||||
"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
|
||||
"ptz_max_zoom": mp.Value("d", 0.0), # type: ignore[typeddict-item]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
"ptz_min_zoom": mp.Value("d", 0.0), # type: ignore[typeddict-item]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
}
|
||||
self.ptz_metrics[camera_name]["ptz_stopped"].set()
|
||||
self.feature_metrics[camera_name] = {
|
||||
@@ -327,7 +351,7 @@ class FrigateApp:
|
||||
60, 10 * len([c for c in self.config.cameras.values() if c.enabled])
|
||||
),
|
||||
)
|
||||
models = [Event, Recordings, RecordingsToDelete, Timeline]
|
||||
models = [Event, Recordings, RecordingsToDelete, Regions, Timeline]
|
||||
self.db.bind(models)
|
||||
|
||||
def init_stats(self) -> None:
|
||||
@@ -445,6 +469,7 @@ class FrigateApp:
|
||||
args=(
|
||||
self.config,
|
||||
self.video_output_queue,
|
||||
self.camera_metrics,
|
||||
),
|
||||
)
|
||||
output_processor.daemon = True
|
||||
@@ -452,6 +477,17 @@ class FrigateApp:
|
||||
output_processor.start()
|
||||
logger.info(f"Output process started: {output_processor.pid}")
|
||||
|
||||
def init_historical_regions(self) -> None:
|
||||
# delete region grids for removed or renamed cameras
|
||||
cameras = list(self.config.cameras.keys())
|
||||
Regions.delete().where(~(Regions.camera << cameras)).execute()
|
||||
|
||||
# create or update region grids for each camera
|
||||
for camera in self.config.cameras.values():
|
||||
self.region_grids[camera.name] = get_camera_regions_grid(
|
||||
camera.name, camera.detect
|
||||
)
|
||||
|
||||
def start_camera_processors(self) -> None:
|
||||
for name, config in self.config.cameras.items():
|
||||
if not self.config.cameras[name].enabled:
|
||||
@@ -469,8 +505,10 @@ class FrigateApp:
|
||||
self.detection_queue,
|
||||
self.detection_out_events[name],
|
||||
self.detected_frames_queue,
|
||||
self.inter_process_queue,
|
||||
self.camera_metrics[name],
|
||||
self.ptz_metrics[name],
|
||||
self.region_grids[name],
|
||||
),
|
||||
)
|
||||
camera_process.daemon = True
|
||||
@@ -571,6 +609,13 @@ class FrigateApp:
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="Frigate",
|
||||
description="An NVR with realtime local object detection for IP cameras.",
|
||||
)
|
||||
parser.add_argument("--validate-config", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
self.init_logger()
|
||||
logger.info(f"Starting Frigate ({VERSION})")
|
||||
try:
|
||||
@@ -594,6 +639,12 @@ class FrigateApp:
|
||||
print("*************************************************************")
|
||||
self.log_process.terminate()
|
||||
sys.exit(1)
|
||||
if args.validate_config:
|
||||
print("*************************************************************")
|
||||
print("*** Your config file is valid. ***")
|
||||
print("*************************************************************")
|
||||
self.log_process.terminate()
|
||||
sys.exit(0)
|
||||
self.set_environment_vars()
|
||||
self.set_log_levels()
|
||||
self.init_queues()
|
||||
@@ -611,6 +662,7 @@ class FrigateApp:
|
||||
self.start_detectors()
|
||||
self.start_video_output_processor()
|
||||
self.start_ptz_autotracker()
|
||||
self.init_historical_regions()
|
||||
self.start_detected_frames_processor()
|
||||
self.start_camera_processors()
|
||||
self.start_camera_capture_processes()
|
||||
|
@@ -4,11 +4,12 @@ import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Callable
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import INSERT_MANY_RECORDINGS
|
||||
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
||||
from frigate.const import INSERT_MANY_RECORDINGS, REQUEST_REGION_GRID
|
||||
from frigate.models import Recordings
|
||||
from frigate.ptz.onvif import OnvifCommandEnum, OnvifController
|
||||
from frigate.types import CameraMetricsTypes, FeatureMetricsTypes, PTZMetricsTypes
|
||||
from frigate.util.object import get_camera_regions_grid
|
||||
from frigate.util.services import restart_frigate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -62,6 +63,8 @@ class Dispatcher:
|
||||
"motion_threshold": self._on_motion_threshold_command,
|
||||
"recordings": self._on_recordings_command,
|
||||
"snapshots": self._on_snapshots_command,
|
||||
"birdseye": self._on_birdseye_command,
|
||||
"birdseye_mode": self._on_birdseye_mode_command,
|
||||
}
|
||||
|
||||
for comm in self.comms:
|
||||
@@ -90,6 +93,11 @@ class Dispatcher:
|
||||
restart_frigate()
|
||||
elif topic == INSERT_MANY_RECORDINGS:
|
||||
Recordings.insert_many(payload).execute()
|
||||
elif topic == REQUEST_REGION_GRID:
|
||||
camera = payload
|
||||
self.camera_metrics[camera]["region_grid_queue"].put(
|
||||
get_camera_regions_grid(camera, self.config.cameras[camera].detect)
|
||||
)
|
||||
else:
|
||||
self.publish(topic, payload, retain=False)
|
||||
|
||||
@@ -176,11 +184,13 @@ class Dispatcher:
|
||||
if not self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value:
|
||||
logger.info(f"Turning on ptz autotracker for {camera_name}")
|
||||
self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value = True
|
||||
self.ptz_metrics[camera_name]["ptz_start_time"].value = 0
|
||||
ptz_autotracker_settings.enabled = True
|
||||
elif payload == "OFF":
|
||||
if self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value:
|
||||
logger.info(f"Turning off ptz autotracker for {camera_name}")
|
||||
self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value = False
|
||||
self.ptz_metrics[camera_name]["ptz_start_time"].value = 0
|
||||
ptz_autotracker_settings.enabled = False
|
||||
|
||||
self.publish(f"{camera_name}/ptz_autotracker/state", payload, retain=True)
|
||||
@@ -288,3 +298,43 @@ class Dispatcher:
|
||||
logger.info(f"Setting ptz command to {command} for {camera_name}")
|
||||
except KeyError as k:
|
||||
logger.error(f"Invalid PTZ command {payload}: {k}")
|
||||
|
||||
def _on_birdseye_command(self, camera_name: str, payload: str) -> None:
|
||||
"""Callback for birdseye topic."""
|
||||
birdseye_settings = self.config.cameras[camera_name].birdseye
|
||||
|
||||
if payload == "ON":
|
||||
if not self.camera_metrics[camera_name]["birdseye_enabled"].value:
|
||||
logger.info(f"Turning on birdseye for {camera_name}")
|
||||
self.camera_metrics[camera_name]["birdseye_enabled"].value = True
|
||||
birdseye_settings.enabled = True
|
||||
|
||||
elif payload == "OFF":
|
||||
if self.camera_metrics[camera_name]["birdseye_enabled"].value:
|
||||
logger.info(f"Turning off birdseye for {camera_name}")
|
||||
self.camera_metrics[camera_name]["birdseye_enabled"].value = False
|
||||
birdseye_settings.enabled = False
|
||||
|
||||
self.publish(f"{camera_name}/birdseye/state", payload, retain=True)
|
||||
|
||||
def _on_birdseye_mode_command(self, camera_name: str, payload: str) -> None:
|
||||
"""Callback for birdseye mode topic."""
|
||||
|
||||
if payload not in ["CONTINUOUS", "MOTION", "OBJECTS"]:
|
||||
logger.info(f"Invalid birdseye_mode command: {payload}")
|
||||
return
|
||||
|
||||
birdseye_config = self.config.cameras[camera_name].birdseye
|
||||
if not birdseye_config.enabled:
|
||||
logger.info(f"Birdseye mode not enabled for {camera_name}")
|
||||
return
|
||||
|
||||
new_birdseye_mode = BirdseyeModeEnum(payload.lower())
|
||||
logger.info(f"Setting birdseye mode for {camera_name} to {new_birdseye_mode}")
|
||||
|
||||
# update the metric (need the mode converted to an int)
|
||||
self.camera_metrics[camera_name][
|
||||
"birdseye_mode"
|
||||
].value = BirdseyeModeEnum.get_index(new_birdseye_mode)
|
||||
|
||||
self.publish(f"{camera_name}/birdseye_mode/state", payload, retain=True)
|
||||
|
@@ -89,6 +89,18 @@ class MqttClient(Communicator): # type: ignore[misc]
|
||||
"OFF",
|
||||
retain=False,
|
||||
)
|
||||
self.publish(
|
||||
f"{camera_name}/birdseye/state",
|
||||
"ON" if camera.birdseye.enabled else "OFF",
|
||||
retain=True,
|
||||
)
|
||||
self.publish(
|
||||
f"{camera_name}/birdseye_mode/state",
|
||||
camera.birdseye.mode.value.upper()
|
||||
if camera.birdseye.enabled
|
||||
else "OFF",
|
||||
retain=True,
|
||||
)
|
||||
|
||||
self.publish("available", "online", retain=True)
|
||||
|
||||
@@ -160,6 +172,8 @@ class MqttClient(Communicator): # type: ignore[misc]
|
||||
"ptz_autotracker",
|
||||
"motion_threshold",
|
||||
"motion_contour_area",
|
||||
"birdseye",
|
||||
"birdseye_mode",
|
||||
]
|
||||
|
||||
for name in self.config.cameras.keys():
|
||||
|
@@ -85,7 +85,10 @@ class WebSocketClient(Communicator): # type: ignore[misc]
|
||||
logger.debug(f"payload for {topic} wasn't text. Skipping...")
|
||||
return
|
||||
|
||||
self.websocket_server.manager.broadcast(ws_message)
|
||||
try:
|
||||
self.websocket_server.manager.broadcast(ws_message)
|
||||
except ConnectionResetError:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
self.websocket_server.manager.close_all()
|
||||
|
@@ -188,8 +188,8 @@ class PtzAutotrackConfig(FrigateBaseModel):
|
||||
else:
|
||||
raise ValueError("Invalid type for movement_weights")
|
||||
|
||||
if len(weights) != 3:
|
||||
raise ValueError("movement_weights must have exactly 3 floats")
|
||||
if len(weights) != 5:
|
||||
raise ValueError("movement_weights must have exactly 5 floats")
|
||||
|
||||
return weights
|
||||
|
||||
@@ -501,6 +501,14 @@ class BirdseyeModeEnum(str, Enum):
|
||||
motion = "motion"
|
||||
continuous = "continuous"
|
||||
|
||||
@classmethod
|
||||
def get_index(cls, type):
|
||||
return list(cls).index(type)
|
||||
|
||||
@classmethod
|
||||
def get(cls, index):
|
||||
return list(cls)[index]
|
||||
|
||||
|
||||
class BirdseyeConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=True, title="Enable birdseye view.")
|
||||
|
@@ -12,7 +12,7 @@ FRIGATE_LOCALHOST = "http://127.0.0.1:5000"
|
||||
PLUS_ENV_VAR = "PLUS_API_KEY"
|
||||
PLUS_API_HOST = "https://api.frigate.video"
|
||||
|
||||
# Attributes
|
||||
# Attribute & Object Consts
|
||||
|
||||
ATTRIBUTE_LABEL_MAP = {
|
||||
"person": ["face", "amazon"],
|
||||
@@ -21,6 +21,11 @@ ATTRIBUTE_LABEL_MAP = {
|
||||
ALL_ATTRIBUTE_LABELS = [
|
||||
item for sublist in ATTRIBUTE_LABEL_MAP.values() for item in sublist
|
||||
]
|
||||
LABEL_CONSOLIDATION_MAP = {
|
||||
"car": 0.8,
|
||||
"face": 0.5,
|
||||
}
|
||||
LABEL_CONSOLIDATION_DEFAULT = 0.9
|
||||
|
||||
# Audio Consts
|
||||
|
||||
@@ -51,3 +56,14 @@ MAX_PLAYLIST_SECONDS = 7200 # support 2 hour segments for a single playlist to
|
||||
# Internal Comms Topics
|
||||
|
||||
INSERT_MANY_RECORDINGS = "insert_many_recordings"
|
||||
REQUEST_REGION_GRID = "request_region_grid"
|
||||
|
||||
# Autotracking
|
||||
|
||||
AUTOTRACKING_MAX_AREA_RATIO = 0.5
|
||||
AUTOTRACKING_MOTION_MIN_DISTANCE = 20
|
||||
AUTOTRACKING_MOTION_MAX_POINTS = 500
|
||||
AUTOTRACKING_MAX_MOVE_METRICS = 500
|
||||
AUTOTRACKING_ZOOM_OUT_HYSTERESIS = 1.2
|
||||
AUTOTRACKING_ZOOM_IN_HYSTERESIS = 0.9
|
||||
AUTOTRACKING_ZOOM_EDGE_THRESHOLD = 0.05
|
||||
|
@@ -205,14 +205,10 @@ class AudioEventMaintainer(threading.Thread):
|
||||
|
||||
# only run audio detection when volume is above min_volume
|
||||
if rms >= self.config.audio.min_volume:
|
||||
# add audio info to recordings queue
|
||||
self.recordings_info_queue.put(
|
||||
(self.config.name, datetime.datetime.now().timestamp(), dBFS)
|
||||
)
|
||||
|
||||
# create waveform relative to max range and look for detections
|
||||
waveform = (audio / AUDIO_MAX_BIT_RANGE).astype(np.float32)
|
||||
model_detections = self.detector.detect(waveform)
|
||||
audio_detections = []
|
||||
|
||||
for label, score, _ in model_detections:
|
||||
logger.debug(f"Heard {label} with a score of {score}")
|
||||
@@ -224,6 +220,17 @@ class AudioEventMaintainer(threading.Thread):
|
||||
"threshold", 0.8
|
||||
):
|
||||
self.handle_detection(label, score)
|
||||
audio_detections.append(label)
|
||||
|
||||
# add audio info to recordings queue
|
||||
self.recordings_info_queue.put(
|
||||
(
|
||||
self.config.name,
|
||||
datetime.datetime.now().timestamp(),
|
||||
dBFS,
|
||||
audio_detections,
|
||||
)
|
||||
)
|
||||
|
||||
self.expire_detections()
|
||||
|
||||
|
@@ -83,18 +83,23 @@ class EventCleanup(threading.Thread):
|
||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||
).timestamp()
|
||||
# grab all events after specific time
|
||||
expired_events = Event.select(
|
||||
Event.id,
|
||||
Event.camera,
|
||||
).where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
expired_events = (
|
||||
Event.select(
|
||||
Event.id,
|
||||
Event.camera,
|
||||
)
|
||||
.where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
)
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
# delete the media from disk
|
||||
for event in expired_events:
|
||||
media_name = f"{event.camera}-{event.id}"
|
||||
for expired in expired_events:
|
||||
media_name = f"{expired.camera}-{expired.id}"
|
||||
media_path = Path(
|
||||
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
|
||||
)
|
||||
@@ -136,14 +141,19 @@ class EventCleanup(threading.Thread):
|
||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||
).timestamp()
|
||||
# grab all events after specific time
|
||||
expired_events = Event.select(
|
||||
Event.id,
|
||||
Event.camera,
|
||||
).where(
|
||||
Event.camera == name,
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
expired_events = (
|
||||
Event.select(
|
||||
Event.id,
|
||||
Event.camera,
|
||||
)
|
||||
.where(
|
||||
Event.camera == name,
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
)
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
# delete the grabbed clips from disk
|
||||
|
@@ -261,7 +261,7 @@ def send_to_plus(id):
|
||||
except Exception as ex:
|
||||
logger.exception(ex)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": str(ex)}),
|
||||
jsonify({"success": False, "message": "Error uploading image"}),
|
||||
400,
|
||||
)
|
||||
|
||||
@@ -281,7 +281,7 @@ def send_to_plus(id):
|
||||
except Exception as ex:
|
||||
logger.exception(ex)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": str(ex)}),
|
||||
jsonify({"success": False, "message": "Error uploading annotation"}),
|
||||
400,
|
||||
)
|
||||
|
||||
@@ -352,7 +352,7 @@ def false_positive(id):
|
||||
except Exception as ex:
|
||||
logger.exception(ex)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": str(ex)}),
|
||||
jsonify({"success": False, "message": "Error uploading false positive"}),
|
||||
400,
|
||||
)
|
||||
|
||||
@@ -455,8 +455,9 @@ def get_labels():
|
||||
else:
|
||||
events = Event.select(Event.label).distinct()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": f"Failed to get labels: {e}"}), 404
|
||||
jsonify({"success": False, "message": "Failed to get labels"}), 404
|
||||
)
|
||||
|
||||
labels = sorted([e.label for e in events])
|
||||
@@ -469,9 +470,9 @@ def get_sub_labels():
|
||||
|
||||
try:
|
||||
events = Event.select(Event.sub_label).distinct()
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": f"Failed to get sub_labels: {e}"}),
|
||||
jsonify({"success": False, "message": "Failed to get sub_labels"}),
|
||||
404,
|
||||
)
|
||||
|
||||
@@ -516,6 +517,7 @@ def delete_event(id):
|
||||
media.unlink(missing_ok=True)
|
||||
|
||||
event.delete_instance()
|
||||
Timeline.delete().where(Timeline.source_id == id).execute()
|
||||
return make_response(
|
||||
jsonify({"success": True, "message": "Event " + id + " deleted"}), 200
|
||||
)
|
||||
@@ -648,7 +650,7 @@ def event_snapshot(id):
|
||||
)
|
||||
# read snapshot from disk
|
||||
with open(
|
||||
os.path.join(CLIPS_DIR, f"{event.camera}-{id}.jpg"), "rb"
|
||||
os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg"), "rb"
|
||||
) as image_file:
|
||||
jpg_bytes = image_file.read()
|
||||
except DoesNotExist:
|
||||
@@ -740,7 +742,7 @@ def event_clip(id):
|
||||
jsonify({"success": False, "message": "Clip not available"}), 404
|
||||
)
|
||||
|
||||
file_name = f"{event.camera}-{id}.mp4"
|
||||
file_name = f"{event.camera}-{event.id}.mp4"
|
||||
clip_path = os.path.join(CLIPS_DIR, file_name)
|
||||
|
||||
if not os.path.isfile(clip_path):
|
||||
@@ -956,9 +958,10 @@ def events():
|
||||
.order_by(Event.start_time.desc())
|
||||
.limit(limit)
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
return jsonify([e for e in events])
|
||||
return jsonify(list(events))
|
||||
|
||||
|
||||
@bp.route("/events/<camera_name>/<label>/create", methods=["POST"])
|
||||
@@ -993,8 +996,9 @@ def create_event(camera_name, label):
|
||||
frame,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": f"An unknown error occurred: {e}"}),
|
||||
jsonify({"success": False, "message": "An unknown error occurred"}),
|
||||
500,
|
||||
)
|
||||
|
||||
@@ -1187,11 +1191,12 @@ def config_set():
|
||||
with open(config_file, "w") as f:
|
||||
f.write(old_raw_config)
|
||||
f.close()
|
||||
logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}")
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"\nConfig Error:\n\n{str(traceback.format_exc())}",
|
||||
"message": "Error parsing config. Check logs for error message.",
|
||||
}
|
||||
),
|
||||
400,
|
||||
@@ -1365,7 +1370,10 @@ def latest_frame(camera_name):
|
||||
@bp.route("/<camera_name>/recordings/<frame_time>/snapshot.png")
|
||||
def get_snapshot_from_recording(camera_name: str, frame_time: str):
|
||||
if camera_name not in current_app.frigate_config.cameras:
|
||||
return "Camera named {} not found".format(camera_name), 404
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Camera not found"}),
|
||||
404,
|
||||
)
|
||||
|
||||
frame_time = float(frame_time)
|
||||
recording_query = (
|
||||
@@ -1483,6 +1491,7 @@ def recordings_summary(camera_name):
|
||||
),
|
||||
).desc()
|
||||
)
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
event_groups = (
|
||||
@@ -1504,14 +1513,14 @@ def recordings_summary(camera_name):
|
||||
),
|
||||
),
|
||||
)
|
||||
.objects()
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
event_map = {g.hour: g.count for g in event_groups}
|
||||
|
||||
days = {}
|
||||
|
||||
for recording_group in recording_groups.objects():
|
||||
for recording_group in recording_groups:
|
||||
parts = recording_group.hour.split()
|
||||
hour = parts[1]
|
||||
day = parts[0]
|
||||
@@ -1555,9 +1564,11 @@ def recordings(camera_name):
|
||||
Recordings.start_time <= before,
|
||||
)
|
||||
.order_by(Recordings.start_time)
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
return jsonify([e for e in recordings.dicts()])
|
||||
return jsonify(list(recordings))
|
||||
|
||||
|
||||
@bp.route("/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/clip.mp4")
|
||||
@@ -1591,7 +1602,7 @@ def recording_clip(camera_name, start_ts, end_ts):
|
||||
if clip.end_time > end_ts:
|
||||
playlist_lines.append(f"outpoint {int(end_ts - clip.start_time)}")
|
||||
|
||||
file_name = f"clip_{camera_name}_{start_ts}-{end_ts}.mp4"
|
||||
file_name = secure_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4")
|
||||
path = os.path.join(CACHE_DIR, file_name)
|
||||
|
||||
if not os.path.exists(path):
|
||||
@@ -1662,6 +1673,7 @@ def vod_ts(camera_name, start_ts, end_ts):
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
.iterator()
|
||||
)
|
||||
|
||||
clips = []
|
||||
@@ -1759,16 +1771,17 @@ def vod_event(id):
|
||||
404,
|
||||
)
|
||||
|
||||
clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{id}.mp4")
|
||||
clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.mp4")
|
||||
|
||||
if not os.path.isfile(clip_path):
|
||||
end_ts = (
|
||||
datetime.now().timestamp() if event.end_time is None else event.end_time
|
||||
)
|
||||
vod_response = vod_ts(event.camera, event.start_time, end_ts)
|
||||
# If the recordings are not found, set has_clip to false
|
||||
# If the recordings are not found and the event started more than 5 minutes ago, set has_clip to false
|
||||
if (
|
||||
type(vod_response) == tuple
|
||||
event.start_time < datetime.now().timestamp() - 300
|
||||
and type(vod_response) == tuple
|
||||
and len(vod_response) == 2
|
||||
and vod_response[1] == 404
|
||||
):
|
||||
@@ -1977,7 +1990,8 @@ def logs(service: str):
|
||||
file.close()
|
||||
return contents, 200
|
||||
except FileNotFoundError as e:
|
||||
logger.error(e)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": f"Could not find log file: {e}"}),
|
||||
jsonify({"success": False, "message": "Could not find log file"}),
|
||||
500,
|
||||
)
|
||||
|
@@ -57,6 +57,12 @@ class Timeline(Model): # type: ignore[misc]
|
||||
data = JSONField() # ex: tracked object id, region, box, etc.
|
||||
|
||||
|
||||
class Regions(Model): # type: ignore[misc]
|
||||
camera = CharField(null=False, primary_key=True, max_length=20)
|
||||
grid = JSONField() # json blob of grid
|
||||
last_update = DateTimeField()
|
||||
|
||||
|
||||
class Recordings(Model): # type: ignore[misc]
|
||||
id = CharField(null=False, primary_key=True, max_length=30)
|
||||
camera = CharField(index=True, max_length=20)
|
||||
|
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
import cv2
|
||||
import imutils
|
||||
import numpy as np
|
||||
@@ -6,6 +8,8 @@ from scipy.ndimage import gaussian_filter
|
||||
from frigate.config import MotionConfig
|
||||
from frigate.motion import MotionDetector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImprovedMotionDetector(MotionDetector):
|
||||
def __init__(
|
||||
@@ -138,8 +142,8 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
self.motion_frame_size[0] * self.motion_frame_size[1]
|
||||
)
|
||||
|
||||
# once the motion drops to less than 1% for the first time, assume its calibrated
|
||||
if pct_motion < 0.01:
|
||||
# once the motion is less than 5% and the number of contours is < 4, assume its calibrated
|
||||
if pct_motion < 0.05 and len(motion_boxes) <= 4:
|
||||
self.calibrating = False
|
||||
|
||||
# if calibrating or the motion contours are > 80% of the image area (lightning, ir, ptz) recalibrate
|
||||
|
@@ -105,6 +105,10 @@ class TrackedObject:
|
||||
def __init__(
|
||||
self, camera, colormap, camera_config: CameraConfig, frame_cache, obj_data
|
||||
):
|
||||
# set the score history then remove as it is not part of object state
|
||||
self.score_history = obj_data["score_history"]
|
||||
del obj_data["score_history"]
|
||||
|
||||
self.obj_data = obj_data
|
||||
self.camera = camera
|
||||
self.colormap = colormap
|
||||
@@ -136,11 +140,8 @@ class TrackedObject:
|
||||
return self.computed_score < threshold
|
||||
|
||||
def compute_score(self):
|
||||
scores = self.score_history[:]
|
||||
# pad with zeros if you dont have at least 3 scores
|
||||
if len(scores) < 3:
|
||||
scores += [0.0] * (3 - len(scores))
|
||||
return median(scores)
|
||||
"""get median of scores for object."""
|
||||
return median(self.score_history)
|
||||
|
||||
def update(self, current_frame_time, obj_data):
|
||||
thumb_update = False
|
||||
@@ -151,6 +152,7 @@ class TrackedObject:
|
||||
self.score_history.append(0.0)
|
||||
else:
|
||||
self.score_history.append(obj_data["score"])
|
||||
|
||||
# only keep the last 10 scores
|
||||
if len(self.score_history) > 10:
|
||||
self.score_history = self.score_history[-10:]
|
||||
@@ -499,6 +501,9 @@ class CameraState:
|
||||
# draw thicker box around ptz autotracked object
|
||||
if (
|
||||
self.camera_config.onvif.autotracking.enabled
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init[
|
||||
self.name
|
||||
]
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||
self.name
|
||||
]
|
||||
@@ -507,6 +512,7 @@ class CameraState:
|
||||
== self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||
self.name
|
||||
].obj_data["id"]
|
||||
and obj["frame_time"] == frame_time
|
||||
):
|
||||
thickness = 5
|
||||
color = self.config.model.colormap[obj["label"]]
|
||||
|
@@ -24,6 +24,7 @@ from ws4py.websocket import WebSocket
|
||||
|
||||
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
||||
from frigate.const import BASE_DIR, BIRDSEYE_PIPE
|
||||
from frigate.types import CameraMetricsTypes
|
||||
from frigate.util.image import (
|
||||
SharedMemoryFrameManager,
|
||||
copy_yuv_to_position,
|
||||
@@ -35,10 +36,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def get_standard_aspect_ratio(width: int, height: int) -> tuple[int, int]:
|
||||
"""Ensure that only standard aspect ratios are used."""
|
||||
# it is imoprtant that all ratios have the same scale
|
||||
known_aspects = [
|
||||
(16, 9),
|
||||
(9, 16),
|
||||
(32, 9),
|
||||
(20, 10),
|
||||
(16, 6), # reolink duo 2
|
||||
(32, 9), # panoramic cameras
|
||||
(12, 9),
|
||||
(9, 12),
|
||||
] # aspects are scaled to have common relative size
|
||||
@@ -238,6 +242,7 @@ class BirdsEyeFrameManager:
|
||||
config: FrigateConfig,
|
||||
frame_manager: SharedMemoryFrameManager,
|
||||
stop_event: mp.Event,
|
||||
camera_metrics: dict[str, CameraMetricsTypes],
|
||||
):
|
||||
self.config = config
|
||||
self.mode = config.birdseye.mode
|
||||
@@ -248,6 +253,7 @@ class BirdsEyeFrameManager:
|
||||
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
|
||||
self.canvas = Canvas(width, height)
|
||||
self.stop_event = stop_event
|
||||
self.camera_metrics = camera_metrics
|
||||
|
||||
# initialize the frame as black and with the Frigate logo
|
||||
self.blank_frame = np.zeros(self.yuv_shape, np.uint8)
|
||||
@@ -494,6 +500,9 @@ class BirdsEyeFrameManager:
|
||||
y += row_height
|
||||
candidate_layout.append(final_row)
|
||||
|
||||
if max_width == 0:
|
||||
max_width = x
|
||||
|
||||
return max_width, y, candidate_layout
|
||||
|
||||
canvas_aspect_x, canvas_aspect_y = self.canvas.get_aspect(coefficient)
|
||||
@@ -557,15 +566,18 @@ class BirdsEyeFrameManager:
|
||||
row_height = int(self.canvas.height / coefficient)
|
||||
total_width, total_height, standard_candidate_layout = map_layout(row_height)
|
||||
|
||||
if not standard_candidate_layout:
|
||||
return None
|
||||
|
||||
# layout can't be optimized more
|
||||
if total_width / self.canvas.width >= 0.99:
|
||||
return standard_candidate_layout
|
||||
|
||||
scale_up_percent = min(
|
||||
1 - (total_width / self.canvas.width),
|
||||
1 - (total_height / self.canvas.height),
|
||||
1 / (total_width / self.canvas.width),
|
||||
1 / (total_height / self.canvas.height),
|
||||
)
|
||||
row_height = int(row_height * (1 + round(scale_up_percent, 1)))
|
||||
row_height = int(row_height * scale_up_percent)
|
||||
_, _, scaled_layout = map_layout(row_height)
|
||||
|
||||
if scaled_layout:
|
||||
@@ -579,9 +591,25 @@ class BirdsEyeFrameManager:
|
||||
if not camera_config.enabled:
|
||||
return False
|
||||
|
||||
# get our metrics (sync'd across processes)
|
||||
# which allows us to control it via mqtt (or any other dispatcher)
|
||||
camera_metrics = self.camera_metrics[camera]
|
||||
|
||||
# disabling birdseye is a little tricky
|
||||
if not camera_metrics["birdseye_enabled"].value:
|
||||
# if we've rendered a frame (we have a value for last_active_frame)
|
||||
# then we need to set it to zero
|
||||
if self.cameras[camera]["last_active_frame"] > 0:
|
||||
self.cameras[camera]["last_active_frame"] = 0
|
||||
|
||||
return False
|
||||
|
||||
# get the birdseye mode state from camera metrics
|
||||
birdseye_mode = BirdseyeModeEnum.get(camera_metrics["birdseye_mode"].value)
|
||||
|
||||
# update the last active frame for the camera
|
||||
self.cameras[camera]["current_frame"] = frame_time
|
||||
if self.camera_active(camera_config.mode, object_count, motion_count):
|
||||
if self.camera_active(birdseye_mode, object_count, motion_count):
|
||||
self.cameras[camera]["last_active_frame"] = frame_time
|
||||
|
||||
now = datetime.datetime.now().timestamp()
|
||||
@@ -605,7 +633,11 @@ class BirdsEyeFrameManager:
|
||||
return False
|
||||
|
||||
|
||||
def output_frames(config: FrigateConfig, video_output_queue):
|
||||
def output_frames(
|
||||
config: FrigateConfig,
|
||||
video_output_queue,
|
||||
camera_metrics: dict[str, CameraMetricsTypes],
|
||||
):
|
||||
threading.current_thread().name = "output"
|
||||
setproctitle("frigate.output")
|
||||
|
||||
@@ -661,7 +693,10 @@ def output_frames(config: FrigateConfig, video_output_queue):
|
||||
config.birdseye.restream,
|
||||
)
|
||||
broadcasters["birdseye"] = BroadcastThread(
|
||||
"birdseye", converters["birdseye"], websocket_server, stop_event
|
||||
"birdseye",
|
||||
converters["birdseye"],
|
||||
websocket_server,
|
||||
stop_event,
|
||||
)
|
||||
|
||||
websocket_thread.start()
|
||||
@@ -669,7 +704,9 @@ def output_frames(config: FrigateConfig, video_output_queue):
|
||||
for t in broadcasters.values():
|
||||
t.start()
|
||||
|
||||
birdseye_manager = BirdsEyeFrameManager(config, frame_manager, stop_event)
|
||||
birdseye_manager = BirdsEyeFrameManager(
|
||||
config, frame_manager, stop_event, camera_metrics
|
||||
)
|
||||
|
||||
if config.birdseye.restream:
|
||||
birdseye_buffer = frame_manager.create(
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,7 @@ class OnvifController:
|
||||
|
||||
request = ptz.create_type("GetConfigurations")
|
||||
configs = ptz.GetConfigurations(request)[0]
|
||||
logger.debug(f"Onvif configs for {camera_name}: {configs}")
|
||||
|
||||
request = ptz.create_type("GetConfigurationOptions")
|
||||
request.ConfigurationToken = profile.PTZConfiguration.token
|
||||
@@ -99,6 +100,17 @@ class OnvifController:
|
||||
None,
|
||||
)
|
||||
|
||||
# status request for autotracking and filling ptz-parameters
|
||||
status_request = ptz.create_type("GetStatus")
|
||||
status_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["status_request"] = status_request
|
||||
try:
|
||||
status = ptz.GetStatus(status_request)
|
||||
logger.debug(f"Onvif status config for {camera_name}: {status}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Unable to get status from camera: {camera_name}: {e}")
|
||||
status = 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:
|
||||
@@ -122,9 +134,7 @@ class OnvifController:
|
||||
move_request = ptz.create_type("RelativeMove")
|
||||
move_request.ProfileToken = profile.token
|
||||
if move_request.Translation is None and fov_space_id is not None:
|
||||
move_request.Translation = ptz.GetStatus(
|
||||
{"ProfileToken": profile.token}
|
||||
).Position
|
||||
move_request.Translation = status.Position
|
||||
move_request.Translation.PanTilt.space = ptz_config["Spaces"][
|
||||
"RelativePanTiltTranslationSpace"
|
||||
][fov_space_id]["URI"]
|
||||
@@ -152,7 +162,7 @@ class OnvifController:
|
||||
)
|
||||
|
||||
if move_request.Speed is None:
|
||||
move_request.Speed = ptz.GetStatus({"ProfileToken": profile.token}).Position
|
||||
move_request.Speed = status.Position if status else None
|
||||
self.cams[camera_name]["relative_move_request"] = move_request
|
||||
|
||||
# setup absolute moving request for autotracking zooming
|
||||
@@ -160,13 +170,6 @@ class OnvifController:
|
||||
move_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["absolute_move_request"] = move_request
|
||||
|
||||
# status request for autotracking
|
||||
status_request = ptz.create_type("GetStatus")
|
||||
status_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["status_request"] = status_request
|
||||
status = ptz.GetStatus(status_request)
|
||||
logger.debug(f"Onvif status config for {camera_name}: {status}")
|
||||
|
||||
# setup existing presets
|
||||
try:
|
||||
presets: list[dict] = ptz.GetPresets({"ProfileToken": profile.token})
|
||||
@@ -176,7 +179,7 @@ class OnvifController:
|
||||
|
||||
for preset in presets:
|
||||
self.cams[camera_name]["presets"][
|
||||
getattr(preset, "Name", f"preset {preset['token']}").lower()
|
||||
(getattr(preset, "Name") or f"preset {preset['token']}").lower()
|
||||
] = preset["token"]
|
||||
|
||||
# get list of supported features
|
||||
@@ -194,6 +197,20 @@ class OnvifController:
|
||||
|
||||
if ptz_config.Spaces and ptz_config.Spaces.RelativeZoomTranslationSpace:
|
||||
supported_features.append("zoom-r")
|
||||
try:
|
||||
# get camera's zoom limits from onvif config
|
||||
self.cams[camera_name][
|
||||
"relative_zoom_range"
|
||||
] = ptz_config.Spaces.RelativeZoomTranslationSpace[0]
|
||||
except Exception:
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
== ZoomingModeEnum.relative
|
||||
):
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming = False
|
||||
logger.warning(
|
||||
f"Disabling autotracking zooming for {camera_name}: Relative zoom not supported"
|
||||
)
|
||||
|
||||
if ptz_config.Spaces and ptz_config.Spaces.AbsoluteZoomPositionSpace:
|
||||
supported_features.append("zoom-a")
|
||||
@@ -271,7 +288,9 @@ class OnvifController:
|
||||
logger.error(f"{camera_name} does not support ONVIF RelativeMove (FOV).")
|
||||
return
|
||||
|
||||
logger.debug(f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt}")
|
||||
logger.debug(
|
||||
f"{camera_name} called RelativeMove: pan: {pan} tilt: {tilt} zoom: {zoom}"
|
||||
)
|
||||
|
||||
if self.cams[camera_name]["active"]:
|
||||
logger.warning(
|
||||
@@ -282,7 +301,7 @@ class OnvifController:
|
||||
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}"
|
||||
f"{camera_name}: 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
|
||||
@@ -348,6 +367,8 @@ class OnvifController:
|
||||
|
||||
self.cams[camera_name]["active"] = True
|
||||
self.ptz_metrics[camera_name]["ptz_stopped"].clear()
|
||||
self.ptz_metrics[camera_name]["ptz_start_time"].value = 0
|
||||
self.ptz_metrics[camera_name]["ptz_stop_time"].value = 0
|
||||
move_request = self.cams[camera_name]["move_request"]
|
||||
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
||||
preset_token = self.cams[camera_name]["presets"][preset]
|
||||
@@ -357,7 +378,7 @@ class OnvifController:
|
||||
"PresetToken": preset_token,
|
||||
}
|
||||
)
|
||||
self.ptz_metrics[camera_name]["ptz_stopped"].set()
|
||||
|
||||
self.cams[camera_name]["active"] = False
|
||||
|
||||
def _zoom(self, camera_name: str, command: OnvifCommandEnum) -> None:
|
||||
@@ -394,7 +415,7 @@ class OnvifController:
|
||||
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}"
|
||||
f"{camera_name}: 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
|
||||
@@ -416,7 +437,7 @@ class OnvifController:
|
||||
move_request.Speed = {"Zoom": speed}
|
||||
move_request.Position = {"Zoom": zoom}
|
||||
|
||||
logger.debug(f"Absolute zoom: {zoom}")
|
||||
logger.debug(f"{camera_name}: Absolute zoom: {zoom}")
|
||||
|
||||
onvif.get_service("ptz").AbsoluteMove(move_request)
|
||||
|
||||
@@ -494,7 +515,10 @@ class OnvifController:
|
||||
|
||||
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
||||
status_request = self.cams[camera_name]["status_request"]
|
||||
status = onvif.get_service("ptz").GetStatus(status_request)
|
||||
try:
|
||||
status = onvif.get_service("ptz").GetStatus(status_request)
|
||||
except Exception:
|
||||
pass # We're unsupported, that'll be reported in the next check.
|
||||
|
||||
# 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
|
||||
@@ -523,7 +547,7 @@ class OnvifController:
|
||||
self.ptz_metrics[camera_name]["ptz_stopped"].set()
|
||||
|
||||
logger.debug(
|
||||
f"PTZ stop time: {self.ptz_metrics[camera_name]['ptz_frame_time'].value}"
|
||||
f"{camera_name}: PTZ stop time: {self.ptz_metrics[camera_name]['ptz_frame_time'].value}"
|
||||
)
|
||||
|
||||
self.ptz_metrics[camera_name]["ptz_stop_time"].value = self.ptz_metrics[
|
||||
@@ -535,7 +559,7 @@ class OnvifController:
|
||||
self.ptz_metrics[camera_name]["ptz_stopped"].clear()
|
||||
|
||||
logger.debug(
|
||||
f"PTZ start time: {self.ptz_metrics[camera_name]['ptz_frame_time'].value}"
|
||||
f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name]['ptz_frame_time'].value}"
|
||||
)
|
||||
|
||||
self.ptz_metrics[camera_name][
|
||||
@@ -545,7 +569,7 @@ class OnvifController:
|
||||
|
||||
if (
|
||||
self.config.cameras[camera_name].onvif.autotracking.zooming
|
||||
== ZoomingModeEnum.absolute
|
||||
!= ZoomingModeEnum.disabled
|
||||
):
|
||||
# store absolute zoom level as 0 to 1 interpolated from the values of the camera
|
||||
self.ptz_metrics[camera_name]["ptz_zoom_level"].value = numpy.interp(
|
||||
@@ -557,5 +581,23 @@ class OnvifController:
|
||||
],
|
||||
)
|
||||
logger.debug(
|
||||
f'Camera zoom level: {self.ptz_metrics[camera_name]["ptz_zoom_level"].value}'
|
||||
f'{camera_name}: Camera zoom level: {self.ptz_metrics[camera_name]["ptz_zoom_level"].value}'
|
||||
)
|
||||
|
||||
# some hikvision cams won't update MoveStatus, so warn if it hasn't changed
|
||||
if (
|
||||
not self.ptz_metrics[camera_name]["ptz_stopped"].is_set()
|
||||
and not self.ptz_metrics[camera_name]["ptz_reset"].is_set()
|
||||
and self.ptz_metrics[camera_name]["ptz_start_time"].value != 0
|
||||
and self.ptz_metrics[camera_name]["ptz_frame_time"].value
|
||||
> (self.ptz_metrics[camera_name]["ptz_start_time"].value + 10)
|
||||
and self.ptz_metrics[camera_name]["ptz_stop_time"].value == 0
|
||||
):
|
||||
logger.debug(
|
||||
f'Start time: {self.ptz_metrics[camera_name]["ptz_start_time"].value}, Stop time: {self.ptz_metrics[camera_name]["ptz_stop_time"].value}, Frame time: {self.ptz_metrics[camera_name]["ptz_frame_time"].value}'
|
||||
)
|
||||
# set the stop time so we don't come back into this again and spam the logs
|
||||
self.ptz_metrics[camera_name]["ptz_stop_time"].value = self.ptz_metrics[
|
||||
camera_name
|
||||
]["ptz_frame_time"].value
|
||||
logger.warning(f"Camera {camera_name} is still in ONVIF 'MOVING' status.")
|
||||
|
@@ -48,12 +48,17 @@ class RecordingCleanup(threading.Thread):
|
||||
expire_before = (
|
||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||
).timestamp()
|
||||
no_camera_recordings: Recordings = Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.path,
|
||||
).where(
|
||||
Recordings.camera.not_in(list(self.config.cameras.keys())),
|
||||
Recordings.end_time < expire_before,
|
||||
no_camera_recordings: Recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.path,
|
||||
)
|
||||
.where(
|
||||
Recordings.camera.not_in(list(self.config.cameras.keys())),
|
||||
Recordings.end_time < expire_before,
|
||||
)
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
deleted_recordings = set()
|
||||
@@ -95,6 +100,8 @@ class RecordingCleanup(threading.Thread):
|
||||
Recordings.end_time < expire_date,
|
||||
)
|
||||
.order_by(Recordings.start_time)
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
# Get all the events to check against
|
||||
@@ -111,14 +118,14 @@ class RecordingCleanup(threading.Thread):
|
||||
Event.has_clip,
|
||||
)
|
||||
.order_by(Event.start_time)
|
||||
.objects()
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
# loop over recordings and see if they overlap with any non-expired events
|
||||
# TODO: expire segments based on segment stats according to config
|
||||
event_start = 0
|
||||
deleted_recordings = set()
|
||||
for recording in recordings.objects().iterator():
|
||||
for recording in recordings:
|
||||
keep = False
|
||||
# Now look for a reason to keep this recording segment
|
||||
for idx in range(event_start, len(events)):
|
||||
|
@@ -163,6 +163,8 @@ class RecordingMaintainer(threading.Thread):
|
||||
Event.has_clip,
|
||||
)
|
||||
.order_by(Event.start_time)
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
tasks.extend(
|
||||
@@ -254,20 +256,29 @@ class RecordingMaintainer(threading.Thread):
|
||||
# if it ends more than the configured pre_capture for the camera
|
||||
else:
|
||||
pre_capture = self.config.cameras[camera].record.events.pre_capture
|
||||
most_recently_processed_frame_time = self.object_recordings_info[
|
||||
camera
|
||||
][-1][0]
|
||||
camera_info = self.object_recordings_info[camera]
|
||||
most_recently_processed_frame_time = (
|
||||
camera_info[-1][0] if len(camera_info) > 0 else 0
|
||||
)
|
||||
retain_cutoff = most_recently_processed_frame_time - pre_capture
|
||||
if end_time.timestamp() < retain_cutoff:
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
# else retain days includes this segment
|
||||
else:
|
||||
record_mode = self.config.cameras[camera].record.retain.mode
|
||||
return await self.move_segment(
|
||||
camera, start_time, end_time, duration, cache_path, record_mode
|
||||
# assume that empty means the relevant recording info has not been received yet
|
||||
camera_info = self.object_recordings_info[camera]
|
||||
most_recently_processed_frame_time = (
|
||||
camera_info[-1][0] if len(camera_info) > 0 else 0
|
||||
)
|
||||
|
||||
# ensure delayed segment info does not lead to lost segments
|
||||
if most_recently_processed_frame_time >= end_time.timestamp():
|
||||
record_mode = self.config.cameras[camera].record.retain.mode
|
||||
return await self.move_segment(
|
||||
camera, start_time, end_time, duration, cache_path, record_mode
|
||||
)
|
||||
|
||||
def segment_stats(
|
||||
self, camera: str, start_time: datetime.datetime, end_time: datetime.datetime
|
||||
) -> SegmentInfo:
|
||||
@@ -301,6 +312,10 @@ class RecordingMaintainer(threading.Thread):
|
||||
if frame[0] < start_time.timestamp():
|
||||
continue
|
||||
|
||||
# add active audio label count to count of active objects
|
||||
active_count += len(frame[2])
|
||||
|
||||
# add sound level to audio values
|
||||
audio_values.append(frame[1])
|
||||
|
||||
average_dBFS = 0 if not audio_values else np.average(audio_values)
|
||||
@@ -406,11 +421,13 @@ class RecordingMaintainer(threading.Thread):
|
||||
return None
|
||||
|
||||
def run(self) -> None:
|
||||
camera_count = sum(camera.enabled for camera in self.config.cameras.values())
|
||||
# Check for new files every 5 seconds
|
||||
wait_time = 0.0
|
||||
while not self.stop_event.wait(wait_time):
|
||||
run_start = datetime.datetime.now().timestamp()
|
||||
|
||||
stale_frame_count = 0
|
||||
stale_frame_count_threshold = 10
|
||||
# empty the object recordings info queue
|
||||
while True:
|
||||
try:
|
||||
@@ -420,7 +437,10 @@ class RecordingMaintainer(threading.Thread):
|
||||
current_tracked_objects,
|
||||
motion_boxes,
|
||||
regions,
|
||||
) = self.object_recordings_info_queue.get(False)
|
||||
) = self.object_recordings_info_queue.get(True, timeout=0.01)
|
||||
|
||||
if frame_time < run_start - stale_frame_count_threshold:
|
||||
stale_frame_count += 1
|
||||
|
||||
if self.process_info[camera]["record_enabled"].value:
|
||||
self.object_recordings_info[camera].append(
|
||||
@@ -432,28 +452,53 @@ class RecordingMaintainer(threading.Thread):
|
||||
)
|
||||
)
|
||||
except queue.Empty:
|
||||
q_size = self.object_recordings_info_queue.qsize()
|
||||
if q_size > camera_count:
|
||||
logger.debug(
|
||||
f"object_recordings_info loop queue not empty ({q_size})."
|
||||
)
|
||||
break
|
||||
|
||||
if stale_frame_count > 0:
|
||||
logger.debug(f"Found {stale_frame_count} old frames.")
|
||||
|
||||
# empty the audio recordings info queue if audio is enabled
|
||||
if self.audio_recordings_info_queue:
|
||||
stale_frame_count = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
(
|
||||
camera,
|
||||
frame_time,
|
||||
dBFS,
|
||||
) = self.audio_recordings_info_queue.get(False)
|
||||
audio_detections,
|
||||
) = self.audio_recordings_info_queue.get(True, timeout=0.01)
|
||||
|
||||
if frame_time < run_start - stale_frame_count_threshold:
|
||||
stale_frame_count += 1
|
||||
|
||||
if self.process_info[camera]["record_enabled"].value:
|
||||
self.audio_recordings_info[camera].append(
|
||||
(
|
||||
frame_time,
|
||||
dBFS,
|
||||
audio_detections,
|
||||
)
|
||||
)
|
||||
except queue.Empty:
|
||||
q_size = self.audio_recordings_info_queue.qsize()
|
||||
if q_size > camera_count:
|
||||
logger.debug(
|
||||
f"object_recordings_info loop audio queue not empty ({q_size})."
|
||||
)
|
||||
break
|
||||
|
||||
if stale_frame_count > 0:
|
||||
logger.error(
|
||||
f"Found {stale_frame_count} old audio frames, segments from recordings may be missing"
|
||||
)
|
||||
|
||||
try:
|
||||
asyncio.run(self.move_files())
|
||||
except Exception as e:
|
||||
|
@@ -248,6 +248,7 @@ def stats_snapshot(
|
||||
|
||||
total_detection_fps = 0
|
||||
|
||||
stats["cameras"] = {}
|
||||
for name, camera_stats in camera_metrics.items():
|
||||
total_detection_fps += camera_stats["detection_fps"].value
|
||||
pid = camera_stats["process"].pid if camera_stats["process"] else None
|
||||
@@ -259,7 +260,7 @@ def stats_snapshot(
|
||||
if camera_stats["capture_process"]
|
||||
else None
|
||||
)
|
||||
stats[name] = {
|
||||
stats["cameras"][name] = {
|
||||
"camera_fps": round(camera_stats["camera_fps"].value, 2),
|
||||
"process_fps": round(camera_stats["process_fps"].value, 2),
|
||||
"skipped_fps": round(camera_stats["skipped_fps"].value, 2),
|
||||
@@ -302,6 +303,7 @@ def stats_snapshot(
|
||||
storage_stats = shutil.disk_usage(path)
|
||||
except FileNotFoundError:
|
||||
stats["service"]["storage"][path] = {}
|
||||
continue
|
||||
|
||||
stats["service"]["storage"][path] = {
|
||||
"total": round(storage_stats.total / pow(2, 20), 1),
|
||||
|
@@ -99,13 +99,19 @@ class StorageMaintainer(threading.Thread):
|
||||
[b["bandwidth"] for b in self.camera_storage_stats.values()]
|
||||
)
|
||||
|
||||
recordings: Recordings = Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
Recordings.segment_size,
|
||||
Recordings.path,
|
||||
).order_by(Recordings.start_time.asc())
|
||||
recordings: Recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
Recordings.segment_size,
|
||||
Recordings.path,
|
||||
)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
retained_events: Event = (
|
||||
Event.select(
|
||||
Event.start_time,
|
||||
@@ -116,12 +122,12 @@ class StorageMaintainer(threading.Thread):
|
||||
Event.has_clip,
|
||||
)
|
||||
.order_by(Event.start_time.asc())
|
||||
.objects()
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
event_start = 0
|
||||
deleted_recordings = set()
|
||||
for recording in recordings.objects().iterator():
|
||||
for recording in recordings:
|
||||
# check if 1 hour of storage has been reclaimed
|
||||
if deleted_segments_size > hourly_bandwidth:
|
||||
break
|
||||
@@ -162,13 +168,18 @@ class StorageMaintainer(threading.Thread):
|
||||
logger.error(
|
||||
f"Could not clear {hourly_bandwidth} MB, currently {deleted_segments_size} MB have been cleared. Retained recordings must be deleted."
|
||||
)
|
||||
recordings = Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.path,
|
||||
Recordings.segment_size,
|
||||
).order_by(Recordings.start_time.asc())
|
||||
recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.path,
|
||||
Recordings.segment_size,
|
||||
)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
for recording in recordings.objects().iterator():
|
||||
for recording in recordings:
|
||||
if deleted_segments_size > hourly_bandwidth:
|
||||
break
|
||||
|
||||
|
@@ -1641,7 +1641,9 @@ class TestConfig(unittest.TestCase):
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
"onvif": {"autotracking": {"movement_weights": "1.23, 2.34, 0.50"}},
|
||||
"onvif": {
|
||||
"autotracking": {"movement_weights": "0, 1, 1.23, 2.34, 0.50"}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1649,6 +1651,8 @@ class TestConfig(unittest.TestCase):
|
||||
|
||||
runtime_config = frigate_config.runtime_config()
|
||||
assert runtime_config.cameras["back"].onvif.autotracking.movement_weights == [
|
||||
0,
|
||||
1,
|
||||
1.23,
|
||||
2.34,
|
||||
0.50,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
from unittest import TestCase, main
|
||||
|
||||
from frigate.video import box_overlaps, reduce_boxes
|
||||
from frigate.util.object import box_overlaps, reduce_boxes
|
||||
|
||||
|
||||
class TestBoxOverlaps(TestCase):
|
||||
|
@@ -6,10 +6,12 @@ from norfair.drawing.color import Palette
|
||||
from norfair.drawing.drawer import Drawer
|
||||
|
||||
from frigate.util.image import intersection
|
||||
from frigate.video import (
|
||||
from frigate.util.object import (
|
||||
get_cluster_boundary,
|
||||
get_cluster_candidates,
|
||||
get_cluster_region,
|
||||
get_region_from_grid,
|
||||
reduce_detections,
|
||||
)
|
||||
|
||||
|
||||
@@ -190,3 +192,125 @@ class TestObjectBoundingBoxes(unittest.TestCase):
|
||||
|
||||
assert intersection(box_a, box_b) == None
|
||||
assert intersection(box_b, box_c) == (899, 128, 985, 151)
|
||||
|
||||
def test_overlapping_objects_reduced(self):
|
||||
"""Test that object not on edge of region is used when a higher scoring object at the edge of region is provided."""
|
||||
detections = [
|
||||
(
|
||||
"car",
|
||||
0.81,
|
||||
(1209, 73, 1437, 163),
|
||||
20520,
|
||||
2.53333333,
|
||||
(1150, 0, 1500, 200),
|
||||
),
|
||||
(
|
||||
"car",
|
||||
0.88,
|
||||
(1238, 73, 1401, 171),
|
||||
15974,
|
||||
1.663265306122449,
|
||||
(1242, 0, 1602, 360),
|
||||
),
|
||||
]
|
||||
frame_shape = (720, 2560)
|
||||
consolidated_detections = reduce_detections(frame_shape, detections)
|
||||
assert consolidated_detections == [
|
||||
(
|
||||
"car",
|
||||
0.81,
|
||||
(1209, 73, 1437, 163),
|
||||
20520,
|
||||
2.53333333,
|
||||
(1150, 0, 1500, 200),
|
||||
)
|
||||
]
|
||||
|
||||
def test_non_overlapping_objects_not_reduced(self):
|
||||
"""Test that non overlapping objects are not reduced."""
|
||||
detections = [
|
||||
(
|
||||
"car",
|
||||
0.81,
|
||||
(1209, 73, 1437, 163),
|
||||
20520,
|
||||
2.53333333,
|
||||
(1150, 0, 1500, 200),
|
||||
),
|
||||
(
|
||||
"car",
|
||||
0.83203125,
|
||||
(1121, 55, 1214, 100),
|
||||
4185,
|
||||
2.066666666666667,
|
||||
(922, 0, 1242, 320),
|
||||
),
|
||||
(
|
||||
"car",
|
||||
0.85546875,
|
||||
(1414, 97, 1571, 186),
|
||||
13973,
|
||||
1.7640449438202248,
|
||||
(1248, 0, 1568, 320),
|
||||
),
|
||||
]
|
||||
frame_shape = (720, 2560)
|
||||
consolidated_detections = reduce_detections(frame_shape, detections)
|
||||
assert len(consolidated_detections) == len(detections)
|
||||
|
||||
def test_overlapping_different_size_objects_not_reduced(self):
|
||||
"""Test that overlapping objects that are significantly different in size are not reduced."""
|
||||
detections = [
|
||||
(
|
||||
"car",
|
||||
0.81,
|
||||
(164, 279, 816, 719),
|
||||
286880,
|
||||
1.48,
|
||||
(90, 0, 910, 820),
|
||||
),
|
||||
(
|
||||
"car",
|
||||
0.83203125,
|
||||
(248, 340, 328, 385),
|
||||
3600,
|
||||
1.777,
|
||||
(0, 0, 460, 460),
|
||||
),
|
||||
]
|
||||
frame_shape = (720, 2560)
|
||||
consolidated_detections = reduce_detections(frame_shape, detections)
|
||||
assert len(consolidated_detections) == len(detections)
|
||||
|
||||
|
||||
class TestRegionGrid(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
pass
|
||||
|
||||
def test_region_in_range(self):
|
||||
"""Test that region is kept at minimal size when within std dev."""
|
||||
frame_shape = (720, 1280)
|
||||
box = [450, 450, 550, 550]
|
||||
region_grid = [
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[{}, {}, {}, {}, {}, {"sizes": [0.25], "mean": 0.26, "std_dev": 0.01}],
|
||||
]
|
||||
|
||||
region = get_region_from_grid(frame_shape, box, 320, region_grid)
|
||||
assert region[2] - region[0] == 320
|
||||
|
||||
def test_region_out_of_range(self):
|
||||
"""Test that region is upsized when outside of std dev."""
|
||||
frame_shape = (720, 1280)
|
||||
box = [450, 450, 550, 550]
|
||||
region_grid = [
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[{}, {}, {}, {}, {}, {"sizes": [0.5], "mean": 0.5, "std_dev": 0.1}],
|
||||
]
|
||||
|
||||
region = get_region_from_grid(frame_shape, box, 320, region_grid)
|
||||
assert region[2] - region[0] > 320
|
||||
|
@@ -85,6 +85,7 @@ class TimelineProcessor(threading.Thread):
|
||||
if (
|
||||
prev_event_data["current_zones"] != event_data["current_zones"]
|
||||
and len(event_data["current_zones"]) > 0
|
||||
and not event_data["stationary"]
|
||||
):
|
||||
timeline_entry[Timeline.class_type] = "entered_zone"
|
||||
timeline_entry[Timeline.data]["zones"] = event_data["current_zones"]
|
||||
|
@@ -13,6 +13,7 @@ from frigate.util import intersection_over_union
|
||||
class CentroidTracker(ObjectTracker):
|
||||
def __init__(self, config: DetectConfig):
|
||||
self.tracked_objects = {}
|
||||
self.untracked_object_boxes = []
|
||||
self.disappeared = {}
|
||||
self.positions = {}
|
||||
self.max_disappeared = config.max_disappeared
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
|
||||
@@ -11,6 +12,8 @@ from frigate.track import ObjectTracker
|
||||
from frigate.types import PTZMetricsTypes
|
||||
from frigate.util.image import intersection_over_union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Normalizes distance from estimate relative to object size
|
||||
# Other ideas:
|
||||
@@ -62,6 +65,7 @@ class NorfairTracker(ObjectTracker):
|
||||
ptz_metrics: PTZMetricsTypes,
|
||||
):
|
||||
self.tracked_objects = {}
|
||||
self.untracked_object_boxes: list[list[int]] = []
|
||||
self.disappeared = {}
|
||||
self.positions = {}
|
||||
self.max_disappeared = config.detect.max_disappeared
|
||||
@@ -77,7 +81,7 @@ class NorfairTracker(ObjectTracker):
|
||||
self.tracker = Tracker(
|
||||
distance_function=frigate_distance,
|
||||
distance_threshold=2.5,
|
||||
initialization_delay=config.detect.fps / 2,
|
||||
initialization_delay=self.detect_config.fps / 2,
|
||||
hit_counter_max=self.max_disappeared,
|
||||
)
|
||||
if self.ptz_autotracker_enabled.value:
|
||||
@@ -93,6 +97,12 @@ class NorfairTracker(ObjectTracker):
|
||||
obj["start_time"] = obj["frame_time"]
|
||||
obj["motionless_count"] = 0
|
||||
obj["position_changes"] = 0
|
||||
obj["score_history"] = [
|
||||
p.data["score"]
|
||||
for p in next(
|
||||
(o for o in self.tracker.tracked_objects if o.global_id == track_id)
|
||||
).past_detections
|
||||
]
|
||||
self.tracked_objects[id] = obj
|
||||
self.disappeared[id] = 0
|
||||
self.positions[id] = {
|
||||
@@ -273,11 +283,10 @@ class NorfairTracker(ObjectTracker):
|
||||
min(self.detect_config.width - 1, estimate[2]),
|
||||
min(self.detect_config.height - 1, estimate[3]),
|
||||
)
|
||||
estimate_velocity = tuple(t.estimate_velocity.flatten().astype(int))
|
||||
obj = {
|
||||
**t.last_detection.data,
|
||||
"estimate": estimate,
|
||||
"estimate_velocity": estimate_velocity,
|
||||
"estimate_velocity": t.estimate_velocity,
|
||||
}
|
||||
active_ids.append(t.global_id)
|
||||
if t.global_id not in self.track_id_map:
|
||||
@@ -299,6 +308,12 @@ class NorfairTracker(ObjectTracker):
|
||||
for e_id in expired_ids:
|
||||
self.deregister(self.track_id_map[e_id], e_id)
|
||||
|
||||
# update list of object boxes that don't have a tracked object yet
|
||||
tracked_object_boxes = [obj["box"] for obj in self.tracked_objects.values()]
|
||||
self.untracked_object_boxes = [
|
||||
o[2] for o in detections if o[2] not in tracked_object_boxes
|
||||
]
|
||||
|
||||
def debug_draw(self, frame, frame_time):
|
||||
active_detections = [
|
||||
Drawable(id=obj.id, points=obj.last_detection.points, label=obj.label)
|
||||
|
@@ -25,6 +25,8 @@ class CameraMetricsTypes(TypedDict):
|
||||
skipped_fps: Synchronized
|
||||
audio_rms: Synchronized
|
||||
audio_dBFS: Synchronized
|
||||
birdseye_enabled: Synchronized
|
||||
birdseye_mode: Synchronized
|
||||
|
||||
|
||||
class PTZMetricsTypes(TypedDict):
|
||||
@@ -35,6 +37,8 @@ class PTZMetricsTypes(TypedDict):
|
||||
ptz_stop_time: Synchronized
|
||||
ptz_frame_time: Synchronized
|
||||
ptz_zoom_level: Synchronized
|
||||
ptz_max_zoom: Synchronized
|
||||
ptz_min_zoom: Synchronized
|
||||
|
||||
|
||||
class FeatureMetricsTypes(TypedDict):
|
||||
|
@@ -14,6 +14,7 @@ import numpy as np
|
||||
import pytz
|
||||
import yaml
|
||||
from ruamel.yaml import YAML
|
||||
from tzlocal import get_localzone
|
||||
|
||||
from frigate.const import REGEX_HTTP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS
|
||||
|
||||
@@ -262,3 +263,10 @@ def find_by_key(dictionary, target_key):
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def get_tomorrow_at_2() -> datetime.datetime:
|
||||
tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1)
|
||||
return tomorrow.replace(hour=2, minute=0, second=0).astimezone(
|
||||
datetime.timezone.utc
|
||||
)
|
||||
|
546
frigate/util/object.py
Normal file
546
frigate/util/object.py
Normal file
@@ -0,0 +1,546 @@
|
||||
"""Utils for reading and writing object detection data."""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import math
|
||||
from collections import defaultdict
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from frigate.config import DetectConfig, ModelConfig
|
||||
from frigate.const import LABEL_CONSOLIDATION_DEFAULT, LABEL_CONSOLIDATION_MAP
|
||||
from frigate.detectors.detector_config import PixelFormatEnum
|
||||
from frigate.models import Event, Regions, Timeline
|
||||
from frigate.util.image import (
|
||||
area,
|
||||
calculate_region,
|
||||
clipped,
|
||||
intersection,
|
||||
intersection_over_union,
|
||||
yuv_region_2_bgr,
|
||||
yuv_region_2_rgb,
|
||||
yuv_region_2_yuv,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GRID_SIZE = 8
|
||||
|
||||
|
||||
def get_camera_regions_grid(
|
||||
name: str, detect: DetectConfig
|
||||
) -> list[list[dict[str, any]]]:
|
||||
"""Build a grid of expected region sizes for a camera."""
|
||||
# get grid from db if available
|
||||
try:
|
||||
regions: Regions = Regions.select().where(Regions.camera == name).get()
|
||||
grid = regions.grid
|
||||
last_update = regions.last_update
|
||||
except DoesNotExist:
|
||||
grid = []
|
||||
for x in range(GRID_SIZE):
|
||||
row = []
|
||||
for y in range(GRID_SIZE):
|
||||
row.append({"sizes": []})
|
||||
grid.append(row)
|
||||
last_update = 0
|
||||
|
||||
# get events for timeline entries
|
||||
events = (
|
||||
Event.select(Event.id)
|
||||
.where(Event.camera == name)
|
||||
.where((Event.false_positive == None) | (Event.false_positive == False))
|
||||
.where(Event.start_time > last_update)
|
||||
)
|
||||
valid_event_ids = [e["id"] for e in events.dicts()]
|
||||
logger.debug(f"Found {len(valid_event_ids)} new events for {name}")
|
||||
|
||||
# no new events, return as is
|
||||
if not valid_event_ids:
|
||||
return grid
|
||||
|
||||
new_update = datetime.datetime.now().timestamp()
|
||||
timeline = (
|
||||
Timeline.select(
|
||||
*[
|
||||
Timeline.camera,
|
||||
Timeline.source,
|
||||
Timeline.data,
|
||||
]
|
||||
)
|
||||
.where(Timeline.source_id << valid_event_ids)
|
||||
.limit(10000)
|
||||
.dicts()
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(timeline)} new entries for {name}")
|
||||
|
||||
width = detect.width
|
||||
height = detect.height
|
||||
|
||||
for t in timeline:
|
||||
if t.get("source") != "tracked_object":
|
||||
continue
|
||||
|
||||
box = t["data"]["box"]
|
||||
|
||||
# calculate centroid position
|
||||
x = box[0] + (box[2] / 2)
|
||||
y = box[1] + (box[3] / 2)
|
||||
|
||||
x_pos = int(x * GRID_SIZE)
|
||||
y_pos = int(y * GRID_SIZE)
|
||||
|
||||
calculated_region = calculate_region(
|
||||
(height, width),
|
||||
box[0] * width,
|
||||
box[1] * height,
|
||||
(box[0] + box[2]) * width,
|
||||
(box[1] + box[3]) * height,
|
||||
320,
|
||||
1.35,
|
||||
)
|
||||
# save width of region to grid as relative
|
||||
grid[x_pos][y_pos]["sizes"].append(
|
||||
(calculated_region[2] - calculated_region[0]) / width
|
||||
)
|
||||
|
||||
for x in range(GRID_SIZE):
|
||||
for y in range(GRID_SIZE):
|
||||
cell = grid[x][y]
|
||||
|
||||
if len(cell["sizes"]) == 0:
|
||||
continue
|
||||
|
||||
std_dev = np.std(cell["sizes"])
|
||||
mean = np.mean(cell["sizes"])
|
||||
logger.debug(f"std dev: {std_dev} mean: {mean}")
|
||||
cell["x"] = x
|
||||
cell["y"] = y
|
||||
cell["std_dev"] = std_dev
|
||||
cell["mean"] = mean
|
||||
|
||||
# update db with new grid
|
||||
region = {
|
||||
Regions.camera: name,
|
||||
Regions.grid: grid,
|
||||
Regions.last_update: new_update,
|
||||
}
|
||||
(
|
||||
Regions.insert(region)
|
||||
.on_conflict(
|
||||
conflict_target=[Regions.camera],
|
||||
update=region,
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
|
||||
return grid
|
||||
|
||||
|
||||
def get_cluster_region_from_grid(frame_shape, min_region, cluster, boxes, region_grid):
|
||||
min_x = frame_shape[1]
|
||||
min_y = frame_shape[0]
|
||||
max_x = 0
|
||||
max_y = 0
|
||||
for b in cluster:
|
||||
min_x = min(boxes[b][0], min_x)
|
||||
min_y = min(boxes[b][1], min_y)
|
||||
max_x = max(boxes[b][2], max_x)
|
||||
max_y = max(boxes[b][3], max_y)
|
||||
return get_region_from_grid(
|
||||
frame_shape, [min_x, min_y, max_x, max_y], min_region, region_grid
|
||||
)
|
||||
|
||||
|
||||
def get_region_from_grid(
|
||||
frame_shape: tuple[int],
|
||||
cluster: list[int],
|
||||
min_region: int,
|
||||
region_grid: list[list[dict[str, any]]],
|
||||
) -> list[int]:
|
||||
"""Get a region for a box based on the region grid."""
|
||||
box = calculate_region(
|
||||
frame_shape, cluster[0], cluster[1], cluster[2], cluster[3], min_region
|
||||
)
|
||||
centroid = (
|
||||
box[0] + (min(frame_shape[1], box[2]) - box[0]) / 2,
|
||||
box[1] + (min(frame_shape[0], box[3]) - box[1]) / 2,
|
||||
)
|
||||
grid_x = int(centroid[0] / frame_shape[1] * GRID_SIZE)
|
||||
grid_y = int(centroid[1] / frame_shape[0] * GRID_SIZE)
|
||||
|
||||
cell = region_grid[grid_x][grid_y]
|
||||
|
||||
# if there is no known data, get standard region for motion box
|
||||
if not cell or not cell["sizes"]:
|
||||
return calculate_region(frame_shape, box[0], box[1], box[2], box[3], min_region)
|
||||
|
||||
# convert the calculated region size to relative
|
||||
calc_size = (box[2] - box[0]) / frame_shape[1]
|
||||
|
||||
# if region is within expected size, don't resize
|
||||
if (
|
||||
(cell["mean"] - cell["std_dev"])
|
||||
<= calc_size
|
||||
<= (cell["mean"] + cell["std_dev"])
|
||||
):
|
||||
return box
|
||||
# TODO not sure how to handle case where cluster is larger than expected region
|
||||
elif calc_size > (cell["mean"] + cell["std_dev"]):
|
||||
return box
|
||||
|
||||
size = cell["mean"] * frame_shape[1]
|
||||
|
||||
# get region based on grid size
|
||||
return calculate_region(
|
||||
frame_shape,
|
||||
max(0, centroid[0] - size / 2),
|
||||
max(0, centroid[1] - size / 2),
|
||||
min(frame_shape[1], centroid[0] + size / 2),
|
||||
min(frame_shape[0], centroid[1] + size / 2),
|
||||
min_region,
|
||||
)
|
||||
|
||||
|
||||
def is_object_filtered(obj, objects_to_track, object_filters):
|
||||
object_name = obj[0]
|
||||
object_score = obj[1]
|
||||
object_box = obj[2]
|
||||
object_area = obj[3]
|
||||
object_ratio = obj[4]
|
||||
|
||||
if object_name not in objects_to_track:
|
||||
return True
|
||||
|
||||
if object_name in object_filters:
|
||||
obj_settings = object_filters[object_name]
|
||||
|
||||
# if the min area is larger than the
|
||||
# detected object, don't add it to detected objects
|
||||
if obj_settings.min_area > object_area:
|
||||
return True
|
||||
|
||||
# if the detected object is larger than the
|
||||
# max area, don't add it to detected objects
|
||||
if obj_settings.max_area < object_area:
|
||||
return True
|
||||
|
||||
# if the score is lower than the min_score, skip
|
||||
if obj_settings.min_score > object_score:
|
||||
return True
|
||||
|
||||
# if the object is not proportionally wide enough
|
||||
if obj_settings.min_ratio > object_ratio:
|
||||
return True
|
||||
|
||||
# if the object is proportionally too wide
|
||||
if obj_settings.max_ratio < object_ratio:
|
||||
return True
|
||||
|
||||
if obj_settings.mask is not None:
|
||||
# compute the coordinates of the object and make sure
|
||||
# the location isn't outside the bounds of the image (can happen from rounding)
|
||||
object_xmin = object_box[0]
|
||||
object_xmax = object_box[2]
|
||||
object_ymax = object_box[3]
|
||||
y_location = min(int(object_ymax), len(obj_settings.mask) - 1)
|
||||
x_location = min(
|
||||
int((object_xmax + object_xmin) / 2.0),
|
||||
len(obj_settings.mask[0]) - 1,
|
||||
)
|
||||
|
||||
# if the object is in a masked location, don't add it to detected objects
|
||||
if obj_settings.mask[y_location][x_location] == 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_min_region_size(model_config: ModelConfig) -> int:
|
||||
"""Get the min region size."""
|
||||
return max(model_config.height, model_config.width)
|
||||
|
||||
|
||||
def create_tensor_input(frame, model_config: ModelConfig, region):
|
||||
if model_config.input_pixel_format == PixelFormatEnum.rgb:
|
||||
cropped_frame = yuv_region_2_rgb(frame, region)
|
||||
elif model_config.input_pixel_format == PixelFormatEnum.bgr:
|
||||
cropped_frame = yuv_region_2_bgr(frame, region)
|
||||
else:
|
||||
cropped_frame = yuv_region_2_yuv(frame, region)
|
||||
|
||||
# Resize if needed
|
||||
if cropped_frame.shape != (model_config.height, model_config.width, 3):
|
||||
cropped_frame = cv2.resize(
|
||||
cropped_frame,
|
||||
dsize=(model_config.width, model_config.height),
|
||||
interpolation=cv2.INTER_LINEAR,
|
||||
)
|
||||
|
||||
# Expand dimensions since the model expects images to have shape: [1, height, width, 3]
|
||||
return np.expand_dims(cropped_frame, axis=0)
|
||||
|
||||
|
||||
def box_overlaps(b1, b2):
|
||||
if b1[2] < b2[0] or b1[0] > b2[2] or b1[1] > b2[3] or b1[3] < b2[1]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def box_inside(b1, b2):
|
||||
# check if b2 is inside b1
|
||||
if b2[0] >= b1[0] and b2[1] >= b1[1] and b2[2] <= b1[2] and b2[3] <= b1[3]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def reduce_boxes(boxes, iou_threshold=0.0):
|
||||
clusters = []
|
||||
|
||||
for box in boxes:
|
||||
matched = 0
|
||||
for cluster in clusters:
|
||||
if intersection_over_union(box, cluster) > iou_threshold:
|
||||
matched = 1
|
||||
cluster[0] = min(cluster[0], box[0])
|
||||
cluster[1] = min(cluster[1], box[1])
|
||||
cluster[2] = max(cluster[2], box[2])
|
||||
cluster[3] = max(cluster[3], box[3])
|
||||
|
||||
if not matched:
|
||||
clusters.append(list(box))
|
||||
|
||||
return [tuple(c) for c in clusters]
|
||||
|
||||
|
||||
def intersects_any(box_a, boxes):
|
||||
for box in boxes:
|
||||
if box_overlaps(box_a, box):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def inside_any(box_a, boxes):
|
||||
for box in boxes:
|
||||
# check if box_a is inside of box
|
||||
if box_inside(box, box_a):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_cluster_boundary(box, min_region):
|
||||
# compute the max region size for the current box (box is 10% of region)
|
||||
box_width = box[2] - box[0]
|
||||
box_height = box[3] - box[1]
|
||||
max_region_area = abs(box_width * box_height) / 0.1
|
||||
max_region_size = max(min_region, int(math.sqrt(max_region_area)))
|
||||
|
||||
centroid = (box_width / 2 + box[0], box_height / 2 + box[1])
|
||||
|
||||
max_x_dist = int(max_region_size - box_width / 2 * 1.1)
|
||||
max_y_dist = int(max_region_size - box_height / 2 * 1.1)
|
||||
|
||||
return [
|
||||
int(centroid[0] - max_x_dist),
|
||||
int(centroid[1] - max_y_dist),
|
||||
int(centroid[0] + max_x_dist),
|
||||
int(centroid[1] + max_y_dist),
|
||||
]
|
||||
|
||||
|
||||
def get_cluster_candidates(frame_shape, min_region, boxes):
|
||||
# and create a cluster of other boxes using it's max region size
|
||||
# only include boxes where the region is an appropriate(except the region could possibly be smaller?)
|
||||
# size in the cluster. in order to be in the cluster, the furthest corner needs to be within x,y offset
|
||||
# determined by the max_region size minus half the box + 20%
|
||||
# TODO: see if we can do this with numpy
|
||||
cluster_candidates = []
|
||||
used_boxes = []
|
||||
# loop over each box
|
||||
for current_index, b in enumerate(boxes):
|
||||
if current_index in used_boxes:
|
||||
continue
|
||||
cluster = [current_index]
|
||||
used_boxes.append(current_index)
|
||||
cluster_boundary = get_cluster_boundary(b, min_region)
|
||||
# find all other boxes that fit inside the boundary
|
||||
for compare_index, compare_box in enumerate(boxes):
|
||||
if compare_index in used_boxes:
|
||||
continue
|
||||
|
||||
# if the box is not inside the potential cluster area, cluster them
|
||||
if not box_inside(cluster_boundary, compare_box):
|
||||
continue
|
||||
|
||||
# get the region if you were to add this box to the cluster
|
||||
potential_cluster = cluster + [compare_index]
|
||||
cluster_region = get_cluster_region(
|
||||
frame_shape, min_region, potential_cluster, boxes
|
||||
)
|
||||
# if region could be smaller and either box would be too small
|
||||
# for the resulting region, dont cluster
|
||||
should_cluster = True
|
||||
if (cluster_region[2] - cluster_region[0]) > min_region:
|
||||
for b in potential_cluster:
|
||||
box = boxes[b]
|
||||
# boxes should be more than 5% of the area of the region
|
||||
if area(box) / area(cluster_region) < 0.05:
|
||||
should_cluster = False
|
||||
break
|
||||
|
||||
if should_cluster:
|
||||
cluster.append(compare_index)
|
||||
used_boxes.append(compare_index)
|
||||
cluster_candidates.append(cluster)
|
||||
|
||||
# return the unique clusters only
|
||||
unique = {tuple(sorted(c)) for c in cluster_candidates}
|
||||
return [list(tup) for tup in unique]
|
||||
|
||||
|
||||
def get_cluster_region(frame_shape, min_region, cluster, boxes):
|
||||
min_x = frame_shape[1]
|
||||
min_y = frame_shape[0]
|
||||
max_x = 0
|
||||
max_y = 0
|
||||
for b in cluster:
|
||||
min_x = min(boxes[b][0], min_x)
|
||||
min_y = min(boxes[b][1], min_y)
|
||||
max_x = max(boxes[b][2], max_x)
|
||||
max_y = max(boxes[b][3], max_y)
|
||||
return calculate_region(
|
||||
frame_shape, min_x, min_y, max_x, max_y, min_region, multiplier=1.2
|
||||
)
|
||||
|
||||
|
||||
def get_startup_regions(
|
||||
frame_shape: tuple[int],
|
||||
region_min_size: int,
|
||||
region_grid: list[list[dict[str, any]]],
|
||||
) -> list[list[int]]:
|
||||
"""Get a list of regions to run on startup."""
|
||||
# return 8 most popular regions for the camera
|
||||
all_cells = np.concatenate(region_grid).flat
|
||||
startup_cells = sorted(all_cells, key=lambda c: len(c["sizes"]), reverse=True)[0:8]
|
||||
regions = []
|
||||
|
||||
for cell in startup_cells:
|
||||
# rest of the cells are empty
|
||||
if not cell["sizes"]:
|
||||
break
|
||||
|
||||
x = frame_shape[1] / GRID_SIZE * (0.5 + cell["x"])
|
||||
y = frame_shape[0] / GRID_SIZE * (0.5 + cell["y"])
|
||||
size = cell["mean"] * frame_shape[1]
|
||||
regions.append(
|
||||
calculate_region(
|
||||
frame_shape,
|
||||
x - size / 2,
|
||||
y - size / 2,
|
||||
x + size / 2,
|
||||
y + size / 2,
|
||||
region_min_size,
|
||||
multiplier=1,
|
||||
)
|
||||
)
|
||||
|
||||
return regions
|
||||
|
||||
|
||||
def reduce_detections(
|
||||
frame_shape: tuple[int],
|
||||
all_detections: list[tuple[any]],
|
||||
) -> list[tuple[any]]:
|
||||
"""Take a list of detections and reduce overlaps to create a list of confident detections."""
|
||||
|
||||
def reduce_overlapping_detections(detections: list[tuple[any]]) -> list[tuple[any]]:
|
||||
"""apply non-maxima suppression to suppress weak, overlapping bounding boxes."""
|
||||
detected_object_groups = defaultdict(lambda: [])
|
||||
for detection in detections:
|
||||
detected_object_groups[detection[0]].append(detection)
|
||||
|
||||
selected_objects = []
|
||||
for group in detected_object_groups.values():
|
||||
# o[2] is the box of the object: xmin, ymin, xmax, ymax
|
||||
# apply max/min to ensure values do not exceed the known frame size
|
||||
boxes = [
|
||||
(
|
||||
o[2][0],
|
||||
o[2][1],
|
||||
o[2][2] - o[2][0],
|
||||
o[2][3] - o[2][1],
|
||||
)
|
||||
for o in group
|
||||
]
|
||||
|
||||
# reduce confidences for objects that are on edge of region
|
||||
# 0.6 should be used to ensure that the object is still considered and not dropped
|
||||
# due to min score requirement of NMSBoxes
|
||||
confidences = [0.6 if clipped(o, frame_shape) else o[1] for o in group]
|
||||
|
||||
idxs = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4)
|
||||
|
||||
# add objects
|
||||
for index in idxs:
|
||||
index = index if isinstance(index, np.int32) else index[0]
|
||||
obj = group[index]
|
||||
selected_objects.append(obj)
|
||||
|
||||
# set the detections list to only include top objects
|
||||
return selected_objects
|
||||
|
||||
def get_consolidated_object_detections(detections: list[tuple[any]]):
|
||||
"""Drop detections that overlap too much."""
|
||||
detected_object_groups = defaultdict(lambda: [])
|
||||
for detection in detections:
|
||||
detected_object_groups[detection[0]].append(detection)
|
||||
|
||||
consolidated_detections = []
|
||||
for group in detected_object_groups.values():
|
||||
# if the group only has 1 item, skip
|
||||
if len(group) == 1:
|
||||
consolidated_detections.append(group[0])
|
||||
continue
|
||||
|
||||
# sort smallest to largest by area
|
||||
sorted_by_area = sorted(group, key=lambda g: g[3])
|
||||
|
||||
for current_detection_idx in range(0, len(sorted_by_area)):
|
||||
current_detection = sorted_by_area[current_detection_idx]
|
||||
current_label = current_detection[0]
|
||||
current_box = current_detection[2]
|
||||
overlap = 0
|
||||
for to_check_idx in range(
|
||||
min(current_detection_idx + 1, len(sorted_by_area)),
|
||||
len(sorted_by_area),
|
||||
):
|
||||
to_check = sorted_by_area[to_check_idx][2]
|
||||
|
||||
# if area of current detection / area of check < 5% they should not be compared
|
||||
# this covers cases where a large car parked in a driveway doesn't block detections
|
||||
# of cars in the street behind it
|
||||
if area(current_box) / area(to_check) < 0.05:
|
||||
continue
|
||||
|
||||
intersect_box = intersection(current_box, to_check)
|
||||
# if % of smaller detection is inside of another detection, consolidate
|
||||
if intersect_box is not None and area(intersect_box) / area(
|
||||
current_box
|
||||
) > LABEL_CONSOLIDATION_MAP.get(
|
||||
current_label, LABEL_CONSOLIDATION_DEFAULT
|
||||
):
|
||||
overlap = 1
|
||||
break
|
||||
if overlap == 0:
|
||||
consolidated_detections.append(
|
||||
sorted_by_area[current_detection_idx]
|
||||
)
|
||||
|
||||
return consolidated_detections
|
||||
|
||||
return get_consolidated_object_detections(
|
||||
reduce_overlapping_detections(all_detections)
|
||||
)
|
449
frigate/video.py
449
frigate/video.py
@@ -1,6 +1,5 @@
|
||||
import datetime
|
||||
import logging
|
||||
import math
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import queue
|
||||
@@ -8,119 +7,49 @@ import signal
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from setproctitle import setproctitle
|
||||
|
||||
from frigate.config import CameraConfig, DetectConfig, ModelConfig
|
||||
from frigate.const import ALL_ATTRIBUTE_LABELS, ATTRIBUTE_LABEL_MAP, CACHE_DIR
|
||||
from frigate.detectors.detector_config import PixelFormatEnum
|
||||
from frigate.const import (
|
||||
ALL_ATTRIBUTE_LABELS,
|
||||
ATTRIBUTE_LABEL_MAP,
|
||||
CACHE_DIR,
|
||||
REQUEST_REGION_GRID,
|
||||
)
|
||||
from frigate.log import LogPipe
|
||||
from frigate.motion import MotionDetector
|
||||
from frigate.motion.improved_motion import ImprovedMotionDetector
|
||||
from frigate.object_detection import RemoteObjectDetector
|
||||
from frigate.ptz.autotrack import ptz_moving_at_frame_time
|
||||
from frigate.track import ObjectTracker
|
||||
from frigate.track.norfair_tracker import NorfairTracker
|
||||
from frigate.types import PTZMetricsTypes
|
||||
from frigate.util.builtin import EventsPerSecond
|
||||
from frigate.util.builtin import EventsPerSecond, get_tomorrow_at_2
|
||||
from frigate.util.image import (
|
||||
FrameManager,
|
||||
SharedMemoryFrameManager,
|
||||
area,
|
||||
calculate_region,
|
||||
draw_box_with_label,
|
||||
intersection,
|
||||
intersection_over_union,
|
||||
yuv_region_2_bgr,
|
||||
yuv_region_2_rgb,
|
||||
yuv_region_2_yuv,
|
||||
)
|
||||
from frigate.util.object import (
|
||||
box_inside,
|
||||
create_tensor_input,
|
||||
get_cluster_candidates,
|
||||
get_cluster_region,
|
||||
get_cluster_region_from_grid,
|
||||
get_min_region_size,
|
||||
get_startup_regions,
|
||||
inside_any,
|
||||
intersects_any,
|
||||
is_object_filtered,
|
||||
reduce_detections,
|
||||
)
|
||||
from frigate.util.services import listen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def filtered(obj, objects_to_track, object_filters):
|
||||
object_name = obj[0]
|
||||
object_score = obj[1]
|
||||
object_box = obj[2]
|
||||
object_area = obj[3]
|
||||
object_ratio = obj[4]
|
||||
|
||||
if object_name not in objects_to_track:
|
||||
return True
|
||||
|
||||
if object_name in object_filters:
|
||||
obj_settings = object_filters[object_name]
|
||||
|
||||
# if the min area is larger than the
|
||||
# detected object, don't add it to detected objects
|
||||
if obj_settings.min_area > object_area:
|
||||
return True
|
||||
|
||||
# if the detected object is larger than the
|
||||
# max area, don't add it to detected objects
|
||||
if obj_settings.max_area < object_area:
|
||||
return True
|
||||
|
||||
# if the score is lower than the min_score, skip
|
||||
if obj_settings.min_score > object_score:
|
||||
return True
|
||||
|
||||
# if the object is not proportionally wide enough
|
||||
if obj_settings.min_ratio > object_ratio:
|
||||
return True
|
||||
|
||||
# if the object is proportionally too wide
|
||||
if obj_settings.max_ratio < object_ratio:
|
||||
return True
|
||||
|
||||
if obj_settings.mask is not None:
|
||||
# compute the coordinates of the object and make sure
|
||||
# the location isn't outside the bounds of the image (can happen from rounding)
|
||||
object_xmin = object_box[0]
|
||||
object_xmax = object_box[2]
|
||||
object_ymax = object_box[3]
|
||||
y_location = min(int(object_ymax), len(obj_settings.mask) - 1)
|
||||
x_location = min(
|
||||
int((object_xmax + object_xmin) / 2.0),
|
||||
len(obj_settings.mask[0]) - 1,
|
||||
)
|
||||
|
||||
# if the object is in a masked location, don't add it to detected objects
|
||||
if obj_settings.mask[y_location][x_location] == 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_min_region_size(model_config: ModelConfig) -> int:
|
||||
"""Get the min region size."""
|
||||
return max(model_config.height, model_config.width)
|
||||
|
||||
|
||||
def create_tensor_input(frame, model_config: ModelConfig, region):
|
||||
if model_config.input_pixel_format == PixelFormatEnum.rgb:
|
||||
cropped_frame = yuv_region_2_rgb(frame, region)
|
||||
elif model_config.input_pixel_format == PixelFormatEnum.bgr:
|
||||
cropped_frame = yuv_region_2_bgr(frame, region)
|
||||
else:
|
||||
cropped_frame = yuv_region_2_yuv(frame, region)
|
||||
|
||||
# Resize if needed
|
||||
if cropped_frame.shape != (model_config.height, model_config.width, 3):
|
||||
cropped_frame = cv2.resize(
|
||||
cropped_frame,
|
||||
dsize=(model_config.width, model_config.height),
|
||||
interpolation=cv2.INTER_LINEAR,
|
||||
)
|
||||
|
||||
# Expand dimensions since the model expects images to have shape: [1, height, width, 3]
|
||||
return np.expand_dims(cropped_frame, axis=0)
|
||||
|
||||
|
||||
def stop_ffmpeg(ffmpeg_process, logger):
|
||||
logger.info("Terminating the existing ffmpeg process...")
|
||||
ffmpeg_process.terminate()
|
||||
@@ -455,8 +384,10 @@ def track_camera(
|
||||
detection_queue,
|
||||
result_connection,
|
||||
detected_objects_queue,
|
||||
inter_process_queue,
|
||||
process_info,
|
||||
ptz_metrics,
|
||||
region_grid,
|
||||
):
|
||||
stop_event = mp.Event()
|
||||
|
||||
@@ -471,6 +402,7 @@ def track_camera(
|
||||
listen()
|
||||
|
||||
frame_queue = process_info["frame_queue"]
|
||||
region_grid_queue = process_info["region_grid_queue"]
|
||||
detection_enabled = process_info["detection_enabled"]
|
||||
motion_enabled = process_info["motion_enabled"]
|
||||
improve_contrast_enabled = process_info["improve_contrast_enabled"]
|
||||
@@ -499,7 +431,9 @@ def track_camera(
|
||||
|
||||
process_frames(
|
||||
name,
|
||||
inter_process_queue,
|
||||
frame_queue,
|
||||
region_grid_queue,
|
||||
frame_shape,
|
||||
model_config,
|
||||
config.detect,
|
||||
@@ -515,50 +449,12 @@ def track_camera(
|
||||
motion_enabled,
|
||||
stop_event,
|
||||
ptz_metrics,
|
||||
region_grid,
|
||||
)
|
||||
|
||||
logger.info(f"{name}: exiting subprocess")
|
||||
|
||||
|
||||
def box_overlaps(b1, b2):
|
||||
if b1[2] < b2[0] or b1[0] > b2[2] or b1[1] > b2[3] or b1[3] < b2[1]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def box_inside(b1, b2):
|
||||
# check if b2 is inside b1
|
||||
if b2[0] >= b1[0] and b2[1] >= b1[1] and b2[2] <= b1[2] and b2[3] <= b1[3]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def reduce_boxes(boxes, iou_threshold=0.0):
|
||||
clusters = []
|
||||
|
||||
for box in boxes:
|
||||
matched = 0
|
||||
for cluster in clusters:
|
||||
if intersection_over_union(box, cluster) > iou_threshold:
|
||||
matched = 1
|
||||
cluster[0] = min(cluster[0], box[0])
|
||||
cluster[1] = min(cluster[1], box[1])
|
||||
cluster[2] = max(cluster[2], box[2])
|
||||
cluster[3] = max(cluster[3], box[3])
|
||||
|
||||
if not matched:
|
||||
clusters.append(list(box))
|
||||
|
||||
return [tuple(c) for c in clusters]
|
||||
|
||||
|
||||
def intersects_any(box_a, boxes):
|
||||
for box in boxes:
|
||||
if box_overlaps(box_a, box):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def detect(
|
||||
detect_config: DetectConfig,
|
||||
object_detector,
|
||||
@@ -597,134 +493,17 @@ def detect(
|
||||
region,
|
||||
)
|
||||
# apply object filters
|
||||
if filtered(det, objects_to_track, object_filters):
|
||||
if is_object_filtered(det, objects_to_track, object_filters):
|
||||
continue
|
||||
detections.append(det)
|
||||
return detections
|
||||
|
||||
|
||||
def get_cluster_boundary(box, min_region):
|
||||
# compute the max region size for the current box (box is 10% of region)
|
||||
box_width = box[2] - box[0]
|
||||
box_height = box[3] - box[1]
|
||||
max_region_area = abs(box_width * box_height) / 0.1
|
||||
max_region_size = max(min_region, int(math.sqrt(max_region_area)))
|
||||
|
||||
centroid = (box_width / 2 + box[0], box_height / 2 + box[1])
|
||||
|
||||
max_x_dist = int(max_region_size - box_width / 2 * 1.1)
|
||||
max_y_dist = int(max_region_size - box_height / 2 * 1.1)
|
||||
|
||||
return [
|
||||
int(centroid[0] - max_x_dist),
|
||||
int(centroid[1] - max_y_dist),
|
||||
int(centroid[0] + max_x_dist),
|
||||
int(centroid[1] + max_y_dist),
|
||||
]
|
||||
|
||||
|
||||
def get_cluster_candidates(frame_shape, min_region, boxes):
|
||||
# and create a cluster of other boxes using it's max region size
|
||||
# only include boxes where the region is an appropriate(except the region could possibly be smaller?)
|
||||
# size in the cluster. in order to be in the cluster, the furthest corner needs to be within x,y offset
|
||||
# determined by the max_region size minus half the box + 20%
|
||||
# TODO: see if we can do this with numpy
|
||||
cluster_candidates = []
|
||||
used_boxes = []
|
||||
# loop over each box
|
||||
for current_index, b in enumerate(boxes):
|
||||
if current_index in used_boxes:
|
||||
continue
|
||||
cluster = [current_index]
|
||||
used_boxes.append(current_index)
|
||||
cluster_boundary = get_cluster_boundary(b, min_region)
|
||||
# find all other boxes that fit inside the boundary
|
||||
for compare_index, compare_box in enumerate(boxes):
|
||||
if compare_index in used_boxes:
|
||||
continue
|
||||
|
||||
# if the box is not inside the potential cluster area, cluster them
|
||||
if not box_inside(cluster_boundary, compare_box):
|
||||
continue
|
||||
|
||||
# get the region if you were to add this box to the cluster
|
||||
potential_cluster = cluster + [compare_index]
|
||||
cluster_region = get_cluster_region(
|
||||
frame_shape, min_region, potential_cluster, boxes
|
||||
)
|
||||
# if region could be smaller and either box would be too small
|
||||
# for the resulting region, dont cluster
|
||||
should_cluster = True
|
||||
if (cluster_region[2] - cluster_region[0]) > min_region:
|
||||
for b in potential_cluster:
|
||||
box = boxes[b]
|
||||
# boxes should be more than 5% of the area of the region
|
||||
if area(box) / area(cluster_region) < 0.05:
|
||||
should_cluster = False
|
||||
break
|
||||
|
||||
if should_cluster:
|
||||
cluster.append(compare_index)
|
||||
used_boxes.append(compare_index)
|
||||
cluster_candidates.append(cluster)
|
||||
|
||||
# return the unique clusters only
|
||||
unique = {tuple(sorted(c)) for c in cluster_candidates}
|
||||
return [list(tup) for tup in unique]
|
||||
|
||||
|
||||
def get_cluster_region(frame_shape, min_region, cluster, boxes):
|
||||
min_x = frame_shape[1]
|
||||
min_y = frame_shape[0]
|
||||
max_x = 0
|
||||
max_y = 0
|
||||
for b in cluster:
|
||||
min_x = min(boxes[b][0], min_x)
|
||||
min_y = min(boxes[b][1], min_y)
|
||||
max_x = max(boxes[b][2], max_x)
|
||||
max_y = max(boxes[b][3], max_y)
|
||||
return calculate_region(
|
||||
frame_shape, min_x, min_y, max_x, max_y, min_region, multiplier=1.2
|
||||
)
|
||||
|
||||
|
||||
def get_consolidated_object_detections(detected_object_groups):
|
||||
"""Drop detections that overlap too much"""
|
||||
consolidated_detections = []
|
||||
for group in detected_object_groups.values():
|
||||
# if the group only has 1 item, skip
|
||||
if len(group) == 1:
|
||||
consolidated_detections.append(group[0])
|
||||
continue
|
||||
|
||||
# sort smallest to largest by area
|
||||
sorted_by_area = sorted(group, key=lambda g: g[3])
|
||||
|
||||
for current_detection_idx in range(0, len(sorted_by_area)):
|
||||
current_detection = sorted_by_area[current_detection_idx][2]
|
||||
overlap = 0
|
||||
for to_check_idx in range(
|
||||
min(current_detection_idx + 1, len(sorted_by_area)),
|
||||
len(sorted_by_area),
|
||||
):
|
||||
to_check = sorted_by_area[to_check_idx][2]
|
||||
intersect_box = intersection(current_detection, to_check)
|
||||
# if 90% of smaller detection is inside of another detection, consolidate
|
||||
if (
|
||||
intersect_box is not None
|
||||
and area(intersect_box) / area(current_detection) > 0.9
|
||||
):
|
||||
overlap = 1
|
||||
break
|
||||
if overlap == 0:
|
||||
consolidated_detections.append(sorted_by_area[current_detection_idx])
|
||||
|
||||
return consolidated_detections
|
||||
|
||||
|
||||
def process_frames(
|
||||
camera_name: str,
|
||||
inter_process_queue: mp.Queue,
|
||||
frame_queue: mp.Queue,
|
||||
region_grid_queue: mp.Queue,
|
||||
frame_shape,
|
||||
model_config: ModelConfig,
|
||||
detect_config: DetectConfig,
|
||||
@@ -740,20 +519,36 @@ def process_frames(
|
||||
motion_enabled: mp.Value,
|
||||
stop_event,
|
||||
ptz_metrics: PTZMetricsTypes,
|
||||
region_grid,
|
||||
exit_on_empty: bool = False,
|
||||
):
|
||||
fps = process_info["process_fps"]
|
||||
detection_fps = process_info["detection_fps"]
|
||||
current_frame_time = process_info["detection_frame"]
|
||||
next_region_update = get_tomorrow_at_2()
|
||||
|
||||
fps_tracker = EventsPerSecond()
|
||||
fps_tracker.start()
|
||||
|
||||
startup_scan_counter = 0
|
||||
startup_scan = True
|
||||
stationary_frame_counter = 0
|
||||
|
||||
region_min_size = get_min_region_size(model_config)
|
||||
|
||||
while not stop_event.is_set():
|
||||
if (
|
||||
datetime.datetime.now().astimezone(datetime.timezone.utc)
|
||||
> next_region_update
|
||||
):
|
||||
inter_process_queue.put((REQUEST_REGION_GRID, camera_name))
|
||||
|
||||
try:
|
||||
region_grid = region_grid_queue.get(True, 10)
|
||||
except queue.Empty:
|
||||
logger.error(f"Unable to get updated region grid for {camera_name}")
|
||||
|
||||
next_region_update = get_tomorrow_at_2()
|
||||
|
||||
try:
|
||||
if exit_on_empty:
|
||||
frame_time = frame_queue.get(False)
|
||||
@@ -790,65 +585,85 @@ def process_frames(
|
||||
# check every Nth frame for stationary objects
|
||||
# disappeared objects are not stationary
|
||||
# also check for overlapping motion boxes
|
||||
stationary_object_ids = [
|
||||
obj["id"]
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
# if it has exceeded the stationary threshold
|
||||
if obj["motionless_count"] >= detect_config.stationary.threshold
|
||||
# and it isn't due for a periodic check
|
||||
and (
|
||||
detect_config.stationary.interval == 0
|
||||
or obj["motionless_count"] % detect_config.stationary.interval != 0
|
||||
)
|
||||
# and it hasn't disappeared
|
||||
and object_tracker.disappeared[obj["id"]] == 0
|
||||
# and it doesn't overlap with any current motion boxes when not calibrating
|
||||
and not intersects_any(
|
||||
obj["box"], [] if motion_detector.is_calibrating() else motion_boxes
|
||||
)
|
||||
]
|
||||
if stationary_frame_counter == detect_config.stationary.interval:
|
||||
stationary_frame_counter = 0
|
||||
stationary_object_ids = []
|
||||
else:
|
||||
stationary_frame_counter += 1
|
||||
stationary_object_ids = [
|
||||
obj["id"]
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
# if it has exceeded the stationary threshold
|
||||
if obj["motionless_count"] >= detect_config.stationary.threshold
|
||||
# and it hasn't disappeared
|
||||
and object_tracker.disappeared[obj["id"]] == 0
|
||||
# and it doesn't overlap with any current motion boxes when not calibrating
|
||||
and not intersects_any(
|
||||
obj["box"],
|
||||
[] if motion_detector.is_calibrating() else motion_boxes,
|
||||
)
|
||||
]
|
||||
|
||||
# get tracked object boxes that aren't stationary
|
||||
tracked_object_boxes = [
|
||||
obj["estimate"]
|
||||
(
|
||||
# use existing object box for stationary objects
|
||||
obj["estimate"]
|
||||
if obj["motionless_count"] < detect_config.stationary.threshold
|
||||
else obj["box"]
|
||||
)
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
if obj["id"] not in stationary_object_ids
|
||||
]
|
||||
object_boxes = tracked_object_boxes + object_tracker.untracked_object_boxes
|
||||
|
||||
combined_boxes = tracked_object_boxes
|
||||
# only add in the motion boxes when not calibrating
|
||||
if not motion_detector.is_calibrating():
|
||||
combined_boxes += motion_boxes
|
||||
|
||||
cluster_candidates = get_cluster_candidates(
|
||||
frame_shape, region_min_size, combined_boxes
|
||||
)
|
||||
|
||||
# get consolidated regions for tracked objects
|
||||
regions = [
|
||||
get_cluster_region(
|
||||
frame_shape, region_min_size, candidate, combined_boxes
|
||||
frame_shape, region_min_size, candidate, object_boxes
|
||||
)
|
||||
for candidate in get_cluster_candidates(
|
||||
frame_shape, region_min_size, object_boxes
|
||||
)
|
||||
for candidate in cluster_candidates
|
||||
]
|
||||
|
||||
# if starting up, get the next startup scan region
|
||||
if startup_scan_counter < 9:
|
||||
ymin = int(frame_shape[0] / 3 * startup_scan_counter / 3)
|
||||
ymax = int(frame_shape[0] / 3 + ymin)
|
||||
xmin = int(frame_shape[1] / 3 * startup_scan_counter / 3)
|
||||
xmax = int(frame_shape[1] / 3 + xmin)
|
||||
regions.append(
|
||||
calculate_region(
|
||||
# only add in the motion boxes when not calibrating and a ptz is not moving via autotracking
|
||||
# ptz_moving_at_frame_time() always returns False for non-autotracking cameras
|
||||
if not motion_detector.is_calibrating() and not ptz_moving_at_frame_time(
|
||||
frame_time,
|
||||
ptz_metrics["ptz_start_time"].value,
|
||||
ptz_metrics["ptz_stop_time"].value,
|
||||
):
|
||||
# find motion boxes that are not inside tracked object regions
|
||||
standalone_motion_boxes = [
|
||||
b for b in motion_boxes if not inside_any(b, regions)
|
||||
]
|
||||
|
||||
if standalone_motion_boxes:
|
||||
motion_clusters = get_cluster_candidates(
|
||||
frame_shape,
|
||||
xmin,
|
||||
ymin,
|
||||
xmax,
|
||||
ymax,
|
||||
region_min_size,
|
||||
multiplier=1.2,
|
||||
standalone_motion_boxes,
|
||||
)
|
||||
)
|
||||
startup_scan_counter += 1
|
||||
motion_regions = [
|
||||
get_cluster_region_from_grid(
|
||||
frame_shape,
|
||||
region_min_size,
|
||||
candidate,
|
||||
standalone_motion_boxes,
|
||||
region_grid,
|
||||
)
|
||||
for candidate in motion_clusters
|
||||
]
|
||||
regions += motion_regions
|
||||
|
||||
# if starting up, get the next startup scan region
|
||||
if startup_scan:
|
||||
for region in get_startup_regions(
|
||||
frame_shape, region_min_size, region_grid
|
||||
):
|
||||
regions.append(region)
|
||||
startup_scan = False
|
||||
|
||||
# resize regions and detect
|
||||
# seed with stationary objects
|
||||
@@ -878,50 +693,10 @@ def process_frames(
|
||||
)
|
||||
)
|
||||
|
||||
#########
|
||||
# merge objects
|
||||
#########
|
||||
# group by name
|
||||
detected_object_groups = defaultdict(lambda: [])
|
||||
for detection in detections:
|
||||
detected_object_groups[detection[0]].append(detection)
|
||||
|
||||
selected_objects = []
|
||||
for group in detected_object_groups.values():
|
||||
# apply non-maxima suppression to suppress weak, overlapping bounding boxes
|
||||
# o[2] is the box of the object: xmin, ymin, xmax, ymax
|
||||
# apply max/min to ensure values do not exceed the known frame size
|
||||
boxes = [
|
||||
(
|
||||
o[2][0],
|
||||
o[2][1],
|
||||
o[2][2] - o[2][0],
|
||||
o[2][3] - o[2][1],
|
||||
)
|
||||
for o in group
|
||||
]
|
||||
confidences = [o[1] for o in group]
|
||||
idxs = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4)
|
||||
|
||||
# add objects
|
||||
for index in idxs:
|
||||
index = index if isinstance(index, np.int32) else index[0]
|
||||
obj = group[index]
|
||||
selected_objects.append(obj)
|
||||
|
||||
# set the detections list to only include top objects
|
||||
detections = selected_objects
|
||||
consolidated_detections = reduce_detections(frame_shape, detections)
|
||||
|
||||
# if detection was run on this frame, consolidate
|
||||
if len(regions) > 0:
|
||||
# group by name
|
||||
detected_object_groups = defaultdict(lambda: [])
|
||||
for detection in detections:
|
||||
detected_object_groups[detection[0]].append(detection)
|
||||
|
||||
consolidated_detections = get_consolidated_object_detections(
|
||||
detected_object_groups
|
||||
)
|
||||
tracked_detections = [
|
||||
d
|
||||
for d in consolidated_detections
|
||||
|
35
migrations/019_create_regions_table.py
Normal file
35
migrations/019_create_regions_table.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Peewee migrations -- 019_create_regions_table.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['model_name'] # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.python(func, *args, **kwargs) # Run python code
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
|
||||
"""
|
||||
import peewee as pw
|
||||
|
||||
SQL = pw.SQL
|
||||
|
||||
|
||||
def migrate(migrator, database, fake=False, **kwargs):
|
||||
migrator.sql(
|
||||
'CREATE TABLE IF NOT EXISTS "regions" ("camera" VARCHAR(20) NOT NULL PRIMARY KEY, "last_update" DATETIME NOT NULL, "grid" JSON)'
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator, database, fake=False, **kwargs):
|
||||
pass
|
@@ -86,4 +86,19 @@ export const handlers = [
|
||||
])
|
||||
);
|
||||
}),
|
||||
rest.get(`api/labels`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json([
|
||||
'person',
|
||||
'car',
|
||||
])
|
||||
);
|
||||
}),
|
||||
rest.get(`api/go2rtc`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({"config_path":"/dev/shm/go2rtc.yaml","host":"frigate.yourdomain.local","rtsp":{"listen":"0.0.0.0:8554","default_query":"mp4","PacketSize":0},"version":"1.7.1"})
|
||||
);
|
||||
}),
|
||||
];
|
||||
|
1394
web/package-lock.json
generated
1394
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
||||
"preact-router": "^4.1.0",
|
||||
"react": "npm:@preact/compat@^17.1.2",
|
||||
"react-dom": "npm:@preact/compat@^17.1.2",
|
||||
"react-use-websocket": "^3.0.0",
|
||||
"strftime": "^0.10.1",
|
||||
"swr": "^1.3.0",
|
||||
"video.js": "^8.5.2",
|
||||
@@ -48,6 +49,7 @@
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-vitest-globals": "^1.4.0",
|
||||
"fake-indexeddb": "^4.0.1",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"jsdom": "^22.0.0",
|
||||
"msw": "^1.2.1",
|
||||
"postcss": "^8.4.29",
|
||||
|
@@ -1,10 +1,12 @@
|
||||
/* eslint-disable jest/no-disabled-tests */
|
||||
import { h } from 'preact';
|
||||
import { WS, WsProvider, useWs } from '../ws';
|
||||
import { WS as frigateWS, WsProvider, useWs } from '../ws';
|
||||
import { useCallback, useContext } from 'preact/hooks';
|
||||
import { fireEvent, render, screen } from 'testing-library';
|
||||
import { WS } from 'jest-websocket-mock';
|
||||
|
||||
function Test() {
|
||||
const { state } = useContext(WS);
|
||||
const { state } = useContext(frigateWS);
|
||||
return state.__connected ? (
|
||||
<div data-testid="data">
|
||||
{Object.keys(state).map((key) => (
|
||||
@@ -19,44 +21,32 @@ function Test() {
|
||||
const TEST_URL = 'ws://test-foo:1234/ws';
|
||||
|
||||
describe('WsProvider', () => {
|
||||
let createWebsocket, wsClient;
|
||||
beforeEach(() => {
|
||||
let wsClient, wsServer;
|
||||
beforeEach(async () => {
|
||||
wsClient = {
|
||||
close: vi.fn(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
createWebsocket = vi.fn((url) => {
|
||||
wsClient.args = [url];
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop, _receiver) {
|
||||
return wsClient[prop];
|
||||
},
|
||||
set(_target, prop, value) {
|
||||
wsClient[prop] = typeof value === 'function' ? vi.fn(value) : value;
|
||||
if (prop === 'onopen') {
|
||||
wsClient[prop]();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
wsServer = new WS(TEST_URL);
|
||||
});
|
||||
|
||||
test('connects to the ws server', async () => {
|
||||
afterEach(() => {
|
||||
WS.clean();
|
||||
});
|
||||
|
||||
test.skip('connects to the ws server', async () => {
|
||||
render(
|
||||
<WsProvider config={mockConfig} createWebsocket={createWebsocket} wsUrl={TEST_URL}>
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByTestId('data');
|
||||
expect(wsClient.args).toEqual([TEST_URL]);
|
||||
expect(screen.getByTestId('__connected')).toHaveTextContent('true');
|
||||
});
|
||||
|
||||
test('receives data through useWs', async () => {
|
||||
test.skip('receives data through useWs', async () => {
|
||||
function Test() {
|
||||
const {
|
||||
value: { payload, retain },
|
||||
@@ -71,16 +61,17 @@ describe('WsProvider', () => {
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<WsProvider config={mockConfig} createWebsocket={createWebsocket} wsUrl={TEST_URL}>
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByTestId('payload');
|
||||
wsClient.onmessage({
|
||||
data: JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }), retain: false }),
|
||||
});
|
||||
rerender(
|
||||
<WsProvider config={mockConfig} createWebsocket={createWebsocket} wsUrl={TEST_URL}>
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
@@ -88,7 +79,7 @@ describe('WsProvider', () => {
|
||||
expect(screen.getByTestId('retain')).toHaveTextContent('false');
|
||||
});
|
||||
|
||||
test('can send values through useWs', async () => {
|
||||
test.skip('can send values through useWs', async () => {
|
||||
function Test() {
|
||||
const { send, connected } = useWs('tacos');
|
||||
const handleClick = useCallback(() => {
|
||||
@@ -98,10 +89,11 @@ describe('WsProvider', () => {
|
||||
}
|
||||
|
||||
render(
|
||||
<WsProvider config={mockConfig} createWebsocket={createWebsocket} wsUrl={TEST_URL}>
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByRole('button');
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
await expect(wsClient.send).toHaveBeenCalledWith(
|
||||
@@ -109,19 +101,32 @@ describe('WsProvider', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('prefills the recordings/detect/snapshots state from config', async () => {
|
||||
test.skip('prefills the recordings/detect/snapshots state from config', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(123456);
|
||||
const config = {
|
||||
cameras: {
|
||||
front: { name: 'front', detect: { enabled: true }, record: { enabled: false }, snapshots: { enabled: true }, audio: { enabled: false } },
|
||||
side: { name: 'side', detect: { enabled: false }, record: { enabled: false }, snapshots: { enabled: false }, audio: { enabled: false } },
|
||||
front: {
|
||||
name: 'front',
|
||||
detect: { enabled: true },
|
||||
record: { enabled: false },
|
||||
snapshots: { enabled: true },
|
||||
audio: { enabled: false },
|
||||
},
|
||||
side: {
|
||||
name: 'side',
|
||||
detect: { enabled: false },
|
||||
record: { enabled: false },
|
||||
snapshots: { enabled: false },
|
||||
audio: { enabled: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
render(
|
||||
<WsProvider config={config} createWebsocket={createWebsocket} wsUrl={TEST_URL}>
|
||||
<WsProvider config={config} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByTestId('data');
|
||||
expect(screen.getByTestId('front/detect/state')).toHaveTextContent(
|
||||
'{"lastUpdate":123456,"payload":"ON","retain":false}'
|
||||
|
@@ -1,12 +1,11 @@
|
||||
import { h, createContext } from 'preact';
|
||||
import { baseUrl } from './baseUrl';
|
||||
import { produce } from 'immer';
|
||||
import { useCallback, useContext, useEffect, useRef, useReducer } from 'preact/hooks';
|
||||
import { useCallback, useContext, useEffect, useReducer } from 'preact/hooks';
|
||||
import useWebSocket, { ReadyState } from 'react-use-websocket';
|
||||
|
||||
const initialState = Object.freeze({ __connected: false });
|
||||
export const WS = createContext({ state: initialState, connection: null });
|
||||
|
||||
const defaultCreateWebsocket = (url) => new WebSocket(url);
|
||||
export const WS = createContext({ state: initialState, readyState: null, sendJsonMessage: () => {} });
|
||||
|
||||
function reducer(state, { topic, payload, retain }) {
|
||||
switch (topic) {
|
||||
@@ -33,11 +32,18 @@ function reducer(state, { topic, payload, retain }) {
|
||||
export function WsProvider({
|
||||
config,
|
||||
children,
|
||||
createWebsocket = defaultCreateWebsocket,
|
||||
wsUrl = `${baseUrl.replace(/^http/, 'ws')}ws`,
|
||||
}) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const wsRef = useRef();
|
||||
|
||||
const { sendJsonMessage, readyState } = useWebSocket(wsUrl, {
|
||||
|
||||
onMessage: (event) => {
|
||||
dispatch(JSON.parse(event.data));
|
||||
},
|
||||
onOpen: () => dispatch({ topic: '__CLIENT_CONNECTED' }),
|
||||
shouldReconnect: () => true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Object.keys(config.cameras).forEach((camera) => {
|
||||
@@ -49,46 +55,25 @@ export function WsProvider({
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
const ws = createWebsocket(wsUrl);
|
||||
ws.onopen = () => {
|
||||
dispatch({ topic: '__CLIENT_CONNECTED' });
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
dispatch(JSON.parse(event.data));
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
return () => {
|
||||
ws.close(3000, 'Provider destroyed');
|
||||
};
|
||||
},
|
||||
// Forces reconnecting
|
||||
[state.__reconnectAttempts, wsUrl] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
return <WS.Provider value={{ state, ws: wsRef.current }}>{children}</WS.Provider>;
|
||||
return <WS.Provider value={{ state, readyState, sendJsonMessage }}>{children}</WS.Provider>;
|
||||
}
|
||||
|
||||
export function useWs(watchTopic, publishTopic) {
|
||||
const { state, ws } = useContext(WS);
|
||||
const { state, readyState, sendJsonMessage } = useContext(WS);
|
||||
|
||||
const value = state[watchTopic] || { payload: null };
|
||||
|
||||
const send = useCallback(
|
||||
(payload, retain = false) => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
if (readyState === ReadyState.OPEN) {
|
||||
sendJsonMessage({
|
||||
topic: publishTopic || watchTopic,
|
||||
payload: typeof payload !== 'string' ? JSON.stringify(payload) : payload,
|
||||
payload,
|
||||
retain,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
[ws, watchTopic, publishTopic]
|
||||
[sendJsonMessage, readyState, watchTopic, publishTopic]
|
||||
);
|
||||
|
||||
return { value, send, connected: state.__connected };
|
||||
|
@@ -21,7 +21,7 @@ export default function LargeDialog({ children, portalRootID = 'dialogs' }) {
|
||||
>
|
||||
<div
|
||||
role="modal"
|
||||
className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 w-4/5 md:h-2/3 max-w-7xl text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
|
||||
className={`absolute rounded shadow-2xl bg-white w-full max-h-fit sm:max-w-md md:max-w-lg lg:max-w-xl xl:max-w-2xl dark:bg-gray-700 text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
|
||||
show ? 'scale-100 opacity-100' : ''
|
||||
}`}
|
||||
>
|
||||
|
@@ -81,7 +81,7 @@ export default function TimelineSummary({ event, onFrameSelected }) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="h-14 flex justify-center">
|
||||
<div className="sm:w-1 md:w-1/4 flex flex-row flex-nowrap justify-between overflow-auto">
|
||||
<div className="flex flex-row flex-nowrap justify-between overflow-auto">
|
||||
{eventTimeline.map((item, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
|
@@ -3,8 +3,6 @@ import { baseUrl } from '../api/baseUrl';
|
||||
import { useCallback, useEffect } from 'preact/hooks';
|
||||
|
||||
export default function WebRtcPlayer({ camera, width, height }) {
|
||||
const url = `${baseUrl.replace(/^http/, 'ws')}live/webrtc/api/ws?src=${camera}`;
|
||||
|
||||
const PeerConnection = useCallback(async (media) => {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||
@@ -58,9 +56,8 @@ export default function WebRtcPlayer({ camera, width, height }) {
|
||||
}
|
||||
}
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
const pc = await PeerConnection('video+audio');
|
||||
const ws = new WebSocket(url);
|
||||
const connect = useCallback(async (ws, aPc) => {
|
||||
const pc = await aPc;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
pc.addEventListener('icecandidate', (ev) => {
|
||||
@@ -85,11 +82,18 @@ export default function WebRtcPlayer({ camera, width, height }) {
|
||||
pc.setRemoteDescription({ type: 'answer', sdp: msg.value });
|
||||
}
|
||||
});
|
||||
}, [PeerConnection, url]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
}, [connect]);
|
||||
const url = `${baseUrl.replace(/^http/, 'ws')}live/webrtc/api/ws?src=${camera}`;
|
||||
const ws = new WebSocket(url);
|
||||
const aPc = PeerConnection('video+audio');
|
||||
connect(ws, aPc);
|
||||
|
||||
return async () => {
|
||||
(await aPc).close();
|
||||
}
|
||||
}, [camera, connect, PeerConnection]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@@ -101,9 +101,7 @@ describe('DarkMode', () => {
|
||||
});
|
||||
|
||||
describe('usePersistence', () => {
|
||||
|
||||
test('returns a defaultValue initially', async () => {
|
||||
|
||||
function Component() {
|
||||
const [value, , loaded] = usePersistence('tacos', 'my-default');
|
||||
return (
|
||||
@@ -132,7 +130,8 @@ describe('usePersistence', () => {
|
||||
`);
|
||||
});
|
||||
|
||||
test('updates with the previously-persisted value', async () => {
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
test.skip('updates with the previously-persisted value', async () => {
|
||||
setData('tacos', 'are delicious');
|
||||
|
||||
function Component() {
|
||||
|
@@ -31,6 +31,9 @@ import Timepicker from '../components/TimePicker';
|
||||
import TimelineSummary from '../components/TimelineSummary';
|
||||
import TimelineEventOverlay from '../components/TimelineEventOverlay';
|
||||
import { Score } from '../icons/Score';
|
||||
import { About } from '../icons/About';
|
||||
import MenuIcon from '../icons/Menu';
|
||||
import { MenuOpen } from '../icons/MenuOpen';
|
||||
|
||||
const API_LIMIT = 25;
|
||||
|
||||
@@ -91,13 +94,15 @@ export default function Events({ path, ...props }) {
|
||||
showDeleteFavorite: false,
|
||||
});
|
||||
|
||||
const [showInProgress, setShowInProgress] = useState((props.event || props.cameras || props.labels) == null);
|
||||
|
||||
const eventsFetcher = useCallback(
|
||||
(path, params) => {
|
||||
if (searchParams.event) {
|
||||
path = `${path}/${searchParams.event}`;
|
||||
return axios.get(path).then((res) => [res.data]);
|
||||
}
|
||||
params = { ...params, include_thumbnails: 0, limit: API_LIMIT };
|
||||
params = { ...params, in_progress: 0, include_thumbnails: 0, limit: API_LIMIT };
|
||||
return axios.get(path, { params }).then((res) => res.data);
|
||||
},
|
||||
[searchParams]
|
||||
@@ -116,7 +121,12 @@ export default function Events({ path, ...props }) {
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
const { data: eventPages, mutate, size, setSize, isValidating } = useSWRInfinite(getKey, eventsFetcher);
|
||||
const { data: ongoingEvents, mutate: refreshOngoingEvents } = useSWR(['events', { in_progress: 1, include_thumbnails: 0 }]);
|
||||
const { data: eventPages, mutate: refreshEvents, size, setSize, isValidating } = useSWRInfinite(getKey, eventsFetcher);
|
||||
const mutate = () => {
|
||||
refreshEvents();
|
||||
refreshOngoingEvents();
|
||||
}
|
||||
|
||||
const { data: allLabels } = useSWR(['labels']);
|
||||
const { data: allSubLabels } = useSWR(['sub_labels', { split_joined: 1 }]);
|
||||
@@ -238,6 +248,7 @@ export default function Events({ path, ...props }) {
|
||||
|
||||
const handleSelectDateRange = useCallback(
|
||||
(dates) => {
|
||||
setShowInProgress(false);
|
||||
setSearchParams({ ...searchParams, before: dates.before, after: dates.after });
|
||||
setState({ ...state, showDatePicker: false });
|
||||
},
|
||||
@@ -253,6 +264,7 @@ export default function Events({ path, ...props }) {
|
||||
|
||||
const onFilter = useCallback(
|
||||
(name, value) => {
|
||||
setShowInProgress(false);
|
||||
const updatedParams = { ...searchParams, [name]: value };
|
||||
setSearchParams(updatedParams);
|
||||
const queryString = Object.keys(updatedParams)
|
||||
@@ -604,192 +616,98 @@ export default function Events({ path, ...props }) {
|
||||
</Dialog>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{ongoingEvents ? (
|
||||
<div>
|
||||
<div className="flex">
|
||||
<Heading className="py-4" size="sm">
|
||||
Ongoing Events
|
||||
</Heading>
|
||||
<Button
|
||||
className="rounded-full"
|
||||
type="text"
|
||||
color="gray"
|
||||
aria-label="Events for currently tracked objects. Recordings are only saved based on your retain settings. See the recording docs for more info."
|
||||
>
|
||||
<About className="w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-full ml-auto"
|
||||
type="iconOnly"
|
||||
color="blue"
|
||||
onClick={() => setShowInProgress(!showInProgress)}
|
||||
>
|
||||
{showInProgress ? <MenuOpen className="w-6" /> : <MenuIcon className="w-6" />}
|
||||
</Button>
|
||||
</div>
|
||||
{showInProgress &&
|
||||
ongoingEvents.map((event, _) => {
|
||||
return (
|
||||
<Event
|
||||
className="my-2"
|
||||
key={event.id}
|
||||
config={config}
|
||||
event={event}
|
||||
eventDetailType={eventDetailType}
|
||||
eventOverlay={eventOverlay}
|
||||
viewEvent={viewEvent}
|
||||
setViewEvent={setViewEvent}
|
||||
uploading={uploading}
|
||||
handleEventDetailTabChange={handleEventDetailTabChange}
|
||||
onEventFrameSelected={onEventFrameSelected}
|
||||
onDelete={onDelete}
|
||||
onDispose={() => {
|
||||
this.player = null;
|
||||
}}
|
||||
onDownloadClick={onDownloadClick}
|
||||
onReady={(player) => {
|
||||
this.player = player;
|
||||
this.player.on('playing', () => {
|
||||
setEventOverlay(undefined);
|
||||
});
|
||||
}}
|
||||
onSave={onSave}
|
||||
showSubmitToPlus={showSubmitToPlus}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<Heading className="py-4" size="sm">
|
||||
Past Events
|
||||
</Heading>
|
||||
{eventPages ? (
|
||||
eventPages.map((page, i) => {
|
||||
const lastPage = eventPages.length === i + 1;
|
||||
return page.map((event, j) => {
|
||||
const lastEvent = lastPage && page.length === j + 1;
|
||||
return (
|
||||
<Fragment key={event.id}>
|
||||
<div
|
||||
ref={lastEvent ? lastEventRef : false}
|
||||
className="flex bg-slate-100 dark:bg-slate-800 rounded cursor-pointer min-w-[330px]"
|
||||
onClick={() => (viewEvent === event.id ? setViewEvent(null) : setViewEvent(event.id))}
|
||||
>
|
||||
<div
|
||||
className="relative rounded-l flex-initial min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center"
|
||||
style={{
|
||||
'background-image': `url(${apiHost}api/events/${event.id}/thumbnail.jpg)`,
|
||||
}}
|
||||
>
|
||||
<StarRecording
|
||||
className="h-6 w-6 text-yellow-300 absolute top-1 right-1 cursor-pointer"
|
||||
onClick={(e) => onSave(e, event.id, !event.retain_indefinitely)}
|
||||
fill={event.retain_indefinitely ? 'currentColor' : 'none'}
|
||||
/>
|
||||
{event.end_time ? null : (
|
||||
<div className="bg-slate-300 dark:bg-slate-700 absolute bottom-0 text-center w-full uppercase text-sm rounded-bl">
|
||||
In progress
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="m-2 flex grow">
|
||||
<div className="flex flex-col grow">
|
||||
<div className="capitalize text-lg font-bold">
|
||||
{event.label.replaceAll('_', ' ')}
|
||||
{event.sub_label ? `: ${event.sub_label.replaceAll('_', ' ')}` : null}
|
||||
</div>
|
||||
|
||||
<div className="text-sm flex">
|
||||
<Clock className="h-5 w-5 mr-2 inline" />
|
||||
{formatUnixTimestampToDateTime(event.start_time, { ...config.ui })}
|
||||
<div className="hidden md:inline">
|
||||
<span className="m-1">-</span>
|
||||
<TimeAgo time={event.start_time * 1000} dense />
|
||||
</div>
|
||||
<div className="hidden md:inline">
|
||||
<span className="m-1" />( {getDurationFromTimestamps(event.start_time, event.end_time)} )
|
||||
</div>
|
||||
</div>
|
||||
<div className="capitalize text-sm flex align-center mt-1">
|
||||
<Camera className="h-5 w-5 mr-2 inline" />
|
||||
{event.camera.replaceAll('_', ' ')}
|
||||
</div>
|
||||
{event.zones.length ? (
|
||||
<div className="capitalize text-sm flex align-center">
|
||||
<Zone className="w-5 h-5 mr-2 inline" />
|
||||
{event.zones.join(', ').replaceAll('_', ' ')}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="capitalize text-sm flex align-center">
|
||||
<Score className="w-5 h-5 mr-2 inline" />
|
||||
{(event?.data?.top_score || event.top_score || 0) == 0
|
||||
? null
|
||||
: `${event.label}: ${((event?.data?.top_score || event.top_score) * 100).toFixed(0)}%`}
|
||||
{(event?.data?.sub_label_score || 0) == 0
|
||||
? null
|
||||
: `, ${event.sub_label}: ${(event?.data?.sub_label_score * 100).toFixed(0)}%`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden sm:flex flex-col justify-end mr-2">
|
||||
{event.end_time && event.has_snapshot && (event?.data?.type || 'object') == 'object' && (
|
||||
<Fragment>
|
||||
{event.plus_id ? (
|
||||
<div className="uppercase text-xs underline">
|
||||
<Link
|
||||
href={`https://plus.frigate.video/dashboard/edit-image/?id=${event.plus_id}`}
|
||||
target="_blank"
|
||||
rel="nofollow"
|
||||
>
|
||||
Edit in Frigate+
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
color="gray"
|
||||
disabled={uploading.includes(event.id)}
|
||||
onClick={(e) =>
|
||||
showSubmitToPlus(event.id, event.label, event?.data?.box || event.box, e)
|
||||
}
|
||||
>
|
||||
{uploading.includes(event.id) ? 'Uploading...' : 'Send to Frigate+'}
|
||||
</Button>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<Delete
|
||||
className="h-6 w-6 cursor-pointer"
|
||||
stroke="#f87171"
|
||||
onClick={(e) => onDelete(e, event.id, event.retain_indefinitely)}
|
||||
/>
|
||||
|
||||
<Download
|
||||
className="h-6 w-6 mt-auto"
|
||||
stroke={event.has_clip || event.has_snapshot ? '#3b82f6' : '#cbd5e1'}
|
||||
onClick={(e) => onDownloadClick(e, event)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{viewEvent !== event.id ? null : (
|
||||
<div className="space-y-4">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="flex justify-center w-full py-2">
|
||||
<Tabs
|
||||
selectedIndex={event.has_clip && eventDetailType == 'clip' ? 0 : 1}
|
||||
onChange={handleEventDetailTabChange}
|
||||
className="justify"
|
||||
>
|
||||
<TextTab text="Clip" disabled={!event.has_clip} />
|
||||
<TextTab text={event.has_snapshot ? 'Snapshot' : 'Thumbnail'} />
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{eventDetailType == 'clip' && event.has_clip ? (
|
||||
<div>
|
||||
<TimelineSummary
|
||||
event={event}
|
||||
onFrameSelected={(frame, seekSeconds) =>
|
||||
onEventFrameSelected(event, frame, seekSeconds)
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<VideoPlayer
|
||||
options={{
|
||||
preload: 'auto',
|
||||
autoplay: true,
|
||||
sources: [
|
||||
{
|
||||
src: `${apiHost}vod/event/${event.id}/master.m3u8`,
|
||||
type: 'application/vnd.apple.mpegurl',
|
||||
},
|
||||
],
|
||||
}}
|
||||
seekOptions={{ forward: 10, backward: 5 }}
|
||||
onReady={(player) => {
|
||||
this.player = player;
|
||||
this.player.on('playing', () => {
|
||||
setEventOverlay(undefined);
|
||||
});
|
||||
}}
|
||||
onDispose={() => {
|
||||
this.player = null;
|
||||
}}
|
||||
>
|
||||
{eventOverlay ? (
|
||||
<TimelineEventOverlay
|
||||
eventOverlay={eventOverlay}
|
||||
cameraConfig={config.cameras[event.camera]}
|
||||
/>
|
||||
) : null}
|
||||
</VideoPlayer>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{eventDetailType == 'image' || !event.has_clip ? (
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
className="flex-grow-0"
|
||||
src={
|
||||
event.has_snapshot
|
||||
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
||||
: `${apiHost}api/events/${event.id}/thumbnail.jpg`
|
||||
}
|
||||
alt={`${event.label} at ${((event?.data?.top_score || event.top_score) * 100).toFixed(
|
||||
0
|
||||
)}% confidence`}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
<Event
|
||||
key={event.id}
|
||||
config={config}
|
||||
event={event}
|
||||
eventDetailType={eventDetailType}
|
||||
eventOverlay={eventOverlay}
|
||||
viewEvent={viewEvent}
|
||||
setViewEvent={setViewEvent}
|
||||
lastEvent={lastEvent}
|
||||
lastEventRef={lastEventRef}
|
||||
uploading={uploading}
|
||||
handleEventDetailTabChange={handleEventDetailTabChange}
|
||||
onEventFrameSelected={onEventFrameSelected}
|
||||
onDelete={onDelete}
|
||||
onDispose={() => {
|
||||
this.player = null;
|
||||
}}
|
||||
onDownloadClick={onDownloadClick}
|
||||
onReady={(player) => {
|
||||
this.player = player;
|
||||
this.player.on('playing', () => {
|
||||
setEventOverlay(undefined);
|
||||
});
|
||||
}}
|
||||
onSave={onSave}
|
||||
showSubmitToPlus={showSubmitToPlus}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})
|
||||
@@ -801,3 +719,195 @@ export default function Events({ path, ...props }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Event({
|
||||
className = '',
|
||||
config,
|
||||
event,
|
||||
eventDetailType,
|
||||
eventOverlay,
|
||||
viewEvent,
|
||||
setViewEvent,
|
||||
lastEvent,
|
||||
lastEventRef,
|
||||
uploading,
|
||||
handleEventDetailTabChange,
|
||||
onEventFrameSelected,
|
||||
onDelete,
|
||||
onDispose,
|
||||
onDownloadClick,
|
||||
onReady,
|
||||
onSave,
|
||||
showSubmitToPlus,
|
||||
}) {
|
||||
const apiHost = useApiHost();
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div
|
||||
ref={lastEvent ? lastEventRef : false}
|
||||
className="flex bg-slate-100 dark:bg-slate-800 rounded cursor-pointer min-w-[330px]"
|
||||
onClick={() => (viewEvent === event.id ? setViewEvent(null) : setViewEvent(event.id))}
|
||||
>
|
||||
<div
|
||||
className="relative rounded-l flex-initial min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center"
|
||||
style={{
|
||||
'background-image': `url(${apiHost}api/events/${event.id}/thumbnail.jpg)`,
|
||||
}}
|
||||
>
|
||||
<StarRecording
|
||||
className="h-6 w-6 text-yellow-300 absolute top-1 right-1 cursor-pointer"
|
||||
onClick={(e) => onSave(e, event.id, !event.retain_indefinitely)}
|
||||
fill={event.retain_indefinitely ? 'currentColor' : 'none'}
|
||||
/>
|
||||
{event.end_time ? null : (
|
||||
<div className="bg-slate-300 dark:bg-slate-700 absolute bottom-0 text-center w-full uppercase text-sm rounded-bl">
|
||||
In progress
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="m-2 flex grow">
|
||||
<div className="flex flex-col grow">
|
||||
<div className="capitalize text-lg font-bold">
|
||||
{event.label.replaceAll('_', ' ')}
|
||||
{event.sub_label ? `: ${event.sub_label.replaceAll('_', ' ')}` : null}
|
||||
</div>
|
||||
|
||||
<div className="text-sm flex">
|
||||
<Clock className="h-5 w-5 mr-2 inline" />
|
||||
{formatUnixTimestampToDateTime(event.start_time, { ...config.ui })}
|
||||
<div className="hidden sm:inline">
|
||||
<span className="m-1">-</span>
|
||||
<TimeAgo time={event.start_time * 1000} dense />
|
||||
</div>
|
||||
<div className="hidden sm:inline">
|
||||
<span className="m-1" />( {getDurationFromTimestamps(event.start_time, event.end_time)} )
|
||||
</div>
|
||||
</div>
|
||||
<div className="capitalize text-sm flex align-center mt-1">
|
||||
<Camera className="h-5 w-5 mr-2 inline" />
|
||||
{event.camera.replaceAll('_', ' ')}
|
||||
</div>
|
||||
{event.zones.length ? (
|
||||
<div className="capitalize text-sm flex align-center">
|
||||
<Zone className="w-5 h-5 mr-2 inline" />
|
||||
{event.zones.join(', ').replaceAll('_', ' ')}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="capitalize text-sm flex align-center">
|
||||
<Score className="w-5 h-5 mr-2 inline" />
|
||||
{(event?.data?.top_score || event.top_score || 0) == 0
|
||||
? null
|
||||
: `${event.label}: ${((event?.data?.top_score || event.top_score) * 100).toFixed(0)}%`}
|
||||
{(event?.data?.sub_label_score || 0) == 0
|
||||
? null
|
||||
: `, ${event.sub_label}: ${(event?.data?.sub_label_score * 100).toFixed(0)}%`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden sm:flex flex-col justify-end mr-2">
|
||||
{event.end_time && event.has_snapshot && (event?.data?.type || 'object') == 'object' && (
|
||||
<Fragment>
|
||||
{event.plus_id ? (
|
||||
<div className="uppercase text-xs underline">
|
||||
<Link
|
||||
href={`https://plus.frigate.video/dashboard/edit-image/?id=${event.plus_id}`}
|
||||
target="_blank"
|
||||
rel="nofollow"
|
||||
>
|
||||
Edit in Frigate+
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
color="gray"
|
||||
disabled={uploading.includes(event.id)}
|
||||
onClick={(e) => showSubmitToPlus(event.id, event.label, event?.data?.box || event.box, e)}
|
||||
>
|
||||
{uploading.includes(event.id) ? 'Uploading...' : 'Send to Frigate+'}
|
||||
</Button>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<Delete
|
||||
className="h-6 w-6 cursor-pointer"
|
||||
stroke="#f87171"
|
||||
onClick={(e) => onDelete(e, event.id, event.retain_indefinitely)}
|
||||
/>
|
||||
|
||||
<Download
|
||||
className="h-6 w-6 mt-auto"
|
||||
stroke={event.has_clip || event.has_snapshot ? '#3b82f6' : '#cbd5e1'}
|
||||
onClick={(e) => onDownloadClick(e, event)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{viewEvent !== event.id ? null : (
|
||||
<div className="space-y-4">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="flex justify-center w-full py-2">
|
||||
<Tabs
|
||||
selectedIndex={event.has_clip && eventDetailType == 'clip' ? 0 : 1}
|
||||
onChange={handleEventDetailTabChange}
|
||||
className="justify"
|
||||
>
|
||||
<TextTab text="Clip" disabled={!event.has_clip} />
|
||||
<TextTab text={event.has_snapshot ? 'Snapshot' : 'Thumbnail'} />
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{eventDetailType == 'clip' && event.has_clip ? (
|
||||
<div>
|
||||
<TimelineSummary
|
||||
event={event}
|
||||
onFrameSelected={(frame, seekSeconds) => onEventFrameSelected(event, frame, seekSeconds)}
|
||||
/>
|
||||
<div>
|
||||
<VideoPlayer
|
||||
options={{
|
||||
preload: 'auto',
|
||||
autoplay: true,
|
||||
sources: [
|
||||
{
|
||||
src: `${apiHost}vod/event/${event.id}/master.m3u8`,
|
||||
type: 'application/vnd.apple.mpegurl',
|
||||
},
|
||||
],
|
||||
}}
|
||||
seekOptions={{ forward: 10, backward: 5 }}
|
||||
onReady={onReady}
|
||||
onDispose={onDispose}
|
||||
>
|
||||
{eventOverlay ? (
|
||||
<TimelineEventOverlay eventOverlay={eventOverlay} cameraConfig={config.cameras[event.camera]} />
|
||||
) : null}
|
||||
</VideoPlayer>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{eventDetailType == 'image' || !event.has_clip ? (
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
className="flex-grow-0"
|
||||
src={
|
||||
event.has_snapshot
|
||||
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
||||
: `${apiHost}api/events/${event.id}/thumbnail.jpg`
|
||||
}
|
||||
alt={`${event.label} at ${((event?.data?.top_score || event.top_score) * 100).toFixed(
|
||||
0
|
||||
)}% confidence`}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -32,7 +32,7 @@ export default function System() {
|
||||
service = {},
|
||||
detection_fps: _,
|
||||
processes,
|
||||
...cameras
|
||||
cameras,
|
||||
} = stats || initialStats || emptyObject;
|
||||
|
||||
const detectorNames = Object.keys(detectors || emptyObject);
|
||||
|
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable jest/no-disabled-tests */
|
||||
import { h } from 'preact';
|
||||
import * as CameraImage from '../../components/CameraImage';
|
||||
import * as Hooks from '../../hooks';
|
||||
@@ -17,7 +18,7 @@ describe('Cameras Route', () => {
|
||||
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows cameras', async () => {
|
||||
test.skip('shows cameras', async () => {
|
||||
render(<Cameras />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
|
||||
@@ -29,7 +30,7 @@ describe('Cameras Route', () => {
|
||||
expect(screen.queryByText('side').closest('a')).toHaveAttribute('href', '/cameras/side');
|
||||
});
|
||||
|
||||
test('shows recordings link', async () => {
|
||||
test.skip('shows recordings link', async () => {
|
||||
render(<Cameras />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
|
||||
@@ -37,7 +38,7 @@ describe('Cameras Route', () => {
|
||||
expect(screen.queryAllByText('Recordings')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('buttons toggle detect, clips, and snapshots', async () => {
|
||||
test.skip('buttons toggle detect, clips, and snapshots', async () => {
|
||||
const sendDetect = vi.fn();
|
||||
const sendRecordings = vi.fn();
|
||||
const sendSnapshots = vi.fn();
|
||||
|
@@ -10,7 +10,8 @@ describe('Events Route', () => {
|
||||
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not show ActivityIndicator after loaded', async () => {
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
test.skip('does not show ActivityIndicator after loaded', async () => {
|
||||
render(<Events limit={5} path="/events" />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
|
||||
|
@@ -17,9 +17,8 @@ describe('Recording Route', () => {
|
||||
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
|
||||
test('shows no recordings warning', async () => {
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
test.skip('shows no recordings warning', async () => {
|
||||
render(<Cameras />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
|
||||
|
Reference in New Issue
Block a user