Compare commits

..

21 Commits

Author SHA1 Message Date
Blake Blackshear
b912851e49 fix default motion comment 2021-11-15 06:54:03 -06:00
Blake Blackshear
14c74e4361 more robust cache management 2021-11-10 21:12:41 -06:00
Blake Blackshear
51fb532e1a set retain when setting switches from frontend 2021-11-09 07:40:23 -06:00
Blake Blackshear
3541f966e3 error handling for the recording maintainer 2021-11-09 07:05:21 -06:00
Blake Blackshear
c7faef8faa don't modify ffmpeg_cmd object 2021-11-08 19:05:39 -06:00
Blake Blackshear
cdd3000315 fix ffmpeg config for env vars 2021-11-08 18:20:47 -06:00
Blake Blackshear
1c1c28d0e5 create ffmpeg commands on startup 2021-11-08 07:36:21 -06:00
Blake Blackshear
4422e86907 clarify shm in docs 2021-11-08 07:36:21 -06:00
Blake Blackshear
8f43a2d109 use resolution of clip 2021-11-08 07:36:21 -06:00
Blake Blackshear
bd7755fdd3 revamp process clip 2021-11-08 07:36:21 -06:00
Blake Blackshear
d554175631 no longer make motion settings dynamic 2021-11-08 07:36:21 -06:00
Blake Blackshear
ff667b019a remove min frame height of 180 and increase contour area 2021-11-08 07:36:21 -06:00
Blake Blackshear
57dcb29f8b consolidate regions 2021-11-08 07:36:21 -06:00
Blake Blackshear
9dc6c423b7 improve contrast 2021-11-08 07:36:21 -06:00
Blake Blackshear
58117e2a3e check for overlapping motion boxes 2021-11-08 07:36:21 -06:00
Blake Blackshear
5bec438f9c config option for stationary detection interval 2021-11-01 07:58:30 -05:00
Blake Blackshear
24cc63d6d3 drop high overlap detections 2021-11-01 07:58:30 -05:00
Blake Blackshear
d17bd74c9a reduce detection rate for stationary objects 2021-11-01 07:58:30 -05:00
Blake Blackshear
8f101ccca8 improve box merging and keep tracking 2021-11-01 07:58:30 -05:00
Blake Blackshear
b63c56d810 only save recordings when an event is in progress 2021-10-25 06:40:36 -05:00
Blake Blackshear
61c62d4685 version tick 2021-10-25 06:40:02 -05:00
59 changed files with 656 additions and 31592 deletions

View File

@@ -44,27 +44,3 @@ jobs:
- name: Test - name: Test
run: npm run test run: npm run test
working-directory: ./web working-directory: ./web
docker_tests_on_aarch64:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build and run tests
run: make run_tests PLATFORM="linux/arm64/v8" ARCH="aarch64"
docker_tests_on_amd64:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build and run tests
run: make run_tests PLATFORM="linux/amd64" ARCH="amd64"

View File

@@ -59,16 +59,4 @@ armv7_frigate: version web
armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate
run_tests: .PHONY: web
# PLATFORM: linux/arm64/v8 linux/amd64 or linux/arm/v7
# ARCH: aarch64 amd64 or armv7
@cat docker/Dockerfile.base docker/Dockerfile.$(ARCH) > docker/Dockerfile.test
@sed -i "s/FROM frigate-web as web/#/g" docker/Dockerfile.test
@sed -i "s/COPY --from=web \/opt\/frigate\/build web\//#/g" docker/Dockerfile.test
@sed -i "s/FROM frigate-base/#/g" docker/Dockerfile.test
@echo "" >> docker/Dockerfile.test
@echo "RUN python3 -m unittest" >> docker/Dockerfile.test
@docker buildx build --platform=$(PLATFORM) --tag frigate-base --build-arg NGINX_VERSION=1.0.2 --build-arg FFMPEG_VERSION=1.0.0 --build-arg ARCH=$(ARCH) --build-arg WHEELS_VERSION=1.0.3 --file docker/Dockerfile.test .
@rm docker/Dockerfile.test
.PHONY: web run_tests

View File

@@ -22,5 +22,3 @@ RUN pip3 install pylint black
# Install Node 14 # Install Node 14
RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - \ RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - \
&& apt-get install -y nodejs && apt-get install -y nodejs
RUN npm install -g npm@latest

View File

@@ -58,7 +58,7 @@ http {
# vod caches # vod caches
vod_metadata_cache metadata_cache 512m; vod_metadata_cache metadata_cache 512m;
vod_mapping_cache mapping_cache 5m 10m; vod_mapping_cache mapping_cache 5m;
# gzip manifests # gzip manifests
gzip on; gzip on;

View File

@@ -43,11 +43,6 @@ If you are storing your database on a network share (SMB, NFS, etc), you may get
This may need to be in a custom location if network storage is used for the media folder. This may need to be in a custom location if network storage is used for the media folder.
```yaml
database:
path: /path/to/frigate.db
```
### `model` ### `model`
If using a custom model, the width and height will need to be specified. If using a custom model, the width and height will need to be specified.

View File

@@ -19,34 +19,6 @@ output_args:
rtmp: -c:v libx264 -an -f flv rtmp: -c:v libx264 -an -f flv
``` ```
### JPEG Stream Cameras
Cameras using a live changing jpeg image will need input parameters as below
```yaml
input_args:
- -r
- 5 # << enter FPS here
- -stream_loop
- -1
- -f
- image2
- -avoid_negative_ts
- make_zero
- -fflags
- nobuffer
- -flags
- low_delay
- -strict
- experimental
- -fflags
- +genpts+discardcorrupt
- -use_wallclock_as_timestamps
- 1
```
Outputting the stream will have the same args and caveats as per [MJPEG Cameras](#mjpeg-cameras)
### RTMP Cameras ### RTMP Cameras
The input parameters need to be adjusted for RTMP cameras The input parameters need to be adjusted for RTMP cameras
@@ -89,8 +61,8 @@ cameras:
roles: roles:
- detect - detect
detect: detect:
width: 896 width: 640
height: 672 height: 480
fps: 7 fps: 7
``` ```

View File

@@ -159,23 +159,8 @@ detect:
enabled: True enabled: True
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate) # Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate)
max_disappeared: 25 max_disappeared: 25
# Optional: Configuration for stationary object tracking # Optional: Frequency for running detection on stationary objects (default: 10x the frame rate)
stationary: stationary_interval: 50
# Optional: Frequency for running detection on stationary objects (default: shown below)
# When set to 0, object detection will never be run on stationary objects. If set to 10, it will be run on every 10th frame.
interval: 0
# Optional: Number of frames without a position change for an object to be considered stationary (default: 10x the frame rate or 10s)
threshold: 50
# Optional: Define a maximum number of frames for tracking a stationary object (default: not set, track forever)
# This can help with false positives for objects that should only be stationary for a limited amount of time.
# It can also be used to disable stationary object tracking. For example, you may want to set a value for person, but leave
# car at the default.
max_frames:
# Optional: Default for all object types (default: not set, track forever)
default: 3000
# Optional: Object specific values
objects:
person: 1000
# Optional: Object configuration # Optional: Object configuration
# NOTE: Can be overridden at the camera level # NOTE: Can be overridden at the camera level
@@ -238,32 +223,16 @@ motion:
# NOTE: Can be overridden at the camera level # NOTE: Can be overridden at the camera level
record: record:
# Optional: Enable recording (default: shown below) # Optional: Enable recording (default: shown below)
# WARNING: Frigate does not currently support limiting recordings based
# on available disk space automatically. If using recordings,
# you must specify retention settings for a number of days that
# will fit within the available disk space of your drive or Frigate
# will crash.
enabled: False enabled: False
# Optional: Number of minutes to wait between cleanup runs (default: shown below) # Optional: Number of days to retain recordings regardless of events (default: shown below)
# This can be used to reduce the frequency of deleting recording segments from disk if you want to minimize i/o # NOTE: This should be set to 0 and retention should be defined in events section below
expire_interval: 60 # if you only want to retain recordings of events.
# Optional: Retention settings for recording retain_days: 0
retain:
# Optional: Number of days to retain recordings regardless of events (default: shown below)
# NOTE: This should be set to 0 and retention should be defined in events section below
# if you only want to retain recordings of events.
days: 0
# Optional: Mode for retention. Available options are: all, motion, and active_objects
# all - save all recording segments regardless of activity
# motion - save all recordings segments with any detected motion
# active_objects - save all recording segments with active/moving objects
# NOTE: this mode only applies when the days setting above is greater than 0
mode: all
# Optional: Event recording settings # Optional: Event recording settings
events: events:
# Optional: Maximum length of time to retain video during long events. (default: shown below) # Optional: Maximum length of time to retain video during long events. (default: shown below)
# NOTE: If an object is being tracked for longer than this amount of time, the retained recordings # NOTE: If an object is being tracked for longer than this amount of time, the retained recordings
# will be the last x seconds of the event unless retain->days under record is > 0. # will be the last x seconds of the event unless retain_days under record is > 0.
max_seconds: 300 max_seconds: 300
# Optional: Number of seconds before the event to include (default: shown below) # Optional: Number of seconds before the event to include (default: shown below)
pre_capture: 5 pre_capture: 5
@@ -278,16 +247,6 @@ record:
retain: retain:
# Required: Default retention days (default: shown below) # Required: Default retention days (default: shown below)
default: 10 default: 10
# Optional: Mode for retention. (default: shown below)
# all - save all recording segments for events regardless of activity
# motion - save all recordings segments for events with any detected motion
# active_objects - save all recording segments for event with active/moving objects
#
# NOTE: If the retain mode for the camera is more restrictive than the mode configured
# here, the segments will already be gone by the time this mode is applied.
# For example, if the camera retain mode is "motion", the segments without motion are
# never stored, so setting the mode to "all" here won't bring them back.
mode: motion
# Optional: Per object retention days # Optional: Per object retention days
objects: objects:
person: 15 person: 15
@@ -400,7 +359,7 @@ cameras:
# camera. # camera.
front_steps: front_steps:
# Required: List of x,y coordinates to define the polygon of the zone. # Required: List of x,y coordinates to define the polygon of the zone.
# NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box. # NOTE: Coordinates can be generated at https://www.image-map.net/
coordinates: 545,1077,747,939,788,805 coordinates: 545,1077,747,939,788,805
# Optional: List of objects that can trigger this zone (default: all tracked objects) # Optional: List of objects that can trigger this zone (default: all tracked objects)
objects: objects:

View File

@@ -97,3 +97,15 @@ processes:
| 0 N/A N/A 12827 C ffmpeg 417MiB | | 0 N/A N/A 12827 C ffmpeg 417MiB |
+-----------------------------------------------------------------------------+ +-----------------------------------------------------------------------------+
``` ```
To further improve performance, you can set ffmpeg to skip frames in the output,
using the fps filter:
```yaml
output_args:
- -filter:v
- fps=fps=5
```
This setting, for example, allows Frigate to consume my 10-15fps camera streams on
my relatively low powered Haswell machine with relatively low cpu usage.

View File

@@ -5,11 +5,7 @@ title: Objects
import labels from "../../../labelmap.txt"; import labels from "../../../labelmap.txt";
Frigate includes the object models listed below from the Google Coral test data. By default, Frigate includes the following object models from the Google Coral test data. Note that `car` is listed twice because `truck` has been renamed to `car` by default. These object types are frequently confused.
Please note:
- `car` is listed twice because `truck` has been renamed to `car` by default. These object types are frequently confused.
- `person` is the only tracked object by default. See the [full configuration reference](https://docs.frigate.video/configuration/index#full-configuration-reference) for an example of expanding the list of tracked objects.
<ul> <ul>
{labels.split("\n").map((label) => ( {labels.split("\n").map((label) => (

View File

@@ -14,11 +14,12 @@ If you only used clips in previous versions with recordings disabled, you can us
```yaml ```yaml
record: record:
enabled: True enabled: True
retain_days: 0
events: events:
retain: retain:
default: 10 default: 10
``` ```
This configuration will retain recording segments that overlap with events and have active tracked objects for 10 days. Because multiple events can reference the same recording segments, this avoids storing duplicate footage for overlapping events and reduces overall storage needs. This configuration will retain recording segments that overlap with events for 10 days. Because multiple events can reference the same recording segments, this avoids storing duplicate footage for overlapping events and reduces overall storage needs.
When `retain_days` is set to `0`, segments will be deleted from the cache if no events are in progress. When `retain_days` is set to `0`, segments will be deleted from the cache if no events are in progress

View File

@@ -5,4 +5,4 @@ title: RTMP
Frigate can re-stream your video feed as a RTMP feed for other applications such as Home Assistant to utilize it at `rtmp://<frigate_host>/live/<camera_name>`. Port 1935 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. 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 can re-stream your video feed as a RTMP feed for other applications such as Home Assistant to utilize it at `rtmp://<frigate_host>/live/<camera_name>`. Port 1935 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. The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate.
Some video feeds are not compatible with RTMP. If you are experiencing issues, check to make sure your camera feed is h264 with AAC audio. If your camera doesn't support a compatible format for RTMP, you can use the ffmpeg args to re-encode it on the fly at the expense of increased CPU utilization. Some more information about it can be found [here](../faqs#audio-in-recordings). Some video feeds are not compatible with RTMP. If you are experiencing issues, check to make sure your camera feed is h264 with AAC audio. If your camera doesn't support a compatible format for RTMP, you can use the ffmpeg args to re-encode it on the fly at the expense of increased CPU utilization.

View File

@@ -3,9 +3,7 @@ id: zones
title: Zones title: Zones
--- ---
Zones allow you to define a specific area of the frame and apply additional filters for object types so you can determine whether or not an object is within a particular area. Presence in a zone is evaluated based on the bottom center of the bounding box for the object. It does not matter how much of the bounding box overlaps with the zone. Zones allow you to define a specific area of the frame and apply additional filters for object types so you can determine whether or not an object is within a particular area. Zones cannot have the same name as a camera. If desired, a single zone can include multiple cameras if you have multiple cameras covering the same area by configuring zones with the same name for each camera.
Zones cannot have the same name as a camera. If desired, a single zone can include multiple cameras if you have multiple cameras covering the same area by configuring zones with the same name for each camera.
During testing, enable the Zones option for the debug feed so you can adjust as needed. The zone line will increase in thickness when any object enters the zone. During testing, enable the Zones option for the debug feed so you can adjust as needed. The zone line will increase in thickness when any object enters the zone.

View File

@@ -11,24 +11,9 @@ This error message is due to a shm-size that is too small. Try updating your shm
A solid green image means that frigate has not received any frames from ffmpeg. Check the logs to see why ffmpeg is exiting and adjust your ffmpeg args accordingly. A solid green image means that frigate has not received any frames from ffmpeg. Check the logs to see why ffmpeg is exiting and adjust your ffmpeg args accordingly.
### How can I get sound or audio in my recordings? {#audio-in-recordings} ### How can I get sound or audio in my recordings?
By default, Frigate removes audio from recordings to reduce the likelihood of failing for invalid data. If you would like to include audio, you need to override the output args to remove `-an` for where you want to include audio. The recommended audio codec is `aac`. Not all audio codecs are supported by RTMP, so you may need to re-encode your audio with `-c:a aac`. The default ffmpeg args are shown [here](/configuration/index/#full-configuration-reference). By default, Frigate removes audio from recordings to reduce the likelihood of failing for invalid data. If you would like to include audio, you need to override the output args to remove `-an` for where you want to include audio. The recommended audio codec is `aac`. Not all audio codecs are supported by RTMP, so you may need to re-encode your audio with `-c:a aac`. The default ffmpeg args are shown [here](configuration/index#full-configuration-reference).
:::tip
When using `-c:a aac`, do not forget to replace `-c copy` with `-c:v copy`. Example:
```diff title="frigate.yml"
ffmpeg:
output_args:
- record: -f segment -segment_time 10 -segment_format mp4 -reset_timestamps 1 -strftime 1 -c copy -an
+ record: -f segment -segment_time 10 -segment_format mp4 -reset_timestamps 1 -strftime 1 -c:v copy -c:a aac
```
This is needed because the `-c` flag (without `:a` or `:v`) applies for both audio and video, thus making it conflicting with `-c:a aac`.
:::
### My mjpeg stream or snapshots look green and crazy ### My mjpeg stream or snapshots look green and crazy

View File

@@ -62,8 +62,6 @@ cameras:
roles: roles:
- detect - detect
- rtmp - rtmp
rtmp:
enabled: False # <-- RTMP should be disabled if your stream is not H264
detect: detect:
width: 1280 # <---- update for your camera's resolution width: 1280 # <---- update for your camera's resolution
height: 720 # <---- update for your camera's resolution height: 720 # <---- update for your camera's resolution
@@ -73,9 +71,7 @@ cameras:
At this point you should be able to start Frigate and see the the video feed in the UI. At this point you should be able to start Frigate and see the the video feed in the UI.
If you get a green image from the camera, this means ffmpeg was not able to get the video feed from your camera. Check the logs for error messages from ffmpeg. The default ffmpeg arguments are designed to work with H264 RTSP cameras that support TCP connections. If you do not have H264 cameras, make sure you have disabled RTMP. It is possible to enable it, but you must tell ffmpeg to re-encode the video with customized output args. If you get a green image from the camera, this means ffmpeg was not able to get the video feed from your camera. Check the logs for error messages from ffmpeg. The default ffmpeg arguments are designed to work with RTSP cameras that support TCP connections. FFmpeg arguments for other types of cameras can be found [here](/configuration/camera_specific).
FFmpeg arguments for other types of cameras can be found [here](/configuration/camera_specific).
### Step 5: Configure hardware acceleration (optional) ### Step 5: Configure hardware acceleration (optional)
@@ -167,17 +163,13 @@ cameras:
roles: roles:
- detect - detect
- rtmp - rtmp
- path: rtsp://10.0.10.10:554/high_res_stream # <----- Add high res stream - record # <----- Add role
roles:
- record
detect: ... detect: ...
record: # <----- Enable recording record: # <----- Enable recording
enabled: True enabled: True
motion: ... motion: ...
``` ```
If you don't have separate streams for detect and record, you would just add the record role to the list on the first input.
By default, Frigate will retain video of all events for 10 days. The full set of options for recording can be found [here](/configuration/index#full-configuration-reference). By default, Frigate will retain video of all events for 10 days. The full set of options for recording can be found [here](/configuration/index#full-configuration-reference).
### Step 8: Enable snapshots (optional) ### Step 8: Enable snapshots (optional)

View File

@@ -25,30 +25,6 @@ automation:
when: '{{trigger.payload_json["after"]["start_time"]|int}}' when: '{{trigger.payload_json["after"]["start_time"]|int}}'
``` ```
Note that iOS devices support live previews of cameras by adding a camera entity id to the message data.
```yaml
automation:
- alias: Security_Frigate_Notifications
description: ""
trigger:
- platform: mqtt
topic: frigate/events
payload: new
value_template: "{{ value_json.type }}"
action:
- service: notify.mobile_app_iphone
data:
message: 'A {{trigger.payload_json["after"]["label"]}} was detected.'
data:
image: >-
https://your.public.hass.address.com/api/frigate/notifications/{{trigger.payload_json["after"]["id"]}}/thumbnail.jpg
tag: '{{trigger.payload_json["after"]["id"]}}'
when: '{{trigger.payload_json["after"]["start_time"]|int}}'
entity_id: camera.{{trigger.payload_json["after"]["camera"]}}
mode: single
```
## Conditions ## Conditions
Conditions with the `before` and `after` values allow a high degree of customization for automations. Conditions with the `before` and `after` values allow a high degree of customization for automations.

View File

@@ -21,12 +21,6 @@ Windows is not officially supported, but some users have had success getting it
Frigate uses the following locations for read/write operations in the container. Docker volume mappings can be used to map these to any location on your host machine. Frigate uses the following locations for read/write operations in the container. Docker volume mappings can be used to map these to any location on your host machine.
:::caution
Note that Frigate does not currently support limiting recordings based on available disk space automatically. If using recordings, you must specify retention settings for a number of days that will fit within the available disk space of your drive or Frigate will crash.
:::
- `/media/frigate/clips`: Used for snapshot storage. In the future, it will likely be renamed from `clips` to `snapshots`. The file structure here cannot be modified and isn't intended to be browsed or managed manually. - `/media/frigate/clips`: Used for snapshot storage. In the future, it will likely be renamed from `clips` to `snapshots`. The file structure here cannot be modified and isn't intended to be browsed or managed manually.
- `/media/frigate/recordings`: Internal system storage for recording segments. The file structure here cannot be modified and isn't intended to be browsed or managed manually. - `/media/frigate/recordings`: Internal system storage for recording segments. The file structure here cannot be modified and isn't intended to be browsed or managed manually.
- `/media/frigate/frigate.db`: Default location for the sqlite database. You will also see several files alongside this file while frigate is running. If moving the database location (often needed when using a network drive at `/media/frigate`), it is recommended to mount a volume with docker at `/db` and change the storage location of the database to `/db/frigate.db` in the config file. - `/media/frigate/frigate.db`: Default location for the sqlite database. You will also see several files alongside this file while frigate is running. If moving the database location (often needed when using a network drive at `/media/frigate`), it is recommended to mount a volume with docker at `/db` and change the storage location of the database to `/db/frigate.db` in the config file.
@@ -124,7 +118,6 @@ services:
shm_size: "64mb" # update for your cameras based on calculation above shm_size: "64mb" # update for your cameras based on calculation above
devices: devices:
- /dev/bus/usb:/dev/bus/usb # passes the USB Coral, needs to be modified for other versions - /dev/bus/usb:/dev/bus/usb # passes the USB Coral, needs to be modified for other versions
- /dev/apex_0:/dev/apex_0 # passes a PCIe Coral, follow driver instructions here https://coral.ai/docs/m2/get-started/#2a-on-linux
- /dev/dri/renderD128 # for intel hwaccel, needs to be updated for your hardware - /dev/dri/renderD128 # for intel hwaccel, needs to be updated for your hardware
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
@@ -184,15 +177,6 @@ HassOS users can install via the addon repository.
6. Start the addon container 6. Start the addon container
7. (not for proxy addon) If you are using hardware acceleration for ffmpeg, you may need to disable "Protection mode" 7. (not for proxy addon) If you are using hardware acceleration for ffmpeg, you may need to disable "Protection mode"
There are several versions of the addon available:
| Addon Version | Description |
| ------------------------------ | ---------------------------------------------------------- |
| Frigate NVR | Current release with protection mode on |
| Frigate NVR (Full Access) | Current release with the option to disable protection mode |
| Frigate NVR Beta | Beta release with protection mode on |
| Frigate NVR Beta (Full Access) | Beta release with the option to disable protection mode |
## Home Assistant Supervised ## Home Assistant Supervised
:::tip :::tip

View File

@@ -45,14 +45,11 @@ that card.
## Configuration ## Configuration
When configuring the integration, you will be asked for the `URL` of your frigate instance which is the URL you use to access Frigate in the browser. This may look like `http://<host>:5000/`. If you are using HassOS with the addon, the URL should be one of the following depending on which addon version you are using. Note that if you are using the Proxy Addon, you do NOT point the integration at the proxy URL. Just enter the URL used to access frigate directly from your network. When configuring the integration, you will be asked for the following parameters:
| Addon Version | URL | | Variable | Description |
| ------------------------------ | -------------------------------------- | | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Frigate NVR | `http://ccab4aaf-frigate:5000` | | URL | The `URL` of your frigate instance, the URL you use to access Frigate in the browser. This may look like `http://<host>:5000/`. If you are using HassOS with the addon, the URL should be `http://ccab4aaf-frigate:5000` (or `http://ccab4aaf-frigate-beta:5000` if your are using the beta version of the addon). Live streams required port 1935, see [RTMP streams](#streams) |
| Frigate NVR (Full Access) | `http://ccab4aaf-frigate-fa:5000` |
| Frigate NVR Beta | `http://ccab4aaf-frigate-beta:5000` |
| Frigate NVR Beta (Full Access) | `http://ccab4aaf-frigate-fa-beta:5000` |
<a name="options"></a> <a name="options"></a>

View File

@@ -55,10 +55,7 @@ Message published for each changed event. The first message is published when th
"entered_zones": ["yard", "driveway"], "entered_zones": ["yard", "driveway"],
"thumbnail": null, "thumbnail": null,
"has_snapshot": false, "has_snapshot": false,
"has_clip": false, "has_clip": false
"stationary": false, // whether or not the object is considered stationary
"motionless_count": 0, // number of frames the object has been motionless
"position_changes": 2 // number of times the object has moved from a stationary position
}, },
"after": { "after": {
"id": "1607123955.475377-mxklsc", "id": "1607123955.475377-mxklsc",
@@ -78,10 +75,7 @@ Message published for each changed event. The first message is published when th
"entered_zones": ["yard", "driveway"], "entered_zones": ["yard", "driveway"],
"thumbnail": null, "thumbnail": null,
"has_snapshot": false, "has_snapshot": false,
"has_clip": false, "has_clip": false
"stationary": false, // whether or not the object is considered stationary
"motionless_count": 0, // number of frames the object has been motionless
"position_changes": 2 // number of times the object has changed position
} }
} }
``` ```

14859
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,13 +12,13 @@
"clear": "docusaurus clear" "clear": "docusaurus clear"
}, },
"dependencies": { "dependencies": {
"@docusaurus/core": "^2.0.0-beta.15", "@docusaurus/core": "^2.0.0-beta.6",
"@docusaurus/preset-classic": "^2.0.0-beta.15", "@docusaurus/preset-classic": "^2.0.0-beta.6",
"@mdx-js/react": "^1.6.22", "@mdx-js/react": "^1.6.21",
"clsx": "^1.1.1", "clsx": "^1.1.1",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"react": "^16.14.0", "react": "^16.8.4",
"react-dom": "^16.14.0" "react-dom": "^16.8.4"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
@@ -31,8 +31,5 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"devDependencies": {
"@types/react": "^16.14.0"
} }
} }

View File

@@ -8,7 +8,6 @@ import threading
from logging.handlers import QueueHandler from logging.handlers import QueueHandler
from typing import Dict, List from typing import Dict, List
import traceback
import yaml import yaml
from peewee_migrate import Router from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqlite_ext import SqliteExtDatabase
@@ -68,16 +67,13 @@ class FrigateApp:
def init_config(self): def init_config(self):
config_file = os.environ.get("CONFIG_FILE", "/config/config.yml") config_file = os.environ.get("CONFIG_FILE", "/config/config.yml")
# Check if we can use .yaml instead of .yml
config_file_yaml = config_file.replace(".yml", ".yaml")
if os.path.isfile(config_file_yaml):
config_file = config_file_yaml
user_config = FrigateConfig.parse_file(config_file) user_config = FrigateConfig.parse_file(config_file)
self.config = user_config.runtime_config self.config = user_config.runtime_config
for camera_name in self.config.cameras.keys(): for camera_name in self.config.cameras.keys():
# generage the ffmpeg commands
self.config.cameras[camera_name].create_ffmpeg_cmds()
# create camera_metrics # create camera_metrics
self.camera_metrics[camera_name] = { self.camera_metrics[camera_name] = {
"camera_fps": mp.Value("d", 0.0), "camera_fps": mp.Value("d", 0.0),
@@ -112,9 +108,6 @@ class FrigateApp:
maxsize=len(self.config.cameras.keys()) * 2 maxsize=len(self.config.cameras.keys()) * 2
) )
# Queue for recordings info
self.recordings_info_queue = mp.Queue()
def init_database(self): def init_database(self):
# Migrate DB location # Migrate DB location
old_db_path = os.path.join(CLIPS_DIR, "frigate.db") old_db_path = os.path.join(CLIPS_DIR, "frigate.db")
@@ -213,7 +206,6 @@ class FrigateApp:
self.event_queue, self.event_queue,
self.event_processed_queue, self.event_processed_queue,
self.video_output_queue, self.video_output_queue,
self.recordings_info_queue,
self.stop_event, self.stop_event,
) )
self.detected_frames_processor.start() self.detected_frames_processor.start()
@@ -281,9 +273,7 @@ class FrigateApp:
self.event_cleanup.start() self.event_cleanup.start()
def start_recording_maintainer(self): def start_recording_maintainer(self):
self.recording_maintainer = RecordingMaintainer( self.recording_maintainer = RecordingMaintainer(self.config, self.stop_event)
self.config, self.recordings_info_queue, self.stop_event
)
self.recording_maintainer.start() self.recording_maintainer.start()
def start_recording_cleanup(self): def start_recording_cleanup(self):
@@ -321,7 +311,6 @@ class FrigateApp:
print("*** Config Validation Errors ***") print("*** Config Validation Errors ***")
print("*************************************************************") print("*************************************************************")
print(e) print(e)
print(traceback.format_exc())
print("*************************************************************") print("*************************************************************")
print("*** End Config Validation Errors ***") print("*** End Config Validation Errors ***")
print("*************************************************************") print("*************************************************************")

View File

@@ -12,8 +12,9 @@ import yaml
from pydantic import BaseModel, Extra, Field, validator from pydantic import BaseModel, Extra, Field, validator
from pydantic.fields import PrivateAttr from pydantic.fields import PrivateAttr
from frigate.const import BASE_DIR, CACHE_DIR, YAML_EXT from frigate.const import BASE_DIR, CACHE_DIR
from frigate.util import create_mask, deep_merge, load_labels from frigate.edgetpu import load_labels
from frigate.util import create_mask, deep_merge
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -64,15 +65,8 @@ class MqttConfig(FrigateBaseModel):
return v return v
class RetainModeEnum(str, Enum):
all = "all"
motion = "motion"
active_objects = "active_objects"
class RetainConfig(FrigateBaseModel): class RetainConfig(FrigateBaseModel):
default: float = Field(default=10, title="Default retention period.") default: float = Field(default=10, title="Default retention period.")
mode: RetainModeEnum = Field(default=RetainModeEnum.motion, title="Retain mode.")
objects: Dict[str, float] = Field( objects: Dict[str, float] = Field(
default_factory=dict, title="Object retention period." default_factory=dict, title="Object retention period."
) )
@@ -94,22 +88,9 @@ class EventsConfig(FrigateBaseModel):
) )
class RecordRetainConfig(FrigateBaseModel):
days: float = Field(default=0, title="Default retention period.")
mode: RetainModeEnum = Field(default=RetainModeEnum.all, title="Retain mode.")
class RecordConfig(FrigateBaseModel): class RecordConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable record on all cameras.") enabled: bool = Field(default=False, title="Enable record on all cameras.")
expire_interval: int = Field( retain_days: float = Field(default=0, title="Recording retention period in days.")
default=60,
title="Number of minutes to wait between cleanup runs.",
)
# deprecated - to be removed in a future version
retain_days: Optional[float] = Field(title="Recording retention period in days.")
retain: RecordRetainConfig = Field(
default_factory=RecordRetainConfig, title="Record retention settings."
)
events: EventsConfig = Field( events: EventsConfig = Field(
default_factory=EventsConfig, title="Event specific settings." default_factory=EventsConfig, title="Event specific settings."
) )
@@ -162,29 +143,6 @@ class RuntimeMotionConfig(MotionConfig):
extra = Extra.ignore extra = Extra.ignore
class StationaryMaxFramesConfig(FrigateBaseModel):
default: Optional[int] = Field(title="Default max frames.", ge=1)
objects: Dict[str, int] = Field(
default_factory=dict, title="Object specific max frames."
)
class StationaryConfig(FrigateBaseModel):
interval: Optional[int] = Field(
default=0,
title="Frame interval for checking stationary objects.",
ge=0,
)
threshold: Optional[int] = Field(
title="Number of frames without a position change for an object to be considered stationary",
ge=1,
)
max_frames: StationaryMaxFramesConfig = Field(
default_factory=StationaryMaxFramesConfig,
title="Max frames for stationary objects.",
)
class DetectConfig(FrigateBaseModel): class DetectConfig(FrigateBaseModel):
height: int = Field(default=720, title="Height of the stream for the detect role.") height: int = Field(default=720, title="Height of the stream for the detect role.")
width: int = Field(default=1280, title="Width of the stream for the detect role.") width: int = Field(default=1280, title="Width of the stream for the detect role.")
@@ -195,9 +153,8 @@ class DetectConfig(FrigateBaseModel):
max_disappeared: Optional[int] = Field( max_disappeared: Optional[int] = Field(
title="Maximum number of frames the object can dissapear before detection ends." title="Maximum number of frames the object can dissapear before detection ends."
) )
stationary: StationaryConfig = Field( stationary_interval: Optional[int] = Field(
default_factory=StationaryConfig, title="Frame interval for checking stationary objects."
title="Stationary objects config.",
) )
@@ -498,7 +455,7 @@ class CameraLiveConfig(FrigateBaseModel):
class CameraConfig(FrigateBaseModel): class CameraConfig(FrigateBaseModel):
name: Optional[str] = Field(title="Camera name.", regex="^[a-zA-Z0-9_-]+$") name: Optional[str] = Field(title="Camera name.")
ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.") ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.")
best_image_timeout: int = Field( best_image_timeout: int = Field(
default=60, default=60,
@@ -562,8 +519,6 @@ class CameraConfig(FrigateBaseModel):
return self._ffmpeg_cmds return self._ffmpeg_cmds
def create_ffmpeg_cmds(self): def create_ffmpeg_cmds(self):
if "_ffmpeg_cmds" in self:
return
ffmpeg_cmds = [] ffmpeg_cmds = []
for ffmpeg_input in self.ffmpeg.inputs: for ffmpeg_input in self.ffmpeg.inputs:
ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input) ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input)
@@ -666,7 +621,7 @@ class ModelConfig(FrigateBaseModel):
return self._merged_labelmap return self._merged_labelmap
@property @property
def colormap(self) -> Dict[int, Tuple[int, int, int]]: def colormap(self) -> Dict[int, tuple[int, int, int]]:
return self._colormap return self._colormap
def __init__(self, **config): def __init__(self, **config):
@@ -788,10 +743,10 @@ class FrigateConfig(FrigateBaseModel):
if camera_config.detect.max_disappeared is None: if camera_config.detect.max_disappeared is None:
camera_config.detect.max_disappeared = max_disappeared camera_config.detect.max_disappeared = max_disappeared
# Default stationary_threshold configuration # Default stationary_interval configuration
stationary_threshold = camera_config.detect.fps * 10 stationary_interval = camera_config.detect.fps * 10
if camera_config.detect.stationary.threshold is None: if camera_config.detect.stationary_interval is None:
camera_config.detect.stationary.threshold = stationary_threshold camera_config.detect.stationary_interval = stationary_interval
# FFMPEG input substitution # FFMPEG input substitution
for input in camera_config.ffmpeg.inputs: for input in camera_config.ffmpeg.inputs:
@@ -854,30 +809,6 @@ class FrigateConfig(FrigateBaseModel):
f"Camera {name} has rtmp enabled, but rtmp is not assigned to an input." f"Camera {name} has rtmp enabled, but rtmp is not assigned to an input."
) )
# backwards compatibility for retain_days
if not camera_config.record.retain_days is None:
logger.warning(
"The 'retain_days' config option has been DEPRECATED and will be removed in a future version. Please use the 'days' setting under 'retain'"
)
if camera_config.record.retain.days == 0:
camera_config.record.retain.days = camera_config.record.retain_days
# warning if the higher level record mode is potentially more restrictive than the events
rank_map = {
RetainModeEnum.all: 0,
RetainModeEnum.motion: 1,
RetainModeEnum.active_objects: 2,
}
if (
camera_config.record.retain.days != 0
and rank_map[camera_config.record.retain.mode]
> rank_map[camera_config.record.events.retain.mode]
):
logger.warning(
f"{name}: Recording retention is configured for {camera_config.record.retain.mode} and event retention is configured for {camera_config.record.events.retain.mode}. The more restrictive retention policy will be applied."
)
# generage the ffmpeg commands
camera_config.create_ffmpeg_cmds()
config.cameras[name] = camera_config config.cameras[name] = camera_config
return config return config
@@ -895,7 +826,7 @@ class FrigateConfig(FrigateBaseModel):
with open(config_file) as f: with open(config_file) as f:
raw_config = f.read() raw_config = f.read()
if config_file.endswith(YAML_EXT): if config_file.endswith(".yml"):
config = yaml.safe_load(raw_config) config = yaml.safe_load(raw_config)
elif config_file.endswith(".json"): elif config_file.endswith(".json"):
config = json.loads(raw_config) config = json.loads(raw_config)

View File

@@ -2,4 +2,3 @@ BASE_DIR = "/media/frigate"
CLIPS_DIR = f"{BASE_DIR}/clips" CLIPS_DIR = f"{BASE_DIR}/clips"
RECORD_DIR = f"{BASE_DIR}/recordings" RECORD_DIR = f"{BASE_DIR}/recordings"
CACHE_DIR = "/tmp/cache" CACHE_DIR = "/tmp/cache"
YAML_EXT = (".yaml", ".yml")

View File

@@ -13,11 +13,31 @@ import tflite_runtime.interpreter as tflite
from setproctitle import setproctitle from setproctitle import setproctitle
from tflite_runtime.interpreter import load_delegate from tflite_runtime.interpreter import load_delegate
from frigate.util import EventsPerSecond, SharedMemoryFrameManager, listen, load_labels from frigate.util import EventsPerSecond, SharedMemoryFrameManager, listen
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def load_labels(path, encoding="utf-8"):
"""Loads labels from file (with or without index numbers).
Args:
path: path to label file.
encoding: label file encoding.
Returns:
Dictionary mapping indices to labels.
"""
with open(path, "r", encoding=encoding) as f:
lines = f.readlines()
if not lines:
return {}
if lines[0].split(" ", maxsplit=1)[0].isdigit():
pairs = [line.split(" ", maxsplit=1) for line in lines]
return {int(index): label.strip() for index, label in pairs}
else:
return {index: line.strip() for index, line in enumerate(lines)}
class ObjectDetector(ABC): class ObjectDetector(ABC):
@abstractmethod @abstractmethod
def detect(self, tensor_input, threshold=0.4): def detect(self, tensor_input, threshold=0.4):

View File

@@ -15,16 +15,6 @@ from frigate.models import Event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def should_update_db(prev_event, current_event):
return (
prev_event["top_score"] != current_event["top_score"]
or prev_event["entered_zones"] != current_event["entered_zones"]
or prev_event["thumbnail"] != current_event["thumbnail"]
or prev_event["has_clip"] != current_event["has_clip"]
or prev_event["has_snapshot"] != current_event["has_snapshot"]
)
class EventProcessor(threading.Thread): class EventProcessor(threading.Thread):
def __init__( def __init__(
self, config, camera_processes, event_queue, event_processed_queue, stop_event self, config, camera_processes, event_queue, event_processed_queue, stop_event
@@ -58,9 +48,7 @@ class EventProcessor(threading.Thread):
if event_type == "start": if event_type == "start":
self.events_in_process[event_data["id"]] = event_data self.events_in_process[event_data["id"]] = event_data
elif event_type == "update" and should_update_db( elif event_type == "update":
self.events_in_process[event_data["id"]], event_data
):
self.events_in_process[event_data["id"]] = event_data self.events_in_process[event_data["id"]] = event_data
# TODO: this will generate a lot of db activity possibly # TODO: this will generate a lot of db activity possibly
if event_data["has_clip"] or event_data["has_snapshot"]: if event_data["has_clip"] or event_data["has_snapshot"]:

View File

@@ -133,8 +133,6 @@ def delete_event(id):
if event.has_snapshot: if event.has_snapshot:
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg") media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
media.unlink(missing_ok=True) media.unlink(missing_ok=True)
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
media.unlink(missing_ok=True)
if event.has_clip: if event.has_clip:
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4") media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
media.unlink(missing_ok=True) media.unlink(missing_ok=True)
@@ -184,7 +182,7 @@ def event_thumbnail(id):
thumbnail_bytes = jpg.tobytes() thumbnail_bytes = jpg.tobytes()
response = make_response(thumbnail_bytes) response = make_response(thumbnail_bytes)
response.headers["Content-Type"] = "image/jpeg" response.headers["Content-Type"] = "image/jpg"
return response return response
@@ -225,7 +223,7 @@ def event_snapshot(id):
return "Event not found", 404 return "Event not found", 404
response = make_response(jpg_bytes) response = make_response(jpg_bytes)
response.headers["Content-Type"] = "image/jpeg" response.headers["Content-Type"] = "image/jpg"
if download: if download:
response.headers[ response.headers[
"Content-Disposition" "Content-Disposition"
@@ -249,10 +247,7 @@ def event_clip(id):
clip_path = os.path.join(CLIPS_DIR, file_name) clip_path = os.path.join(CLIPS_DIR, file_name)
if not os.path.isfile(clip_path): if not os.path.isfile(clip_path):
end_ts = ( return recording_clip(event.camera, event.start_time, event.end_time)
datetime.now().timestamp() if event.end_time is None else event.end_time
)
return recording_clip(event.camera, event.start_time, end_ts)
response = make_response() response = make_response()
response.headers["Content-Description"] = "File Transfer" response.headers["Content-Description"] = "File Transfer"
@@ -364,16 +359,9 @@ def best(camera_name, label):
crop = bool(request.args.get("crop", 0, type=int)) crop = bool(request.args.get("crop", 0, type=int))
if crop: if crop:
box_size = 300 box = best_object.get("box", (0, 0, 300, 300))
box = best_object.get("box", (0, 0, box_size, box_size))
region = calculate_region( region = calculate_region(
best_frame.shape, best_frame.shape, box[0], box[1], box[2], box[3], 1.1
box[0],
box[1],
box[2],
box[3],
box_size,
multiplier=1.1,
) )
best_frame = best_frame[region[1] : region[3], region[0] : region[2]] best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
@@ -388,7 +376,7 @@ def best(camera_name, label):
".jpg", best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality] ".jpg", best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality]
) )
response = make_response(jpg.tobytes()) response = make_response(jpg.tobytes())
response.headers["Content-Type"] = "image/jpeg" response.headers["Content-Type"] = "image/jpg"
return response return response
else: else:
return "Camera named {} not found".format(camera_name), 404 return "Camera named {} not found".format(camera_name), 404
@@ -450,7 +438,7 @@ def latest_frame(camera_name):
".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality] ".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality]
) )
response = make_response(jpg.tobytes()) response = make_response(jpg.tobytes())
response.headers["Content-Type"] = "image/jpeg" response.headers["Content-Type"] = "image/jpg"
return response return response
else: else:
return "Camera named {} not found".format(camera_name), 404 return "Camera named {} not found".format(camera_name), 404
@@ -527,17 +515,12 @@ def recordings(camera_name):
FROM C2 FROM C2
WHERE cnt = 0 WHERE cnt = 0
) )
SELECT id, label, camera, top_score, start_time, end_time
FROM event
WHERE camera = ? AND end_time IS NULL
UNION ALL
SELECT MIN(id) as id, label, camera, MAX(top_score) as top_score, MIN(ts) AS start_time, max(ts) AS end_time SELECT MIN(id) as id, label, camera, MAX(top_score) as top_score, MIN(ts) AS start_time, max(ts) AS end_time
FROM C3 FROM C3
GROUP BY label, grpnum GROUP BY label, grpnum
ORDER BY start_time;""", ORDER BY start_time;""",
camera_name, camera_name,
camera_name, camera_name,
camera_name,
) )
event: Event event: Event
@@ -675,15 +658,10 @@ def vod_ts(camera, start_ts, end_ts):
# Determine if we need to end the last clip early # Determine if we need to end the last clip early
if recording.end_time > end_ts: if recording.end_time > end_ts:
duration -= int((recording.end_time - end_ts) * 1000) duration -= int((recording.end_time - end_ts) * 1000)
clips.append(clip)
if duration > 0: durations.append(duration)
clips.append(clip)
durations.append(duration)
else:
logger.warning(f"Recording clip is missing or empty: {recording.path}")
if not clips: if not clips:
logger.error("No recordings found for the requested time range")
return "No recordings found.", 404 return "No recordings found.", 404
hour_ago = datetime.now() - timedelta(hours=1) hour_ago = datetime.now() - timedelta(hours=1)
@@ -712,12 +690,10 @@ def vod_event(id):
try: try:
event: Event = Event.get(Event.id == id) event: Event = Event.get(Event.id == id)
except DoesNotExist: except DoesNotExist:
logger.error(f"Event not found: {id}")
return "Event not found.", 404 return "Event not found.", 404
if not event.has_clip: if not event.has_clip:
logger.error(f"Event does not have recordings: {id}") return "Clip not available", 404
return "Recordings not available", 404
clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{id}.mp4") clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{id}.mp4")
@@ -725,15 +701,7 @@ def vod_event(id):
end_ts = ( end_ts = (
datetime.now().timestamp() if event.end_time is None else event.end_time datetime.now().timestamp() if event.end_time is None else event.end_time
) )
vod_response = vod_ts(event.camera, event.start_time, end_ts) return vod_ts(event.camera, event.start_time, end_ts)
# If the recordings are not found, set has_clip to false
if (
type(vod_response) == tuple
and len(vod_response) == 2
and vod_response[1] == 404
):
Event.update(has_clip=False).where(Event.id == id).execute()
return vod_response
duration = int((event.end_time - event.start_time) * 1000) duration = int((event.end_time - event.start_time) * 1000)
return jsonify( return jsonify(

View File

@@ -27,5 +27,3 @@ class Recordings(Model):
start_time = DateTimeField() start_time = DateTimeField()
end_time = DateTimeField() end_time = DateTimeField()
duration = FloatField() duration = FloatField()
motion = IntegerField(null=True)
objects = IntegerField(null=True)

View File

@@ -40,12 +40,10 @@ class MotionDetector:
# Improve contrast # Improve contrast
minval = np.percentile(resized_frame, 4) minval = np.percentile(resized_frame, 4)
maxval = np.percentile(resized_frame, 96) maxval = np.percentile(resized_frame, 96)
# don't adjust if the image is a single color resized_frame = np.clip(resized_frame, minval, maxval)
if minval < maxval: resized_frame = (((resized_frame - minval) / (maxval - minval)) * 255).astype(
resized_frame = np.clip(resized_frame, minval, maxval) np.uint8
resized_frame = ( )
((resized_frame - minval) / (maxval - minval)) * 255
).astype(np.uint8)
# mask frame # mask frame
resized_frame[self.mask] = [255] resized_frame[self.mask] = [255]

View File

@@ -107,7 +107,7 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
+ str(rc) + str(rc)
) )
logger.debug("MQTT connected") logger.info("MQTT connected")
client.subscribe(f"{mqtt_config.topic_prefix}/#") client.subscribe(f"{mqtt_config.topic_prefix}/#")
client.publish(mqtt_config.topic_prefix + "/available", "online", retain=True) client.publish(mqtt_config.topic_prefix + "/available", "online", retain=True)

View File

@@ -18,12 +18,12 @@ import numpy as np
from frigate.config import CameraConfig, SnapshotsConfig, RecordConfig, FrigateConfig from frigate.config import CameraConfig, SnapshotsConfig, RecordConfig, FrigateConfig
from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR
from frigate.edgetpu import load_labels
from frigate.util import ( from frigate.util import (
SharedMemoryFrameManager, SharedMemoryFrameManager,
calculate_region, calculate_region,
draw_box_with_label, draw_box_with_label,
draw_timestamp, draw_timestamp,
load_labels,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -71,7 +71,7 @@ class TrackedObject:
self.camera_config = camera_config self.camera_config = camera_config
self.frame_cache = frame_cache self.frame_cache = frame_cache
self.current_zones = [] self.current_zones = []
self.entered_zones = [] self.entered_zones = set()
self.false_positive = True self.false_positive = True
self.has_clip = False self.has_clip = False
self.has_snapshot = False self.has_snapshot = False
@@ -101,13 +101,14 @@ class TrackedObject:
return median(scores) return median(scores)
def update(self, current_frame_time, obj_data): def update(self, current_frame_time, obj_data):
thumb_update = False significant_update = False
significant_change = False zone_change = False
self.obj_data.update(obj_data)
# if the object is not in the current frame, add a 0.0 to the score history # if the object is not in the current frame, add a 0.0 to the score history
if obj_data["frame_time"] != current_frame_time: if self.obj_data["frame_time"] != current_frame_time:
self.score_history.append(0.0) self.score_history.append(0.0)
else: else:
self.score_history.append(obj_data["score"]) self.score_history.append(self.obj_data["score"])
# only keep the last 10 scores # only keep the last 10 scores
if len(self.score_history) > 10: if len(self.score_history) > 10:
self.score_history = self.score_history[-10:] self.score_history = self.score_history[-10:]
@@ -121,24 +122,24 @@ class TrackedObject:
if not self.false_positive: if not self.false_positive:
# determine if this frame is a better thumbnail # determine if this frame is a better thumbnail
if self.thumbnail_data is None or is_better_thumbnail( if self.thumbnail_data is None or is_better_thumbnail(
self.thumbnail_data, obj_data, self.camera_config.frame_shape self.thumbnail_data, self.obj_data, self.camera_config.frame_shape
): ):
self.thumbnail_data = { self.thumbnail_data = {
"frame_time": obj_data["frame_time"], "frame_time": self.obj_data["frame_time"],
"box": obj_data["box"], "box": self.obj_data["box"],
"area": obj_data["area"], "area": self.obj_data["area"],
"region": obj_data["region"], "region": self.obj_data["region"],
"score": obj_data["score"], "score": self.obj_data["score"],
} }
thumb_update = True significant_update = True
# check zones # check zones
current_zones = [] current_zones = []
bottom_center = (obj_data["centroid"][0], obj_data["box"][3]) bottom_center = (self.obj_data["centroid"][0], self.obj_data["box"][3])
# check each zone # check each zone
for name, zone in self.camera_config.zones.items(): for name, zone in self.camera_config.zones.items():
# if the zone is not for this object type, skip # if the zone is not for this object type, skip
if len(zone.objects) > 0 and not obj_data["label"] in zone.objects: if len(zone.objects) > 0 and not self.obj_data["label"] in zone.objects:
continue continue
contour = zone.contour contour = zone.contour
# check if the object is in the zone # check if the object is in the zone
@@ -146,32 +147,14 @@ class TrackedObject:
# if the object passed the filters once, dont apply again # if the object passed the filters once, dont apply again
if name in self.current_zones or not zone_filtered(self, zone.filters): if name in self.current_zones or not zone_filtered(self, zone.filters):
current_zones.append(name) current_zones.append(name)
if name not in self.entered_zones: self.entered_zones.add(name)
self.entered_zones.append(name)
if not self.false_positive: # if the zones changed, signal an update
# if the zones changed, signal an update if not self.false_positive and set(self.current_zones) != set(current_zones):
if set(self.current_zones) != set(current_zones): zone_change = True
significant_change = True
# if the position changed, signal an update
if self.obj_data["position_changes"] != obj_data["position_changes"]:
significant_change = True
# if the motionless_count reaches the stationary threshold
if (
self.obj_data["motionless_count"]
== self.camera_config.detect.stationary.threshold
):
significant_change = True
# update at least once per minute
if self.obj_data["frame_time"] - self.previous["frame_time"] > 60:
significant_change = True
self.obj_data.update(obj_data)
self.current_zones = current_zones self.current_zones = current_zones
return (thumb_update, significant_change) return (significant_update, zone_change)
def to_dict(self, include_thumbnail: bool = False): def to_dict(self, include_thumbnail: bool = False):
snapshot_time = ( snapshot_time = (
@@ -193,12 +176,8 @@ class TrackedObject:
"box": self.obj_data["box"], "box": self.obj_data["box"],
"area": self.obj_data["area"], "area": self.obj_data["area"],
"region": self.obj_data["region"], "region": self.obj_data["region"],
"stationary": self.obj_data["motionless_count"]
> self.camera_config.detect.stationary.threshold,
"motionless_count": self.obj_data["motionless_count"],
"position_changes": self.obj_data["position_changes"],
"current_zones": self.current_zones.copy(), "current_zones": self.current_zones.copy(),
"entered_zones": self.entered_zones.copy(), "entered_zones": list(self.entered_zones).copy(),
"has_clip": self.has_clip, "has_clip": self.has_clip,
"has_snapshot": self.has_snapshot, "has_snapshot": self.has_snapshot,
} }
@@ -283,15 +262,8 @@ class TrackedObject:
if crop: if crop:
box = self.thumbnail_data["box"] box = self.thumbnail_data["box"]
box_size = 300
region = calculate_region( region = calculate_region(
best_frame.shape, best_frame.shape, box[0], box[1], box[2], box[3], 1.1
box[0],
box[1],
box[2],
box[3],
box_size,
multiplier=1.1,
) )
best_frame = best_frame[region[1] : region[3], region[0] : region[2]] best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
@@ -484,11 +456,11 @@ class CameraState:
for id in updated_ids: for id in updated_ids:
updated_obj = tracked_objects[id] updated_obj = tracked_objects[id]
thumb_update, significant_update = updated_obj.update( significant_update, zone_change = updated_obj.update(
frame_time, current_detections[id] frame_time, current_detections[id]
) )
if thumb_update: if significant_update:
# ensure this frame is stored in the cache # ensure this frame is stored in the cache
if ( if (
updated_obj.thumbnail_data["frame_time"] == frame_time updated_obj.thumbnail_data["frame_time"] == frame_time
@@ -498,13 +470,13 @@ class CameraState:
updated_obj.last_updated = frame_time updated_obj.last_updated = frame_time
# if it has been more than 5 seconds since the last thumb update # if it has been more than 5 seconds since the last publish
# and the last update is greater than the last publish or # and the last update is greater than the last publish or
# the object has changed significantly # the object has changed zones
if ( if (
frame_time - updated_obj.last_published > 5 frame_time - updated_obj.last_published > 5
and updated_obj.last_updated > updated_obj.last_published and updated_obj.last_updated > updated_obj.last_published
) or significant_update: ) or zone_change:
# call event handlers # call event handlers
for c in self.callbacks["update"]: for c in self.callbacks["update"]:
c(self.name, updated_obj, frame_time) c(self.name, updated_obj, frame_time)
@@ -612,7 +584,6 @@ class TrackedObjectProcessor(threading.Thread):
event_queue, event_queue,
event_processed_queue, event_processed_queue,
video_output_queue, video_output_queue,
recordings_info_queue,
stop_event, stop_event,
): ):
threading.Thread.__init__(self) threading.Thread.__init__(self)
@@ -624,7 +595,6 @@ class TrackedObjectProcessor(threading.Thread):
self.event_queue = event_queue self.event_queue = event_queue
self.event_processed_queue = event_processed_queue self.event_processed_queue = event_processed_queue
self.video_output_queue = video_output_queue self.video_output_queue = video_output_queue
self.recordings_info_queue = recordings_info_queue
self.stop_event = stop_event self.stop_event = stop_event
self.camera_states: Dict[str, CameraState] = {} self.camera_states: Dict[str, CameraState] = {}
self.frame_manager = SharedMemoryFrameManager() self.frame_manager = SharedMemoryFrameManager()
@@ -757,13 +727,9 @@ class TrackedObjectProcessor(threading.Thread):
if not snapshot_config.enabled: if not snapshot_config.enabled:
return False return False
# object never changed position
if obj.obj_data["position_changes"] == 0:
return False
# if there are required zones and there is no overlap # if there are required zones and there is no overlap
required_zones = snapshot_config.required_zones required_zones = snapshot_config.required_zones
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones): if len(required_zones) > 0 and not obj.entered_zones & set(required_zones):
logger.debug( logger.debug(
f"Not creating snapshot for {obj.obj_data['id']} because it did not enter required zones" f"Not creating snapshot for {obj.obj_data['id']} because it did not enter required zones"
) )
@@ -781,10 +747,6 @@ class TrackedObjectProcessor(threading.Thread):
if not record_config.enabled: if not record_config.enabled:
return False return False
# object never changed position
if obj.obj_data["position_changes"] == 0:
return False
# If there are required zones and there is no overlap # If there are required zones and there is no overlap
required_zones = record_config.events.required_zones required_zones = record_config.events.required_zones
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones): if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
@@ -806,13 +768,9 @@ class TrackedObjectProcessor(threading.Thread):
return True return True
def should_mqtt_snapshot(self, camera, obj: TrackedObject): def should_mqtt_snapshot(self, camera, obj: TrackedObject):
# object never changed position
if obj.obj_data["position_changes"] == 0:
return False
# if there are required zones and there is no overlap # if there are required zones and there is no overlap
required_zones = self.config.cameras[camera].mqtt.required_zones required_zones = self.config.cameras[camera].mqtt.required_zones
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones): if len(required_zones) > 0 and not obj.entered_zones & set(required_zones):
logger.debug( logger.debug(
f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones" f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones"
) )
@@ -855,26 +813,11 @@ class TrackedObjectProcessor(threading.Thread):
frame_time, current_tracked_objects, motion_boxes, regions frame_time, current_tracked_objects, motion_boxes, regions
) )
tracked_objects = [
o.to_dict() for o in camera_state.tracked_objects.values()
]
self.video_output_queue.put( self.video_output_queue.put(
( (
camera, camera,
frame_time, frame_time,
tracked_objects, current_tracked_objects,
motion_boxes,
regions,
)
)
# send info on this frame to the recordings maintainer
self.recordings_info_queue.put(
(
camera,
frame_time,
tracked_objects,
motion_boxes, motion_boxes,
regions, regions,
) )

View File

@@ -20,9 +20,7 @@ class ObjectTracker:
def __init__(self, config: DetectConfig): def __init__(self, config: DetectConfig):
self.tracked_objects = {} self.tracked_objects = {}
self.disappeared = {} self.disappeared = {}
self.positions = {}
self.max_disappeared = config.max_disappeared self.max_disappeared = config.max_disappeared
self.detect_config = config
def register(self, index, obj): def register(self, index, obj):
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
@@ -30,116 +28,24 @@ class ObjectTracker:
obj["id"] = id obj["id"] = id
obj["start_time"] = obj["frame_time"] obj["start_time"] = obj["frame_time"]
obj["motionless_count"] = 0 obj["motionless_count"] = 0
obj["position_changes"] = 0
self.tracked_objects[id] = obj self.tracked_objects[id] = obj
self.disappeared[id] = 0 self.disappeared[id] = 0
self.positions[id] = {
"xmins": [],
"ymins": [],
"xmaxs": [],
"ymaxs": [],
"xmin": 0,
"ymin": 0,
"xmax": self.detect_config.width,
"ymax": self.detect_config.height,
}
def deregister(self, id): def deregister(self, id):
del self.tracked_objects[id] del self.tracked_objects[id]
del self.disappeared[id] del self.disappeared[id]
# tracks the current position of the object based on the last N bounding boxes
# returns False if the object has moved outside its previous position
def update_position(self, id, box):
position = self.positions[id]
position_box = (
position["xmin"],
position["ymin"],
position["xmax"],
position["ymax"],
)
xmin, ymin, xmax, ymax = box
iou = intersection_over_union(position_box, box)
# if the iou drops below the threshold
# assume the object has moved to a new position and reset the computed box
if iou < 0.6:
self.positions[id] = {
"xmins": [xmin],
"ymins": [ymin],
"xmaxs": [xmax],
"ymaxs": [ymax],
"xmin": xmin,
"ymin": ymin,
"xmax": xmax,
"ymax": ymax,
}
return False
# if there are less than 10 entries for the position, add the bounding box
# and recompute the position box
if len(position["xmins"]) < 10:
position["xmins"].append(xmin)
position["ymins"].append(ymin)
position["xmaxs"].append(xmax)
position["ymaxs"].append(ymax)
# by using percentiles here, we hopefully remove outliers
position["xmin"] = np.percentile(position["xmins"], 15)
position["ymin"] = np.percentile(position["ymins"], 15)
position["xmax"] = np.percentile(position["xmaxs"], 85)
position["ymax"] = np.percentile(position["ymaxs"], 85)
return True
def is_expired(self, id):
obj = self.tracked_objects[id]
# get the max frames for this label type or the default
max_frames = self.detect_config.stationary.max_frames.objects.get(
obj["label"], self.detect_config.stationary.max_frames.default
)
# if there is no max_frames for this label type, continue
if max_frames is None:
return False
# if the object has exceeded the max_frames setting, deregister
if (
obj["motionless_count"] - self.detect_config.stationary.threshold
> max_frames
):
print(f"expired: {obj['motionless_count']}")
return True
def update(self, id, new_obj): def update(self, id, new_obj):
self.disappeared[id] = 0 self.disappeared[id] = 0
# update the motionless count if the object has not moved to a new position if (
if self.update_position(id, new_obj["box"]): intersection_over_union(self.tracked_objects[id]["box"], new_obj["box"])
> 0.9
):
self.tracked_objects[id]["motionless_count"] += 1 self.tracked_objects[id]["motionless_count"] += 1
if self.is_expired(id):
self.deregister(id)
return
else: else:
# register the first position change and then only increment if
# the object was previously stationary
if (
self.tracked_objects[id]["position_changes"] == 0
or self.tracked_objects[id]["motionless_count"]
>= self.detect_config.stationary.threshold
):
self.tracked_objects[id]["position_changes"] += 1
self.tracked_objects[id]["motionless_count"] = 0 self.tracked_objects[id]["motionless_count"] = 0
self.tracked_objects[id].update(new_obj) self.tracked_objects[id].update(new_obj)
def update_frame_times(self, frame_time):
for id in list(self.tracked_objects.keys()):
self.tracked_objects[id]["frame_time"] = frame_time
self.tracked_objects[id]["motionless_count"] += 1
if self.is_expired(id):
self.deregister(id)
def match_and_update(self, frame_time, new_objects): def match_and_update(self, frame_time, new_objects):
# group by name # group by name
new_object_groups = defaultdict(lambda: []) new_object_groups = defaultdict(lambda: [])

View File

@@ -184,7 +184,10 @@ class BirdsEyeFrameManager:
if self.mode == BirdseyeModeEnum.continuous: if self.mode == BirdseyeModeEnum.continuous:
return True return True
if self.mode == BirdseyeModeEnum.motion and motion_box_count > 0: if (
self.mode == BirdseyeModeEnum.motion
and object_box_count + motion_box_count > 0
):
return True return True
if self.mode == BirdseyeModeEnum.objects and object_box_count > 0: if self.mode == BirdseyeModeEnum.objects and object_box_count > 0:
@@ -415,7 +418,7 @@ def output_frames(config: FrigateConfig, video_output_queue):
): ):
if birdseye_manager.update( if birdseye_manager.update(
camera, camera,
len([o for o in current_tracked_objects if not o["stationary"]]), len(current_tracked_objects),
len(motion_boxes), len(motion_boxes),
frame_time, frame_time,
frame, frame,

View File

@@ -1,25 +1,22 @@
import datetime import datetime
import time
import itertools import itertools
import logging import logging
import multiprocessing as mp
import os import os
import queue
import random import random
import shutil import shutil
import string import string
import subprocess as sp import subprocess as sp
import threading import threading
import time
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
import psutil import psutil
from peewee import JOIN, DoesNotExist from peewee import JOIN, DoesNotExist
from frigate.config import RetainModeEnum, FrigateConfig from frigate.config import FrigateConfig
from frigate.const import CACHE_DIR, RECORD_DIR from frigate.const import CACHE_DIR, RECORD_DIR
from frigate.models import Event, Recordings from frigate.models import Event, Recordings
from frigate.util import area
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -43,27 +40,20 @@ def remove_empty_directories(directory):
class RecordingMaintainer(threading.Thread): class RecordingMaintainer(threading.Thread):
def __init__( def __init__(self, config: FrigateConfig, stop_event):
self, config: FrigateConfig, recordings_info_queue: mp.Queue, stop_event
):
threading.Thread.__init__(self) threading.Thread.__init__(self)
self.name = "recording_maint" self.name = "recording_maint"
self.config = config self.config = config
self.recordings_info_queue = recordings_info_queue
self.stop_event = stop_event self.stop_event = stop_event
self.recordings_info = defaultdict(list)
self.end_time_cache = {}
def move_files(self): def move_files(self):
cache_files = sorted( cache_files = [
[ d
d for d in os.listdir(CACHE_DIR)
for d in os.listdir(CACHE_DIR) if os.path.isfile(os.path.join(CACHE_DIR, d))
if os.path.isfile(os.path.join(CACHE_DIR, d)) and d.endswith(".mp4")
and d.endswith(".mp4") and not d.startswith("clip_")
and not d.startswith("clip_") ]
]
)
files_in_use = [] files_in_use = []
for process in psutil.process_iter(): for process in psutil.process_iter():
@@ -97,26 +87,21 @@ class RecordingMaintainer(threading.Thread):
} }
) )
# delete all cached files past the most recent 5 # delete all cached files past the most recent 2
keep_count = 5
for camera in grouped_recordings.keys(): for camera in grouped_recordings.keys():
if len(grouped_recordings[camera]) > keep_count: if len(grouped_recordings[camera]) > 2:
to_remove = grouped_recordings[camera][:-keep_count] logger.warning(
"Proactively cleaning cache. Your recordings disk may be too slow."
)
sorted_recordings = sorted(
grouped_recordings[camera], key=lambda i: i["start_time"]
)
to_remove = sorted_recordings[:-2]
for f in to_remove: for f in to_remove:
Path(f["cache_path"]).unlink(missing_ok=True) Path(f["cache_path"]).unlink(missing_ok=True)
self.end_time_cache.pop(f["cache_path"], None) grouped_recordings[camera] = sorted_recordings[-2:]
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
for camera, recordings in grouped_recordings.items(): for camera, recordings in grouped_recordings.items():
# clear out all the recording info for old frames
while (
len(self.recordings_info[camera]) > 0
and self.recordings_info[camera][0][0]
< recordings[0]["start_time"].timestamp()
):
self.recordings_info[camera].pop(0)
# get all events with the end time after the start of the oldest cache file # get all events with the end time after the start of the oldest cache file
# or with end_time None # or with end_time None
events: Event = ( events: Event = (
@@ -124,7 +109,7 @@ class RecordingMaintainer(threading.Thread):
.where( .where(
Event.camera == camera, Event.camera == camera,
(Event.end_time == None) (Event.end_time == None)
| (Event.end_time >= recordings[0]["start_time"].timestamp()), | (Event.end_time >= recordings[0]["start_time"]),
Event.has_clip, Event.has_clip,
) )
.order_by(Event.start_time) .order_by(Event.start_time)
@@ -139,38 +124,33 @@ class RecordingMaintainer(threading.Thread):
or not self.config.cameras[camera].record.enabled or not self.config.cameras[camera].record.enabled
): ):
Path(cache_path).unlink(missing_ok=True) Path(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None)
continue continue
if cache_path in self.end_time_cache: ffprobe_cmd = [
end_time, duration = self.end_time_cache[cache_path] "ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
f"{cache_path}",
]
p = sp.run(ffprobe_cmd, capture_output=True)
if p.returncode == 0:
duration = float(p.stdout.decode().strip())
end_time = start_time + datetime.timedelta(seconds=duration)
else: else:
ffprobe_cmd = [ logger.warning(f"Discarding a corrupt recording segment: {f}")
"ffprobe", Path(cache_path).unlink(missing_ok=True)
"-v", continue
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
f"{cache_path}",
]
p = sp.run(ffprobe_cmd, capture_output=True)
if p.returncode == 0:
duration = float(p.stdout.decode().strip())
end_time = start_time + datetime.timedelta(seconds=duration)
self.end_time_cache[cache_path] = (end_time, duration)
else:
logger.warning(f"Discarding a corrupt recording segment: {f}")
Path(cache_path).unlink(missing_ok=True)
continue
# if cached file's start_time is earlier than the retain days for the camera # if cached file's start_time is earlier than the retain_days for the camera
if start_time <= ( if start_time <= (
( (
datetime.datetime.now() datetime.datetime.now()
- datetime.timedelta( - datetime.timedelta(
days=self.config.cameras[camera].record.retain.days days=self.config.cameras[camera].record.retain_days
) )
) )
): ):
@@ -178,26 +158,18 @@ class RecordingMaintainer(threading.Thread):
overlaps = False overlaps = False
for event in events: for event in events:
# if the event starts in the future, stop checking events # if the event starts in the future, stop checking events
# and remove this segment # and let this recording segment expire
if event.start_time > end_time.timestamp(): if event.start_time > end_time.timestamp():
overlaps = False overlaps = False
Path(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None)
break break
# if the event is in progress or ends after the recording starts, keep it # if the event is in progress or ends after the recording starts, keep it
# and stop looking at events # and stop looking at events
if ( if event.end_time is None or event.end_time >= start_time:
event.end_time is None
or event.end_time >= start_time.timestamp()
):
overlaps = True overlaps = True
break break
if overlaps: if overlaps:
record_mode = self.config.cameras[
camera
].record.events.retain.mode
# move from cache to recordings immediately # move from cache to recordings immediately
self.store_segment( self.store_segment(
camera, camera,
@@ -205,57 +177,14 @@ class RecordingMaintainer(threading.Thread):
end_time, end_time,
duration, duration,
cache_path, cache_path,
record_mode,
) )
# else retain days includes this segment # else retain_days includes this segment
else: else:
record_mode = self.config.cameras[camera].record.retain.mode
self.store_segment( self.store_segment(
camera, start_time, end_time, duration, cache_path, record_mode camera, start_time, end_time, duration, cache_path
) )
def segment_stats(self, camera, start_time, end_time): def store_segment(self, camera, start_time, end_time, duration, cache_path):
active_count = 0
motion_count = 0
for frame in self.recordings_info[camera]:
# frame is after end time of segment
if frame[0] > end_time.timestamp():
break
# frame is before start time of segment
if frame[0] < start_time.timestamp():
continue
active_count += len(
[
o
for o in frame[1]
if not o["false_positive"] and o["motionless_count"] == 0
]
)
motion_count += sum([area(box) for box in frame[2]])
return (motion_count, active_count)
def store_segment(
self,
camera,
start_time,
end_time,
duration,
cache_path,
store_mode: RetainModeEnum,
):
motion_count, active_count = self.segment_stats(camera, start_time, end_time)
# check if the segment shouldn't be stored
if (store_mode == RetainModeEnum.motion and motion_count == 0) or (
store_mode == RetainModeEnum.active_objects and active_count == 0
):
Path(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None)
return
directory = os.path.join(RECORD_DIR, start_time.strftime("%Y-%m/%d/%H"), camera) directory = os.path.join(RECORD_DIR, start_time.strftime("%Y-%m/%d/%H"), camera)
if not os.path.exists(directory): if not os.path.exists(directory):
@@ -283,47 +212,17 @@ class RecordingMaintainer(threading.Thread):
start_time=start_time.timestamp(), start_time=start_time.timestamp(),
end_time=end_time.timestamp(), end_time=end_time.timestamp(),
duration=duration, duration=duration,
motion=motion_count,
# TODO: update this to store list of active objects at some point
objects=active_count,
) )
except Exception as e: except Exception as e:
logger.error(f"Unable to store recording segment {cache_path}") logger.error(f"Unable to store recording segment {cache_path}")
Path(cache_path).unlink(missing_ok=True) Path(cache_path).unlink(missing_ok=True)
logger.error(e) logger.error(e)
# clear end_time cache
self.end_time_cache.pop(cache_path, None)
def run(self): def run(self):
# Check for new files every 5 seconds # Check for new files every 5 seconds
wait_time = 5 wait_time = 5
while not self.stop_event.wait(wait_time): while not self.stop_event.wait(wait_time):
run_start = datetime.datetime.now().timestamp() run_start = datetime.datetime.now().timestamp()
# empty the recordings info queue
while True:
try:
(
camera,
frame_time,
current_tracked_objects,
motion_boxes,
regions,
) = self.recordings_info_queue.get(False)
if self.config.cameras[camera].record.enabled:
self.recordings_info[camera].append(
(
frame_time,
current_tracked_objects,
motion_boxes,
regions,
)
)
except queue.Empty:
break
try: try:
self.move_files() self.move_files()
except Exception as e: except Exception as e:
@@ -331,8 +230,7 @@ class RecordingMaintainer(threading.Thread):
"Error occurred when attempting to maintain recording cache" "Error occurred when attempting to maintain recording cache"
) )
logger.error(e) logger.error(e)
duration = datetime.datetime.now().timestamp() - run_start wait_time = max(0, 5 - (datetime.datetime.now().timestamp() - run_start))
wait_time = max(0, 5 - duration)
logger.info(f"Exiting recording maintenance...") logger.info(f"Exiting recording maintenance...")
@@ -357,7 +255,7 @@ class RecordingCleanup(threading.Thread):
logger.debug("Start deleted cameras.") logger.debug("Start deleted cameras.")
# Handle deleted cameras # Handle deleted cameras
expire_days = self.config.record.retain.days expire_days = self.config.record.retain_days
expire_before = ( expire_before = (
datetime.datetime.now() - datetime.timedelta(days=expire_days) datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp() ).timestamp()
@@ -383,7 +281,7 @@ class RecordingCleanup(threading.Thread):
datetime.datetime.now() datetime.datetime.now()
- datetime.timedelta(seconds=config.record.events.max_seconds) - datetime.timedelta(seconds=config.record.events.max_seconds)
).timestamp() ).timestamp()
expire_days = config.record.retain.days expire_days = config.record.retain_days
expire_before = ( expire_before = (
datetime.datetime.now() - datetime.timedelta(days=expire_days) datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp() ).timestamp()
@@ -414,7 +312,6 @@ class RecordingCleanup(threading.Thread):
) )
# loop over recordings and see if they overlap with any non-expired events # 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 event_start = 0
deleted_recordings = set() deleted_recordings = set()
for recording in recordings.objects().iterator(): for recording in recordings.objects().iterator():
@@ -442,19 +339,8 @@ class RecordingCleanup(threading.Thread):
if event.end_time < recording.start_time: if event.end_time < recording.start_time:
event_start = idx event_start = idx
# Delete recordings outside of the retention window or based on the retention mode # Delete recordings outside of the retention window
if ( if not keep:
not keep
or (
config.record.events.retain.mode == RetainModeEnum.motion
and recording.motion == 0
)
or (
config.record.events.retain.mode
== RetainModeEnum.active_objects
and recording.objects == 0
)
):
Path(recording.path).unlink(missing_ok=True) Path(recording.path).unlink(missing_ok=True)
deleted_recordings.add(recording.id) deleted_recordings.add(recording.id)
@@ -471,14 +357,14 @@ class RecordingCleanup(threading.Thread):
default_expire = ( default_expire = (
datetime.datetime.now().timestamp() datetime.datetime.now().timestamp()
- SECONDS_IN_DAY * self.config.record.retain.days - SECONDS_IN_DAY * self.config.record.retain_days
) )
delete_before = {} delete_before = {}
for name, camera in self.config.cameras.items(): for name, camera in self.config.cameras.items():
delete_before[name] = ( delete_before[name] = (
datetime.datetime.now().timestamp() datetime.datetime.now().timestamp()
- SECONDS_IN_DAY * camera.record.retain.days - SECONDS_IN_DAY * camera.record.retain_days
) )
# find all the recordings older than the oldest recording in the db # find all the recordings older than the oldest recording in the db
@@ -491,8 +377,7 @@ class RecordingCleanup(threading.Thread):
oldest_timestamp = datetime.datetime.now().timestamp() oldest_timestamp = datetime.datetime.now().timestamp()
except FileNotFoundError: except FileNotFoundError:
logger.warning(f"Unable to find file from recordings database: {p}") logger.warning(f"Unable to find file from recordings database: {p}")
Recordings.delete().where(Recordings.id == oldest_recording.id).execute() oldest_timestamp = datetime.datetime.now().timestamp()
return
logger.debug(f"Oldest recording in the db: {oldest_timestamp}") logger.debug(f"Oldest recording in the db: {oldest_timestamp}")
process = sp.run( process = sp.run(
@@ -504,52 +389,21 @@ class RecordingCleanup(threading.Thread):
for f in files_to_check: for f in files_to_check:
p = Path(f) p = Path(f)
try: if p.stat().st_mtime < delete_before.get(p.parent.name, default_expire):
if p.stat().st_mtime < delete_before.get(p.parent.name, default_expire): p.unlink(missing_ok=True)
p.unlink(missing_ok=True)
except FileNotFoundError:
logger.warning(f"Attempted to expire missing file: {f}")
logger.debug("End expire files (legacy).") logger.debug("End expire files (legacy).")
def sync_recordings(self):
logger.debug("Start sync recordings.")
# get all recordings in the db
recordings: Recordings = Recordings.select()
# get all recordings files on disk
process = sp.run(
["find", RECORD_DIR, "-type", "f"],
capture_output=True,
text=True,
)
files_on_disk = process.stdout.splitlines()
recordings_to_delete = []
for recording in recordings.objects().iterator():
if not recording.path in files_on_disk:
recordings_to_delete.append(recording.id)
logger.debug(
f"Deleting {len(recordings_to_delete)} recordings with missing files"
)
Recordings.delete().where(Recordings.id << recordings_to_delete).execute()
logger.debug("End sync recordings.")
def run(self): def run(self):
# on startup sync recordings with disk (disabled due to too much CPU usage) # Expire recordings every minute, clean directories every hour.
# self.sync_recordings() for counter in itertools.cycle(range(60)):
# Expire tmp clips every minute, recordings and clean directories every hour.
for counter in itertools.cycle(range(self.config.record.expire_interval)):
if self.stop_event.wait(60): if self.stop_event.wait(60):
logger.info(f"Exiting recording cleanup...") logger.info(f"Exiting recording cleanup...")
break break
self.expire_recordings()
self.clean_tmp_clips() self.clean_tmp_clips()
if counter == 0: if counter == 0:
self.expire_recordings()
self.expire_files() self.expire_files()
remove_empty_directories(RECORD_DIR) remove_empty_directories(RECORD_DIR)

View File

@@ -4,7 +4,6 @@ import threading
import time import time
import psutil import psutil
import shutil import shutil
import os
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import RECORD_DIR, CLIPS_DIR, CACHE_DIR from frigate.const import RECORD_DIR, CLIPS_DIR, CACHE_DIR
@@ -32,28 +31,6 @@ def get_fs_type(path):
return fsType return fsType
def read_temperature(path):
if os.path.isfile(path):
with open(path) as f:
line = f.readline().strip()
return int(line) / 1000
return None
def get_temperatures():
temps = {}
# Get temperatures for all attached Corals
base = "/sys/class/apex/"
if os.path.isdir(base):
for apex in os.listdir(base):
temp = read_temperature(os.path.join(base, apex, "temp"))
if temp is not None:
temps[apex] = temp
return temps
def stats_snapshot(stats_tracking): def stats_snapshot(stats_tracking):
camera_metrics = stats_tracking["camera_metrics"] camera_metrics = stats_tracking["camera_metrics"]
stats = {} stats = {}
@@ -84,7 +61,6 @@ def stats_snapshot(stats_tracking):
"uptime": (int(time.time()) - stats_tracking["started"]), "uptime": (int(time.time()) - stats_tracking["started"]),
"version": VERSION, "version": VERSION,
"storage": {}, "storage": {},
"temperatures": get_temperatures(),
} }
for path in [RECORD_DIR, CLIPS_DIR, CACHE_DIR, "/dev/shm"]: for path in [RECORD_DIR, CLIPS_DIR, CACHE_DIR, "/dev/shm"]:

View File

@@ -572,7 +572,7 @@ class TestConfig(unittest.TestCase):
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].motion.frame_height == 50 assert runtime_config.cameras["back"].motion.frame_height >= 120
def test_motion_contour_area_dynamic(self): def test_motion_contour_area_dynamic(self):
@@ -601,7 +601,7 @@ class TestConfig(unittest.TestCase):
assert config == frigate_config.dict(exclude_unset=True) assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config runtime_config = frigate_config.runtime_config
assert round(runtime_config.cameras["back"].motion.contour_area) == 30 assert round(runtime_config.cameras["back"].motion.contour_area) == 99
def test_merge_labelmap(self): def test_merge_labelmap(self):
@@ -1244,30 +1244,6 @@ class TestConfig(unittest.TestCase):
runtime_config = frigate_config.runtime_config runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].snapshots.retain.default == 1.5 assert runtime_config.cameras["back"].snapshots.retain.default == 1.5
def test_fails_on_bad_camera_name(self):
config = {
"mqtt": {"host": "mqtt"},
"snapshots": {"retain": {"default": 1.5}},
"cameras": {
"back camer#": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
}
},
}
frigate_config = FrigateConfig(**config)
self.assertRaises(
ValidationError, lambda: frigate_config.runtime_config.cameras
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)

View File

@@ -1,3 +1,4 @@
import cv2
import numpy as np import numpy as np
from unittest import TestCase, main from unittest import TestCase, main
from frigate.video import box_overlaps, reduce_boxes from frigate.video import box_overlaps, reduce_boxes

View File

@@ -189,12 +189,12 @@ def draw_box_with_label(
) )
def calculate_region(frame_shape, xmin, ymin, xmax, ymax, model_size, multiplier=2): def calculate_region(frame_shape, xmin, ymin, xmax, ymax, multiplier=2):
# size is the longest edge and divisible by 4 # size is the longest edge and divisible by 4
size = int((max(xmax - xmin, ymax - ymin) * multiplier) // 4 * 4) size = int((max(xmax - xmin, ymax - ymin) * multiplier) // 4 * 4)
# dont go any smaller than the model_size # dont go any smaller than 300
if size < model_size: if size < 300:
size = model_size size = 300
# x_offset is midpoint of bounding box minus half the size # x_offset is midpoint of bounding box minus half the size
x_offset = int((xmax - xmin) / 2.0 + xmin - size / 2.0) x_offset = int((xmax - xmin) / 2.0 + xmin - size / 2.0)
@@ -567,9 +567,6 @@ class EventsPerSecond:
# compute the (approximate) events in the last n seconds # compute the (approximate) events in the last n seconds
now = datetime.datetime.now().timestamp() now = datetime.datetime.now().timestamp()
seconds = min(now - self._start, last_n_seconds) seconds = min(now - self._start, last_n_seconds)
# avoid divide by zero
if seconds == 0:
seconds = 1
return ( return (
len([t for t in self._timestamps if t > (now - last_n_seconds)]) / seconds len([t for t in self._timestamps if t > (now - last_n_seconds)]) / seconds
) )
@@ -605,26 +602,6 @@ def add_mask(mask, mask_img):
cv2.fillPoly(mask_img, pts=[contour], color=(0)) cv2.fillPoly(mask_img, pts=[contour], color=(0))
def load_labels(path, encoding="utf-8"):
"""Loads labels from file (with or without index numbers).
Args:
path: path to label file.
encoding: label file encoding.
Returns:
Dictionary mapping indices to labels.
"""
with open(path, "r", encoding=encoding) as f:
lines = f.readlines()
if not lines:
return {}
if lines[0].split(" ", maxsplit=1)[0].isdigit():
pairs = [line.split(" ", maxsplit=1) for line in lines]
return {int(index): label.strip() for index, label in pairs}
else:
return {index: line.strip() for index, line in enumerate(lines)}
class FrameManager(ABC): class FrameManager(ABC):
@abstractmethod @abstractmethod
def create(self, name, size) -> AnyStr: def create(self, name, size) -> AnyStr:

View File

@@ -3,7 +3,6 @@ import itertools
import logging import logging
import multiprocessing as mp import multiprocessing as mp
import queue import queue
import random
import signal import signal
import subprocess as sp import subprocess as sp
import threading import threading
@@ -76,7 +75,25 @@ def filtered(obj, objects_to_track, object_filters):
def create_tensor_input(frame, model_shape, region): def create_tensor_input(frame, model_shape, region):
cropped_frame = yuv_region_2_rgb(frame, region) # TODO: is it faster to just convert grayscale to RGB? or repeat dimensions with numpy?
height = frame.shape[0] // 3 * 2
width = frame.shape[1]
# get the crop box if the region extends beyond the frame
crop_x1 = max(0, region[0])
crop_y1 = max(0, region[1])
crop_x2 = min(width, region[2])
crop_y2 = min(height, region[3])
size = region[3] - region[1]
cropped_frame = np.zeros((size, size), np.uint8)
cropped_frame[
0 : crop_y2 - crop_y1,
0 : crop_x2 - crop_x1,
] = frame[crop_y1:crop_y2, crop_x1:crop_x2]
cropped_frame = np.repeat(np.expand_dims(cropped_frame, -1), 3, 2)
# Resize to 300x300 if needed # Resize to 300x300 if needed
if cropped_frame.shape != (model_shape[0], model_shape[1], 3): if cropped_frame.shape != (model_shape[0], model_shape[1], 3):
@@ -153,10 +170,10 @@ def capture_frames(
try: try:
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size) frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
except Exception as e: except Exception as e:
logger.error(f"{camera_name}: Unable to read frames from ffmpeg process.") logger.info(f"{camera_name}: ffmpeg sent a broken frame. {e}")
if ffmpeg_process.poll() != None: if ffmpeg_process.poll() != None:
logger.error( logger.info(
f"{camera_name}: ffmpeg process is not running. exiting capture thread..." f"{camera_name}: ffmpeg process is not running. exiting capture thread..."
) )
frame_manager.delete(frame_name) frame_manager.delete(frame_name)
@@ -221,11 +238,12 @@ class CameraWatchdog(threading.Thread):
if not self.capture_thread.is_alive(): if not self.capture_thread.is_alive():
self.logger.error( self.logger.error(
f"Ffmpeg process crashed unexpectedly for {self.camera_name}." f"FFMPEG process crashed unexpectedly for {self.camera_name}."
) )
self.logger.error( self.logger.error(
"The following ffmpeg logs include the last 100 lines prior to exit." "The following ffmpeg logs include the last 100 lines prior to exit."
) )
self.logger.error("You may have invalid args defined for this camera.")
self.logpipe.dump() self.logpipe.dump()
self.start_ffmpeg_detect() self.start_ffmpeg_detect()
elif now - self.capture_thread.current_frame.value > 20: elif now - self.capture_thread.current_frame.value > 20:
@@ -469,8 +487,6 @@ def process_frames(
fps_tracker = EventsPerSecond() fps_tracker = EventsPerSecond()
fps_tracker.start() fps_tracker.start()
startup_scan_counter = 0
while not stop_event.is_set(): while not stop_event.is_set():
if exit_on_empty and frame_queue.empty(): if exit_on_empty and frame_queue.empty():
logger.info(f"Exiting track_objects...") logger.info(f"Exiting track_objects...")
@@ -491,219 +507,179 @@ def process_frames(
logger.info(f"{camera_name}: frame {frame_time} is not in memory store.") logger.info(f"{camera_name}: frame {frame_time} is not in memory store.")
continue continue
if not detection_enabled.value:
fps.value = fps_tracker.eps()
object_tracker.match_and_update(frame_time, [])
detected_objects_queue.put(
(camera_name, frame_time, object_tracker.tracked_objects, [], [])
)
detection_fps.value = object_detector.fps.eps()
frame_manager.close(f"{camera_name}{frame_time}")
continue
# look for motion # look for motion
motion_boxes = motion_detector.detect(frame) motion_boxes = motion_detector.detect(frame)
regions = [] # get stationary object ids
# 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 there hasn't been motion for 10 frames
if obj["motionless_count"] >= 10
# and it isn't due for a periodic check
and 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
and not intersects_any(obj["box"], motion_boxes)
]
# if detection is disabled # get tracked object boxes that aren't stationary
if not detection_enabled.value: tracked_object_boxes = [
object_tracker.match_and_update(frame_time, []) obj["box"]
else: for obj in object_tracker.tracked_objects.values()
# get stationary object ids if not obj["id"] in stationary_object_ids
# check every Nth frame for stationary objects ]
# disappeared objects are not stationary
# also check for overlapping motion boxes # combine motion boxes with known locations of existing objects
stationary_object_ids = [ combined_boxes = reduce_boxes(motion_boxes + tracked_object_boxes)
obj["id"]
for obj in object_tracker.tracked_objects.values() # compute regions
# if there hasn't been motion for 10 frames regions = [
if obj["motionless_count"] >= 10 calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.2)
# and it isn't due for a periodic check for a in combined_boxes
and ( ]
detect_config.stationary.interval == 0
or obj["motionless_count"] % detect_config.stationary.interval != 0 # consolidate regions with heavy overlap
regions = [
calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.0)
for a in reduce_boxes(regions, 0.4)
]
# resize regions and detect
# seed with stationary objects
detections = [
(
obj["label"],
obj["score"],
obj["box"],
obj["area"],
obj["region"],
)
for obj in object_tracker.tracked_objects.values()
if obj["id"] in stationary_object_ids
]
for region in regions:
detections.extend(
detect(
object_detector,
frame,
model_shape,
region,
objects_to_track,
object_filters,
) )
# and it hasn't disappeared )
and object_tracker.disappeared[obj["id"]] == 0
# and it doesn't overlap with any current motion boxes
and not intersects_any(obj["box"], motion_boxes)
]
# get tracked object boxes that aren't stationary #########
tracked_object_boxes = [ # merge objects, check for clipped objects and look again up to 4 times
obj["box"] #########
for obj in object_tracker.tracked_objects.values() refining = True
if not obj["id"] in stationary_object_ids refine_count = 0
] while refining and refine_count < 4:
refining = False
# combine motion boxes with known locations of existing objects # group by name
combined_boxes = reduce_boxes(motion_boxes + tracked_object_boxes) detected_object_groups = defaultdict(lambda: [])
for detection in detections:
detected_object_groups[detection[0]].append(detection)
region_min_size = max(model_shape[0], model_shape[1]) selected_objects = []
# compute regions for group in detected_object_groups.values():
regions = [
calculate_region(
frame_shape,
a[0],
a[1],
a[2],
a[3],
region_min_size,
multiplier=random.uniform(1.2, 1.5),
)
for a in combined_boxes
]
# consolidate regions with heavy overlap # apply non-maxima suppression to suppress weak, overlapping bounding boxes
regions = [ boxes = [
calculate_region( (o[2][0], o[2][1], o[2][2] - o[2][0], o[2][3] - o[2][1])
frame_shape, a[0], a[1], a[2], a[3], region_min_size, multiplier=1.0 for o in group
) ]
for a in reduce_boxes(regions, 0.4) confidences = [o[1] for o in group]
] idxs = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4)
# if starting up, get the next startup scan region for index in idxs:
if startup_scan_counter < 9: obj = group[index[0]]
ymin = int(frame_shape[0] / 3 * startup_scan_counter / 3) if clipped(obj, frame_shape):
ymax = int(frame_shape[0] / 3 + ymin) box = obj[2]
xmin = int(frame_shape[1] / 3 * startup_scan_counter / 3) # calculate a new region that will hopefully get the entire object
xmax = int(frame_shape[1] / 3 + xmin) region = calculate_region(
regions.append( frame_shape, box[0], box[1], box[2], box[3]
calculate_region( )
frame_shape,
xmin, regions.append(region)
ymin,
xmax, selected_objects.extend(
ymax, detect(
region_min_size, object_detector,
multiplier=1.2, frame,
model_shape,
region,
objects_to_track,
object_filters,
)
)
refining = True
else:
selected_objects.append(obj)
# set the detections list to only include top, complete objects
# and new detections
detections = selected_objects
if refining:
refine_count += 1
## drop detections that overlap too much
consolidated_detections = []
# group by name
detected_object_groups = defaultdict(lambda: [])
for detection in detections:
detected_object_groups[detection[0]].append(detection)
# loop over detections grouped by label
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]
# if 90% of smaller detection is inside of another detection, consolidate
if (
area(intersection(current_detection, to_check))
/ area(current_detection)
> 0.9
):
overlap = 1
break
if overlap == 0:
consolidated_detections.append(
sorted_by_area[current_detection_idx]
) )
)
startup_scan_counter += 1
# resize regions and detect # now that we have refined our detections, we need to track objects
# seed with stationary objects object_tracker.match_and_update(frame_time, consolidated_detections)
detections = [
(
obj["label"],
obj["score"],
obj["box"],
obj["area"],
obj["region"],
)
for obj in object_tracker.tracked_objects.values()
if obj["id"] in stationary_object_ids
]
for region in regions:
detections.extend(
detect(
object_detector,
frame,
model_shape,
region,
objects_to_track,
object_filters,
)
)
#########
# merge objects, check for clipped objects and look again up to 4 times
#########
refining = len(regions) > 0
refine_count = 0
while refining and refine_count < 4:
refining = False
# 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
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)
for index in idxs:
obj = group[index[0]]
if clipped(obj, frame_shape):
box = obj[2]
# calculate a new region that will hopefully get the entire object
region = calculate_region(
frame_shape,
box[0],
box[1],
box[2],
box[3],
region_min_size,
)
regions.append(region)
selected_objects.extend(
detect(
object_detector,
frame,
model_shape,
region,
objects_to_track,
object_filters,
)
)
refining = True
else:
selected_objects.append(obj)
# set the detections list to only include top, complete objects
# and new detections
detections = selected_objects
if refining:
refine_count += 1
## drop detections that overlap too much
consolidated_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)
# loop over detections grouped by label
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]
# if 90% of smaller detection is inside of another detection, consolidate
if (
area(intersection(current_detection, to_check))
/ area(current_detection)
> 0.9
):
overlap = 1
break
if overlap == 0:
consolidated_detections.append(
sorted_by_area[current_detection_idx]
)
# now that we have refined our detections, we need to track objects
object_tracker.match_and_update(frame_time, consolidated_detections)
# else, just update the frame times for the stationary objects
else:
object_tracker.update_frame_times(frame_time)
# add to the queue if not full # add to the queue if not full
if detected_objects_queue.full(): if detected_objects_queue.full():

View File

@@ -28,19 +28,17 @@ SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs): def migrate(migrator, database, fake=False, **kwargs):
migrator.sql( migrator.create_model(Recordings)
'CREATE TABLE IF NOT EXISTS "recordings" ("id" VARCHAR(30) NOT NULL PRIMARY KEY, "camera" VARCHAR(20) NOT NULL, "path" VARCHAR(255) NOT NULL, "start_time" DATETIME NOT NULL, "end_time" DATETIME NOT NULL, "duration" REAL NOT NULL)'
) def add_index():
migrator.sql( # First add the index here, because there is a bug in peewee_migrate
'CREATE INDEX IF NOT EXISTS "recordings_camera" ON "recordings" ("camera")' # when trying to create an multi-column index in the same migration
) # as the table: https://github.com/klen/peewee_migrate/issues/19
migrator.sql( Recordings.add_index("start_time", "end_time")
'CREATE UNIQUE INDEX IF NOT EXISTS "recordings_path" ON "recordings" ("path")' Recordings.create_table()
)
migrator.sql( migrator.python(add_index)
'CREATE INDEX IF NOT EXISTS "recordings_start_time_end_time" ON "recordings" (start_time, end_time)'
)
def rollback(migrator, database, fake=False, **kwargs): def rollback(migrator, database, fake=False, **kwargs):
pass migrator.remove_model(Recordings)

View File

@@ -1,47 +0,0 @@
"""Peewee migrations -- 004_add_bbox_region_area.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 datetime as dt
import peewee as pw
from playhouse.sqlite_ext import *
from decimal import ROUND_HALF_EVEN
from frigate.models import Recordings
try:
import playhouse.postgres_ext as pw_pext
except ImportError:
pass
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.add_fields(
Recordings,
objects=pw.IntegerField(null=True),
motion=pw.IntegerField(null=True),
)
def rollback(migrator, database, fake=False, **kwargs):
migrator.remove_fields(Recordings, ["objects", "motion"])

View File

@@ -162,12 +162,9 @@ class ProcessClip:
) )
total_regions += len(regions) total_regions += len(regions)
total_motion_boxes += len(motion_boxes) total_motion_boxes += len(motion_boxes)
top_score = 0
for id, obj in self.camera_state.tracked_objects.items(): for id, obj in self.camera_state.tracked_objects.items():
if not obj.false_positive: if not obj.false_positive:
object_ids.add(id) object_ids.add(id)
if obj.top_score > top_score:
top_score = obj.top_score
total_frames += 1 total_frames += 1
@@ -178,7 +175,6 @@ class ProcessClip:
"total_motion_boxes": total_motion_boxes, "total_motion_boxes": total_motion_boxes,
"true_positive_objects": len(object_ids), "true_positive_objects": len(object_ids),
"total_frames": total_frames, "total_frames": total_frames,
"top_score": top_score,
} }
def save_debug_frame(self, debug_path, frame_time, tracked_objects): def save_debug_frame(self, debug_path, frame_time, tracked_objects):
@@ -281,7 +277,6 @@ def process(path, label, output, debug_path):
frigate_config = FrigateConfig(**json_config) frigate_config = FrigateConfig(**json_config)
runtime_config = frigate_config.runtime_config runtime_config = frigate_config.runtime_config
runtime_config.cameras["camera"].create_ffmpeg_cmds()
process_clip = ProcessClip(c, frame_shape, runtime_config) process_clip = ProcessClip(c, frame_shape, runtime_config)
process_clip.load_frames() process_clip.load_frames()

14794
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,329 +0,0 @@
import { h } from 'preact';
import { useEffect, useState, useCallback, useMemo, useRef } from 'preact/hooks';
import ArrowRight from '../icons/ArrowRight';
import ArrowRightDouble from '../icons/ArrowRightDouble';
const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf();
const Calender = ({ onChange, calenderRef, close }) => {
const keyRef = useRef([]);
const date = new Date();
const year = date.getFullYear();
const month = date.getMonth();
const daysMap = useMemo(() => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], []);
const monthMap = useMemo(
() => [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
],
[]
);
const [state, setState] = useState({
getMonthDetails: [],
year,
month,
selectedDay: null,
timeRange: { before: null, after: null },
monthDetails: null,
});
const getNumberOfDays = useCallback((year, month) => {
return 40 - new Date(year, month, 40).getDate();
}, []);
const getDayDetails = useCallback(
(args) => {
const date = args.index - args.firstDay;
const day = args.index % 7;
let prevMonth = args.month - 1;
let prevYear = args.year;
if (prevMonth < 0) {
prevMonth = 11;
prevYear--;
}
const prevMonthNumberOfDays = getNumberOfDays(prevYear, prevMonth);
const _date = (date < 0 ? prevMonthNumberOfDays + date : date % args.numberOfDays) + 1;
const month = date < 0 ? -1 : date >= args.numberOfDays ? 1 : 0;
const timestamp = new Date(args.year, args.month, _date).getTime();
return {
date: _date,
day,
month,
timestamp,
dayString: daysMap[day],
};
},
[getNumberOfDays, daysMap]
);
const getMonthDetails = useCallback(
(year, month) => {
const firstDay = new Date(year, month).getDay();
const numberOfDays = getNumberOfDays(year, month);
const monthArray = [];
const rows = 6;
let currentDay = null;
let index = 0;
const cols = 7;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
currentDay = getDayDetails({
index,
numberOfDays,
firstDay,
year,
month,
});
monthArray.push(currentDay);
index++;
}
}
return monthArray;
},
[getNumberOfDays, getDayDetails]
);
useEffect(() => {
setState((prev) => ({ ...prev, selectedDay: todayTimestamp, monthDetails: getMonthDetails(year, month) }));
}, [year, month, getMonthDetails]);
useEffect(() => {
// add refs for keyboard navigation
if (state.monthDetails) {
keyRef.current = keyRef.current.slice(0, state.monthDetails.length);
}
// set today date in focus for keyboard navigation
const todayDate = new Date(todayTimestamp).getDate();
keyRef.current.find((t) => t.tabIndex === todayDate)?.focus();
}, [state.monthDetails]);
const isCurrentDay = (day) => day.timestamp === todayTimestamp;
const isSelectedRange = useCallback(
(day) => {
if (!state.timeRange.after || !state.timeRange.before) return;
return day.timestamp < state.timeRange.before && day.timestamp >= state.timeRange.after;
},
[state.timeRange]
);
const isFirstDayInRange = useCallback(
(day) => {
if (isCurrentDay(day)) return;
return state.timeRange.after === day.timestamp;
},
[state.timeRange.after]
);
const isLastDayInRange = useCallback(
(day) => {
return state.timeRange.before === new Date(day.timestamp).setHours(24, 0, 0, 0);
},
[state.timeRange.before]
);
const getMonthStr = useCallback(
(month) => {
return monthMap[Math.max(Math.min(11, month), 0)] || 'Month';
},
[monthMap]
);
const onDateClick = (day) => {
const { before, after } = state.timeRange;
let timeRange = { before: null, after: null };
// user has selected a date < after, reset values
if (after === null || day.timestamp < after) {
timeRange = { before: new Date(day.timestamp).setHours(24, 0, 0, 0), after: day.timestamp };
}
// user has selected a date > after
if (after !== null && before !== new Date(day.timestamp).setHours(24, 0, 0, 0) && day.timestamp > after) {
timeRange = {
after,
before:
day.timestamp >= todayTimestamp
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
: new Date(day.timestamp).setHours(24, 0, 0, 0),
};
}
// reset values
if (before === new Date(day.timestamp).setHours(24, 0, 0, 0)) {
timeRange = { before: null, after: null };
}
setState((prev) => ({
...prev,
timeRange,
selectedDay: day.timestamp,
}));
if (onChange) {
onChange(timeRange.after ? { before: timeRange.before / 1000, after: timeRange.after / 1000 } : ['all']);
}
};
const setYear = useCallback(
(offset) => {
const year = state.year + offset;
const month = state.month;
setState((prev) => {
return {
...prev,
year,
monthDetails: getMonthDetails(year, month),
};
});
},
[state.year, state.month, getMonthDetails]
);
const setMonth = (offset) => {
let year = state.year;
let month = state.month + offset;
if (month === -1) {
month = 11;
year--;
} else if (month === 12) {
month = 0;
year++;
}
setState((prev) => {
return {
...prev,
year,
month,
monthDetails: getMonthDetails(year, month),
};
});
};
const handleKeydown = (e, day, index) => {
if ((keyRef.current && e.key === 'Enter') || e.keyCode === 32) {
e.preventDefault();
day.month === 0 && onDateClick(day);
}
if (e.key === 'ArrowLeft') {
index > 0 && keyRef.current[index - 1].focus();
}
if (e.key === 'ArrowRight') {
index < 41 && keyRef.current[index + 1].focus();
}
if (e.key === 'ArrowUp') {
e.preventDefault();
index > 6 && keyRef.current[index - 7].focus();
}
if (e.key === 'ArrowDown') {
e.preventDefault();
index < 36 && keyRef.current[index + 7].focus();
}
if (e.key === 'Escape') {
close();
}
};
const renderCalendar = () => {
const days =
state.monthDetails &&
state.monthDetails.map((day, idx) => {
return (
<div
onClick={() => onDateClick(day)}
onkeydown={(e) => handleKeydown(e, day, idx)}
ref={(ref) => (keyRef.current[idx] = ref)}
tabIndex={day.month === 0 ? day.date : null}
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
}
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''}
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
key={idx}
>
<div className="font-light">
<span className="text-gray-400">{day.date}</span>
</div>
</div>
);
});
return (
<div>
<div className="w-full flex justify-start flex-shrink">
{['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'].map((d, i) => (
<div key={i} className="w-12 text-xs font-light text-center">
{d}
</div>
))}
</div>
<div className="w-full h-56">{days}</div>
</div>
);
};
return (
<div className="select-none w-96 flex flex-shrink" ref={calenderRef}>
<div className="py-4 px-6">
<div className="flex items-center">
<div className="w-1/6 relative flex justify-around">
<div
tabIndex={100}
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
onClick={() => setYear(-1)}
>
<ArrowRightDouble className="h-2/6 transform rotate-180 " />
</div>
</div>
<div className="w-1/6 relative flex justify-around ">
<div
tabIndex={101}
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
onClick={() => setMonth(-1)}
>
<ArrowRight className="h-2/6 transform rotate-180 red" />
</div>
</div>
<div className="w-1/3">
<div className="text-3xl text-center text-gray-200 font-extralight">{state.year}</div>
<div className="text-center text-gray-400 font-extralight">{getMonthStr(state.month)}</div>
</div>
<div className="w-1/6 relative flex justify-around ">
<div
tabIndex={102}
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
onClick={() => setMonth(1)}
>
<ArrowRight className="h-2/6" />
</div>
</div>
<div className="w-1/6 relative flex justify-around " tabIndex={104} onClick={() => setYear(1)}>
<div className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800">
<ArrowRightDouble className="h-2/6" />
</div>
</div>
</div>
<div className="mt-3">{renderCalendar()}</div>
</div>
</div>
);
};
export default Calender;

View File

@@ -1,162 +0,0 @@
import { h } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
export const DateFilterOptions = [
{
label: 'All',
value: ['all'],
},
{
label: 'Today',
value: {
//Before
before: new Date().setHours(24, 0, 0, 0) / 1000,
//After
after: new Date().setHours(0, 0, 0, 0) / 1000,
},
},
{
label: 'Yesterday',
value: {
//Before
before: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(24, 0, 0, 0) / 1000,
//After
after: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(0, 0, 0, 0) / 1000,
},
},
{
label: 'Last 7 Days',
value: {
//Before
before: new Date().setHours(24, 0, 0, 0) / 1000,
//After
after: new Date(new Date().setDate(new Date().getDate() - 7)).setHours(0, 0, 0, 0) / 1000,
},
},
{
label: 'This Month',
value: {
//Before
before: new Date().setHours(24, 0, 0, 0) / 1000,
//After
after: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
},
},
{
label: 'Last Month',
value: {
//Before
before: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
//After
after: new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1).getTime() / 1000,
},
},
{
label: 'Custom Range',
value: 'custom_range',
},
];
export default function DatePicker({
helpText,
keyboardType = 'text',
inputRef,
label,
leadingIcon: LeadingIcon,
onBlur,
onChangeText,
onFocus,
readonly,
trailingIcon: TrailingIcon,
value: propValue = '',
...props
}) {
const [isFocused, setFocused] = useState(false);
const [value, setValue] = useState(propValue);
useEffect(() => {
if (propValue !== value) {
setValue(propValue);
}
}, [propValue, setValue, value]);
const handleFocus = useCallback(
(event) => {
setFocused(true);
onFocus && onFocus(event);
},
[onFocus]
);
const handleBlur = useCallback(
(event) => {
setFocused(false);
onBlur && onBlur(event);
},
[onBlur]
);
const handleChange = useCallback(
(event) => {
const { value } = event.target;
setValue(value);
onChangeText && onChangeText(value);
},
[onChangeText, setValue]
);
const onClick = (e) => {
props.onclick(e);
};
const labelMoved = isFocused || value !== '';
return (
<div className="w-full">
{props.children}
<div
className={`bg-gray-100 dark:bg-gray-700 rounded rounded-b-none border-gray-400 border-b p-1 pl-4 pr-3 ${
isFocused ? 'border-blue-500 dark:border-blue-500' : ''
}`}
ref={inputRef}
>
<label
className="flex space-x-2 items-center"
data-testid={`label-${label.toLowerCase().replace(/[^\w]+/g, '_')}`}
>
{LeadingIcon ? (
<div className="w-10 h-full">
<LeadingIcon />
</div>
) : null}
<div className="relative w-full">
<input
className="h-6 mt-6 w-full bg-transparent focus:outline-none focus:ring-0"
type={keyboardType}
readOnly
onBlur={handleBlur}
onFocus={handleFocus}
onInput={handleChange}
tabIndex="0"
onClick={onClick}
value={propValue}
{...props}
/>
<div
className={`absolute top-3 transition transform text-gray-600 dark:text-gray-400 ${
labelMoved ? 'text-xs -translate-y-2' : ''
} ${isFocused ? 'text-blue-500 dark:text-blue-500' : ''}`}
>
<p>{label}</p>
</div>
</div>
{TrailingIcon ? (
<div className="w-10 h-10">
<TrailingIcon />
</div>
) : null}
</label>
</div>
{helpText ? <div className="text-xs pl-3 pt-1">{helpText}</div> : null}
</div>
);
}

View File

@@ -1,14 +1,6 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { import { addSeconds, differenceInSeconds, fromUnixTime, format, parseISO, startOfHour } from 'date-fns';
differenceInSeconds,
fromUnixTime,
format,
parseISO,
startOfHour,
differenceInMinutes,
differenceInHours,
} from 'date-fns';
import ArrowDropdown from '../icons/ArrowDropdown'; import ArrowDropdown from '../icons/ArrowDropdown';
import ArrowDropup from '../icons/ArrowDropup'; import ArrowDropup from '../icons/ArrowDropup';
import Link from '../components/Link'; import Link from '../components/Link';
@@ -29,31 +21,25 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate, se
events={recording.events} events={recording.events}
selected={recording.date === selectedDate} selected={recording.date === selectedDate}
> >
{recording.recordings {recording.recordings.slice().reverse().map((item, i) => (
.slice() <div className="mb-2 w-full">
.reverse() <div
.map((item, i) => ( className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
<div className="mb-2 w-full"> i === 0 ? 'border-t border-white border-opacity-50' : ''
<div }`}
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${ >
i === 0 ? 'border-t border-white border-opacity-50' : '' <div className="flex-1">
}`} <Link href={`/recording/${camera}/${recording.date}/${item.hour}`} type="text">
> {item.hour}:00
<div className="flex-1"> </Link>
<Link href={`/recording/${camera}/${recording.date}/${item.hour}`} type="text">
{item.hour}:00
</Link>
</div>
<div className="flex-1 text-right">{item.events.length} Events</div>
</div> </div>
{item.events <div className="flex-1 text-right">{item.events.length} Events</div>
.slice()
.reverse()
.map((event) => (
<EventCard camera={camera} event={event} delay={item.delay} />
))}
</div> </div>
))} {item.events.slice().reverse().map((event) => (
<EventCard camera={camera} event={event} delay={item.delay} />
))}
</div>
))}
</ExpandableList> </ExpandableList>
); );
} }
@@ -97,17 +83,8 @@ export function ExpandableList({ title, events = 0, children, selected = false }
export function EventCard({ camera, event, delay }) { export function EventCard({ camera, event, delay }) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const start = fromUnixTime(event.start_time); const start = fromUnixTime(event.start_time);
let duration = 'In Progress'; const end = fromUnixTime(event.end_time);
if (event.end_time) { const duration = addSeconds(new Date(0), differenceInSeconds(end, start));
const end = fromUnixTime(event.end_time);
const hours = differenceInHours(end, start);
const minutes = differenceInMinutes(end, start) - hours * 60;
const seconds = differenceInSeconds(end, start) - hours * 60 - minutes * 60;
duration = '';
if (hours) duration += `${hours}h `;
if (minutes) duration += `${minutes}m `;
duration += `${seconds}s`;
}
const position = differenceInSeconds(start, startOfHour(start)); const position = differenceInSeconds(start, startOfHour(start));
const offset = Object.entries(delay) const offset = Object.entries(delay)
.map(([p, d]) => (position > p ? d : 0)) .map(([p, d]) => (position > p ? d : 0))
@@ -125,7 +102,7 @@ export function EventCard({ camera, event, delay }) {
<div className="flex-1"> <div className="flex-1">
<div className="text-2xl text-white leading-tight capitalize">{event.label}</div> <div className="text-2xl text-white leading-tight capitalize">{event.label}</div>
<div className="text-xs md:text-normal text-gray-300">Start: {format(start, 'HH:mm:ss')}</div> <div className="text-xs md:text-normal text-gray-300">Start: {format(start, 'HH:mm:ss')}</div>
<div className="text-xs md:text-normal text-gray-300">Duration: {duration}</div> <div className="text-xs md:text-normal text-gray-300">Duration: {format(duration, 'mm:ss')}</div>
</div> </div>
<div className="text-lg text-white text-right leading-tight">{(event.top_score * 100).toFixed(1)}%</div> <div className="text-lg text-white text-right leading-tight">{(event.top_score * 100).toFixed(1)}%</div>
</div> </div>

View File

@@ -27,7 +27,7 @@ export default function RelativeModal({
const handleKeydown = useCallback( const handleKeydown = useCallback(
(event) => { (event) => {
const focusable = ref.current && ref.current.querySelectorAll('[tabindex]'); const focusable = ref.current.querySelectorAll('[tabindex]');
if (event.key === 'Tab' && focusable.length) { if (event.key === 'Tab' && focusable.length) {
if (event.shiftKey && document.activeElement === focusable[0]) { if (event.shiftKey && document.activeElement === focusable[0]) {
focusable[focusable.length - 1].focus(); focusable[focusable.length - 1].focus();
@@ -69,15 +69,14 @@ export default function RelativeModal({
let newTop = top; let newTop = top;
let newLeft = left; let newLeft = left;
// too far left
if (left < WINDOW_PADDING) {
newLeft = WINDOW_PADDING;
}
// too far right // too far right
else if (newLeft + width + WINDOW_PADDING >= windowWidth - WINDOW_PADDING) { if (newLeft + width + WINDOW_PADDING >= windowWidth - WINDOW_PADDING) {
newLeft = windowWidth - width - WINDOW_PADDING; newLeft = windowWidth - width - WINDOW_PADDING;
} }
// too far left
else if (left < WINDOW_PADDING) {
newLeft = WINDOW_PADDING;
}
// too close to bottom // too close to bottom
if (top + menuHeight > windowHeight - WINDOW_PADDING + window.scrollY) { if (top + menuHeight > windowHeight - WINDOW_PADDING + window.scrollY) {
newTop = WINDOW_PADDING; newTop = WINDOW_PADDING;

View File

@@ -3,27 +3,74 @@ import ArrowDropdown from '../icons/ArrowDropdown';
import ArrowDropup from '../icons/ArrowDropup'; import ArrowDropup from '../icons/ArrowDropup';
import Menu, { MenuItem } from './Menu'; import Menu, { MenuItem } from './Menu';
import TextField from './TextField'; import TextField from './TextField';
import DatePicker from './DatePicker';
import Calender from './Calender';
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
export default function Select({ export default function Select({ label, onChange, options: inputOptions = [], selected: propSelected }) {
type,
label,
onChange,
paramName,
options: inputOptions = [],
selected: propSelected,
}) {
const options = useMemo( const options = useMemo(
() => () =>
typeof inputOptions[0] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions, typeof inputOptions[0] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions,
[inputOptions] [inputOptions]
); );
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const [selected, setSelected] = useState(); const [selected, setSelected] = useState(
const [datePickerValue, setDatePickerValue] = useState(); Math.max(
options.findIndex(({ value }) => value === propSelected),
0
)
);
const [focused, setFocused] = useState(null);
const ref = useRef(null);
const handleSelect = useCallback(
(value, label) => {
setSelected(options.findIndex((opt) => opt.value === value));
onChange && onChange(value, label);
setShowMenu(false);
},
[onChange, options]
);
const handleClick = useCallback(() => {
setShowMenu(true);
}, [setShowMenu]);
const handleKeydown = useCallback(
(event) => {
switch (event.key) {
case 'Enter': {
if (!showMenu) {
setShowMenu(true);
setFocused(selected);
} else {
setSelected(focused);
onChange && onChange(options[focused].value, options[focused].label);
setShowMenu(false);
}
break;
}
case 'ArrowDown': {
const newIndex = focused + 1;
newIndex < options.length && setFocused(newIndex);
break;
}
case 'ArrowUp': {
const newIndex = focused - 1;
newIndex > -1 && setFocused(newIndex);
break;
}
// no default
}
},
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
);
const handleDismiss = useCallback(() => {
setShowMenu(false);
}, [setShowMenu]);
// Reset the state if the prop value changes // Reset the state if the prop value changes
useEffect(() => { useEffect(() => {
@@ -38,219 +85,25 @@ export default function Select({
// DO NOT include `selected` // DO NOT include `selected`
}, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps }, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { return (
if (type === 'datepicker') { <Fragment>
if ('after' && 'before' in propSelected) { <TextField
if (!propSelected.before || !propSelected.after) return setDatePickerValue('all'); inputRef={ref}
label={label}
for (let i = 0; i < inputOptions.length; i++) { onchange={onChange}
if ( onclick={handleClick}
inputOptions[i].value && onkeydown={handleKeydown}
Object.entries(inputOptions[i].value).sort().toString() === Object.entries(propSelected).sort().toString() readonly
) { trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
setDatePickerValue(inputOptions[i]?.label); value={options[selected]?.label}
break; />
} else { {showMenu ? (
setDatePickerValue( <Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
`${new Date(propSelected.after * 1000).toLocaleDateString()} -> ${new Date( {options.map(({ value, label }, i) => (
propSelected.before * 1000 - 1 <MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
).toLocaleDateString()}` ))}
); </Menu>
} ) : null}
} </Fragment>
}
}
if (type === 'dropdown') {
setSelected(
Math.max(
options.findIndex(({ value }) => Object.values(propSelected).includes(value)),
0
)
);
}
}, [type, options, inputOptions, propSelected, setSelected]);
const [focused, setFocused] = useState(null);
const [showCalender, setShowCalender] = useState(false);
const calenderRef = useRef(null);
const ref = useRef(null);
const handleSelect = useCallback(
(value) => {
setSelected(options.findIndex(({ value }) => Object.values(propSelected).includes(value)));
setShowMenu(false);
//show calender date range picker
if (value === 'custom_range') return setShowCalender(true);
onChange && onChange(value);
},
[onChange, options, propSelected, setSelected]
); );
const handleDateRange = useCallback(
(range) => {
onChange && onChange(range);
setShowMenu(false);
},
[onChange]
);
const handleClick = useCallback(() => {
setShowMenu(true);
}, [setShowMenu]);
const handleKeydownDatePicker = useCallback(
(event) => {
switch (event.key) {
case 'Enter': {
if (!showMenu) {
setShowMenu(true);
setFocused(selected);
} else {
setSelected(focused);
if (options[focused].value === 'custom_range') {
setShowMenu(false);
return setShowCalender(true);
}
onChange && onChange(options[focused].value);
setShowMenu(false);
}
break;
}
case 'ArrowDown': {
event.preventDefault();
const newIndex = focused + 1;
newIndex < options.length && setFocused(newIndex);
break;
}
case 'ArrowUp': {
event.preventDefault();
const newIndex = focused - 1;
newIndex > -1 && setFocused(newIndex);
break;
}
// no default
}
},
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
);
const handleKeydown = useCallback(
(event) => {
switch (event.key) {
case 'Enter': {
if (!showMenu) {
setShowMenu(true);
setFocused(selected);
} else {
setSelected(focused);
onChange && onChange({ [paramName]: options[focused].value });
setShowMenu(false);
}
break;
}
case 'ArrowDown': {
event.preventDefault();
const newIndex = focused + 1;
newIndex < options.length && setFocused(newIndex);
break;
}
case 'ArrowUp': {
event.preventDefault();
const newIndex = focused - 1;
newIndex > -1 && setFocused(newIndex);
break;
}
// no default
}
},
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected, paramName]
);
const handleDismiss = useCallback(() => {
setShowMenu(false);
}, [setShowMenu]);
const findDOMNodes = (component) => {
return (component && (component.base || (component.nodeType === 1 && component))) || null;
};
useEffect(() => {
const addBackDrop = (e) => {
if (showCalender && !findDOMNodes(calenderRef.current).contains(e.target)) {
setShowCalender(false);
}
};
window.addEventListener('click', addBackDrop);
return function cleanup() {
window.removeEventListener('click', addBackDrop);
};
}, [showCalender]);
switch (type) {
case 'datepicker':
return (
<Fragment>
<DatePicker
inputRef={ref}
label={label}
onchange={onChange}
onclick={handleClick}
onkeydown={handleKeydownDatePicker}
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
value={datePickerValue}
/>
{showCalender && (
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref}>
<Calender onChange={handleDateRange} calenderRef={calenderRef} close={() => setShowCalender(false)} />
</Menu>
)}
{showMenu ? (
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
{options.map(({ value, label }, i) => (
<MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
))}
</Menu>
) : null}
</Fragment>
);
// case 'dropdown':
default:
return (
<Fragment>
<TextField
inputRef={ref}
label={label}
onchange={onChange}
onclick={handleClick}
onkeydown={handleKeydown}
readonly
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
value={options[selected]?.label}
/>
{showMenu ? (
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
{options.map(({ value, label }, i) => (
<MenuItem
key={value}
label={label}
focus={focused === i}
onSelect={handleSelect}
value={{ [paramName]: value }}
/>
))}
</Menu>
) : null}
</Fragment>
);
}
} }

View File

@@ -5,40 +5,21 @@ import { fireEvent, render, screen } from '@testing-library/preact';
describe('Select', () => { describe('Select', () => {
test('on focus, shows a menu', async () => { test('on focus, shows a menu', async () => {
const handleChange = jest.fn(); const handleChange = jest.fn();
render( render(<Select label="Tacos" onChange={handleChange} options={['tacos', 'burritos']} />);
<Select
label="Tacos"
type="dropdown"
onChange={handleChange}
options={['all', 'tacos', 'burritos']}
paramName={['dinner']}
selected=""
/>
);
expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('textbox')); fireEvent.click(screen.getByRole('textbox'));
expect(screen.queryByRole('listbox')).toBeInTheDocument(); expect(screen.queryByRole('listbox')).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'all' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'tacos' })).toBeInTheDocument(); expect(screen.queryByRole('option', { name: 'tacos' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'burritos' })).toBeInTheDocument(); expect(screen.queryByRole('option', { name: 'burritos' })).toBeInTheDocument();
fireEvent.click(screen.queryByRole('option', { name: 'tacos' })); fireEvent.click(screen.queryByRole('option', { name: 'burritos' }));
expect(handleChange).toHaveBeenCalledWith({ dinner: 'tacos' }); expect(handleChange).toHaveBeenCalledWith('burritos', 'burritos');
}); });
test('allows keyboard navigation', async () => { test('allows keyboard navigation', async () => {
const handleChange = jest.fn(); const handleChange = jest.fn();
render( render(<Select label="Tacos" onChange={handleChange} options={['tacos', 'burritos']} />);
<Select
label="Tacos"
type="dropdown"
onChange={handleChange}
options={['tacos', 'burritos']}
paramName={['dinner']}
selected=""
/>
);
expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
const input = screen.getByRole('textbox'); const input = screen.getByRole('textbox');
@@ -48,6 +29,6 @@ describe('Select', () => {
fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(handleChange).toHaveBeenCalledWith({ dinner: 'burritos' }); expect(handleChange).toHaveBeenCalledWith('burritos', 'burritos');
}); });
}); });

View File

@@ -18,8 +18,7 @@ export const useSearchString = (limit, searchParams) => {
const removeDefaultSearchKeys = useCallback((searchParams) => { const removeDefaultSearchKeys = useCallback((searchParams) => {
searchParams.delete('limit'); searchParams.delete('limit');
searchParams.delete('include_thumbnails'); searchParams.delete('include_thumbnails');
// removed deletion of "before" as its used by DatePicker searchParams.delete('before');
// searchParams.delete('before');
}, []); }, []);
return { searchString, setSearchString, removeDefaultSearchKeys }; return { searchString, setSearchString, removeDefaultSearchKeys };

View File

@@ -1,18 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowLeft({ className = '' }) {
return (
<svg
className={`fill-current ${className}`}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm-1.218 19l-1.782-1.75 5.25-5.25-5.25-5.25 1.782-1.75 6.968 7-6.968 7z" />
</svg>
);
}
export default memo(ArrowLeft);

View File

@@ -1,12 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowRight({ className = '' }) {
return (
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M5 3l3.057-3 11.943 12-11.943 12-3.057-3 9-9z" />
</svg>
);
}
export default memo(ArrowRight);

View File

@@ -1,12 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowRightDouble({ className = '' }) {
return (
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M0 3.795l2.995-2.98 11.132 11.185-11.132 11.186-2.995-2.981 8.167-8.205-8.167-8.205zm18.04 8.205l-8.167 8.205 2.995 2.98 11.132-11.185-11.132-11.186-2.995 2.98 8.167 8.206z" />
</svg>
);
}
export default memo(ArrowRightDouble);

View File

@@ -29,8 +29,12 @@ function Camera({ name, conf }) {
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name); const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
const href = `/cameras/${name}`; const href = `/cameras/${name}`;
const buttons = useMemo(() => { const buttons = useMemo(() => {
return [{ name: 'Events', href: `/events?camera=${name}` }, { name: 'Recordings', href: `/recording/${name}` }]; const result = [{ name: 'Events', href: `/events?camera=${name}` }];
}, [name]); if (conf.record.enabled) {
result.push({ name: 'Recordings', href: `/recording/${name}` });
}
return result;
}, [name, conf.record.enabled]);
const icons = useMemo( const icons = useMemo(
() => [ () => [
{ {

View File

@@ -199,7 +199,7 @@ export default function Event({ eventId, close, scrollRef }) {
<img <img
src={ src={
data.has_snapshot data.has_snapshot
? `${apiHost}/api/events/${eventId}/snapshot.jpg` ? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
: `data:image/jpeg;base64,${data.thumbnail}` : `data:image/jpeg;base64,${data.thumbnail}`
} }
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`} alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}

View File

@@ -1,26 +1,31 @@
import { h } from 'preact'; import { h } from 'preact';
import Select from '../../../components/Select'; import Select from '../../../components/Select';
import { useCallback } from 'preact/hooks'; import { useCallback, useMemo } from 'preact/hooks';
function Filter({ onChange, searchParams, paramName, options, ...rest }) { const Filter = ({ onChange, searchParams, paramName, options }) => {
const handleSelect = useCallback( const handleSelect = useCallback(
(key) => { (key) => {
const newParams = new URLSearchParams(searchParams.toString()); const newParams = new URLSearchParams(searchParams.toString());
Object.keys(key).map((entries) => { if (key !== 'all') {
if (key[entries] !== 'all') { newParams.set(paramName, key);
newParams.set(entries, key[entries]); } else {
} else { newParams.delete(paramName);
paramName.map((p) => newParams.delete(p)); }
}
});
onChange(newParams); onChange(newParams);
}, },
[searchParams, paramName, onChange] [searchParams, paramName, onChange]
); );
const obj = {}; const selectOptions = useMemo(() => ['all', ...options], [options]);
paramName.map((name) => Object.assign(obj, { [name]: searchParams.get(name) }), [searchParams]);
return <Select onChange={handleSelect} options={options} selected={obj} paramName={paramName} {...rest} />; return (
} <Select
label={`${paramName.charAt(0).toUpperCase()}${paramName.substr(1)}`}
onChange={handleSelect}
options={selectOptions}
selected={searchParams.get(paramName) || 'all'}
/>
);
};
export default Filter; export default Filter;

View File

@@ -3,13 +3,7 @@ import { useCallback, useMemo } from 'preact/hooks';
import Link from '../../../components/Link'; import Link from '../../../components/Link';
import { route } from 'preact-router'; import { route } from 'preact-router';
function Filterable({ onFilter, pathname, searchParams, paramName, name }) { const Filterable = ({ onFilter, pathname, searchParams, paramName, name, removeDefaultSearchKeys }) => {
const removeDefaultSearchKeys = useCallback((searchParams) => {
searchParams.delete('limit');
searchParams.delete('include_thumbnails');
// searchParams.delete('before');
}, []);
const href = useMemo(() => { const href = useMemo(() => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
params.set(paramName, name); params.set(paramName, name);
@@ -33,6 +27,6 @@ function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
{name} {name}
</Link> </Link>
); );
} };
export default Filterable; export default Filterable;

View File

@@ -1,13 +1,11 @@
import { h } from 'preact'; import { h } from 'preact';
import Filter from './filter'; import Filter from './filter';
import { useConfig } from '../../../api'; import { useConfig } from '../../../api';
import { useMemo, useState } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
import { DateFilterOptions } from '../../../components/DatePicker';
import Button from '../../../components/Button';
const Filters = ({ onChange, searchParams }) => { const Filters = ({ onChange, searchParams }) => {
const [viewFilters, setViewFilters] = useState(false);
const { data } = useConfig(); const { data } = useConfig();
const cameras = useMemo(() => Object.keys(data.cameras), [data]); const cameras = useMemo(() => Object.keys(data.cameras), [data]);
const zones = useMemo( const zones = useMemo(
@@ -29,52 +27,12 @@ const Filters = ({ onChange, searchParams }) => {
}, data.objects?.track || []) }, data.objects?.track || [])
.filter((value, i, self) => self.indexOf(value) === i); .filter((value, i, self) => self.indexOf(value) === i);
}, [data]); }, [data]);
return ( return (
<div> <div className="flex space-x-4">
<Button <Filter onChange={onChange} options={cameras} paramName="camera" searchParams={searchParams} />
onClick={() => setViewFilters(!viewFilters)} <Filter onChange={onChange} options={zones} paramName="zone" searchParams={searchParams} />
className="block xs:hidden w-full mb-4 text-center" <Filter onChange={onChange} options={labels} paramName="label" searchParams={searchParams} />
type="text"
>
{`${viewFilters ? 'Hide Filter' : 'Filter'}`}
</Button>
<div className={`xs:flex space-y-1 xs:space-y-0 xs:space-x-4 ${viewFilters ? 'flex-col' : 'hidden'}`}>
<Filter
type="dropdown"
onChange={onChange}
options={['all', ...cameras]}
paramName={['camera']}
label="Camera"
searchParams={searchParams}
/>
<Filter
type="dropdown"
onChange={onChange}
options={['all', ...zones]}
paramName={['zone']}
label="Zone"
searchParams={searchParams}
/>
<Filter
type="dropdown"
onChange={onChange}
options={['all', ...labels]}
paramName={['label']}
label="Label"
searchParams={searchParams}
/>
<Filter
type="datepicker"
onChange={onChange}
options={DateFilterOptions}
paramName={['before', 'after']}
label="DatePicker"
searchParams={searchParams}
/>
</div>
</div> </div>
); );
}; };

View File

@@ -66,9 +66,6 @@ export default function Recording({ camera, date, hour, seconds }) {
this.player.currentTime(seconds); this.player.currentTime(seconds);
} }
} }
// Force playback rate to be correct
const playbackRate = this.player.playbackRate();
this.player.defaultPlaybackRate(playbackRate);
} }
return ( return (

View File

@@ -46,7 +46,7 @@ describe('Cameras Route', () => {
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
expect(screen.queryAllByText('Recordings')).toHaveLength(2); expect(screen.queryAllByText('Recordings')).toHaveLength(1);
}); });
test('buttons toggle detect, clips, and snapshots', async () => { test('buttons toggle detect, clips, and snapshots', async () => {